├── .editorconfig
├── .github
└── workflows
│ └── ci.yaml
├── .gitignore
├── .scrutinizer.yml
├── CONTRIBUTING.md
├── LICENSE.txt
├── README.md
├── composer.json
├── phpcs.xml
├── phpunit.xml.dist
├── src
├── Event
│ ├── EventListener.php
│ └── VersionListener.php
├── Model
│ └── Behavior
│ │ ├── Version
│ │ └── VersionTrait.php
│ │ └── VersionBehavior.php
└── VersionPlugin.php
└── tests
├── Fixture
├── ArticlesFixture.php
├── ArticlesTagsFixture.php
├── ArticlesTagsVersionsFixture.php
├── VersionsFixture.php
└── VersionsWithUserFixture.php
├── TestApp
└── Model
│ └── Entity
│ └── Test.php
├── TestCase
└── Model
│ └── Behavior
│ └── VersionBehaviorTest.php
├── bootstrap.php
└── schema.php
/.editorconfig:
--------------------------------------------------------------------------------
1 | ; This file is for unifying the coding style for different editors and IDEs.
2 | ; More information at http://editorconfig.org
3 |
4 | root = false
5 |
6 | [*]
7 | indent_style = space
8 | indent_size = 4
9 | charset = "utf-8"
10 | end_of_line = lf
11 | insert_final_newline = true
12 | trim_trailing_whitespace = true
13 |
14 | [*.yml]
15 | indent_style = space
16 | indent_size = 2
17 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on: [push]
4 |
5 | jobs:
6 | testsuite:
7 | strategy:
8 | matrix:
9 | php-versions: ['8.1', '8.2', '8.3']
10 |
11 | runs-on: ubuntu-latest
12 |
13 | steps:
14 | - uses: actions/checkout@v4
15 |
16 | - name: Setup PHP
17 | uses: shivammathur/setup-php@v2
18 | with:
19 | php-version: ${{ matrix.php-version }}
20 | extensions: mbstring, intl-72.1
21 | ini-values: zend.assertions = 1
22 |
23 | - name: Install composer dependencies.
24 | run: composer install --no-interaction --prefer-dist --optimize-autoloader
25 |
26 | - name: Run PHPUnit.
27 | run: vendor/bin/phpunit
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .phpunit.cache/
2 | vendor/
3 | composer.lock
4 |
--------------------------------------------------------------------------------
/.scrutinizer.yml:
--------------------------------------------------------------------------------
1 | imports:
2 | - php
3 |
4 | filter:
5 | excluded_paths:
6 | - docs/
7 | - tests/
8 | tools:
9 | php_mess_detector: true
10 | php_cpd:
11 | excluded_dirs:
12 | - docs/
13 | - tests/
14 | php_loc:
15 | excluded_dirs:
16 | - docs/
17 | - tests/
18 | php_pdepend:
19 | excluded_dirs:
20 | 1: docs/
21 | 2: tests/
22 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | Version loves to welcome your contributions. There are several ways to help out:
4 | * Create a ticket in GitHub, if you have found a bug
5 | * Write testcases for open bug tickets
6 | * Write patches for open bug/feature tickets, preferably with testcases included
7 | * Contribute to the [documentation](https://github.com/josegonzalez/cakephp-version/tree/gh-pages)
8 |
9 | There are a few guidelines that we need contributors to follow so that we have a
10 | chance of keeping on top of things.
11 |
12 | ## Getting Started
13 |
14 | * Make sure you have a [GitHub account](https://github.com/signup/free)
15 | * Submit a ticket for your issue, assuming one does not already exist.
16 | * Clearly describe the issue including steps to reproduce when it is a bug.
17 | * Make sure you fill in the earliest version that you know has the issue.
18 | * Fork the repository on GitHub.
19 |
20 | ## Making Changes
21 |
22 | * Create a topic branch from where you want to base your work.
23 | * This is usually the develop branch
24 | * To quickly create a topic branch based on master; `git branch
25 | master/my_contribution master` then checkout the new branch with `git
26 | checkout master/my_contribution`. Better avoid working directly on the
27 | `master` branch, to avoid conflicts if you pull in updates from origin.
28 | * Make commits of logical units.
29 | * Check for unnecessary whitespace with `git diff --check` before committing.
30 | * Use descriptive commit messages and reference the #ticket number
31 | * Core testcases should continue to pass. You can run tests locally or enable
32 | [travis-ci](https://travis-ci.org/) for your fork, so all tests and codesniffs
33 | will be executed.
34 | * Your work should apply the CakePHP coding standards.
35 |
36 | ## Which branch to base the work
37 |
38 | * Bugfix branches will be based on develop branch.
39 | * New features that are backwards compatible will be based on develop branch
40 | * New features or other non-BC changes will go in the next major release branch.
41 |
42 | ## Submitting Changes
43 |
44 | * Push your changes to a topic branch in your fork of the repository.
45 | * Submit a pull request to the repository with the correct target branch.
46 |
47 | ## Testcases and codesniffer
48 |
49 | Version tests requires [PHPUnit](http://www.phpunit.de/manual/current/en/installation.html)
50 | 3.5 or higher. To run the testcases locally use the following command:
51 |
52 | ./lib/Cake/Console/cake test Version AllVersion
53 |
54 | To run the sniffs for CakePHP coding standards
55 |
56 | phpcs -p --extensions=php --standard=CakePHP ./app/Plugin/Version
57 |
58 | Check the [cakephp-codesniffer](https://github.com/cakephp/cakephp-codesniffer)
59 | repository to setup the CakePHP standard. The README contains installation info
60 | for the sniff and phpcs.
61 |
62 |
63 | # Additional Resources
64 |
65 | * [CakePHP coding standards](http://book.cakephp.org/2.0/en/contributing/cakephp-coding-conventions.html)
66 | * [Bug tracker](https://github.com/josegonzalez/cakephp-version/issues)
67 | * [General GitHub documentation](https://help.github.com/)
68 | * [GitHub pull request documentation](https://help.github.com/send-pull-requests/)
69 | * #cakephp IRC channel on freenode.org
70 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2014 Jose Diaz-Gonzalez
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/josegonzalez/cakephp-version)
2 | [](https://coveralls.io/r/josegonzalez/cakephp-version?branch=master)
3 | [](https://packagist.org/packages/josegonzalez/cakephp-version)
4 | [](https://packagist.org/packages/josegonzalez/cakephp-version)
5 | [](https://readthedocs.org/projects/cakephp-version/?badge=latest)
6 | [](https://gratipay.com/~josegonzalez/)
7 |
8 | # Version
9 |
10 | A CakePHP 4.x plugin that facilitates versioned database entities
11 |
12 | ## Installation
13 |
14 | Add the following lines to your application's `composer.json`:
15 |
16 | ```json
17 | "require": {
18 | "josegonzalez/cakephp-version": "dev-master"
19 | }
20 | ```
21 |
22 | followed by the command:
23 |
24 | `composer update`
25 |
26 | Or run the following command directly without changing your `composer.json`:
27 |
28 | `composer require josegonzalez/cakephp-version:dev-master`
29 |
30 | ## Usage
31 |
32 | In your app's `config/bootstrap.php` add:
33 |
34 | ```php
35 | Plugin::load('Josegonzalez/Version', ['bootstrap' => true]);
36 | ```
37 |
38 | ## Usage
39 |
40 | Run the following schema migration:
41 |
42 | ```sql
43 | CREATE TABLE `version` (
44 | `id` int(11) NOT NULL AUTO_INCREMENT,
45 | `version_id` int(11) DEFAULT NULL,
46 | `model` varchar(255) NOT NULL,
47 | `foreign_key` int(10) NOT NULL,
48 | `field` varchar(255) NOT NULL,
49 | `content` text NULL,
50 | `created` datetime NOT NULL,
51 | PRIMARY KEY (`id`)
52 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
53 | ```
54 |
55 | > Note that the `content` field must be nullable if you want to be able to version any nullable fields in your application.
56 |
57 | > You may optionally add a `version_id` field of type `integer` to the table which is being versioned. This will store the latest version number of a given page.
58 |
59 | If you wish to create the table using `cakephp/migrations` then you will need to use a migration that looks something like this:
60 |
61 | ```php
62 | table('version')
71 | ->addColumn('version_id', 'integer', ['null' => true])
72 | ->addColumn('model', 'string')
73 | ->addColumn('foreign_key', 'integer')
74 | ->addColumn('field', 'string')
75 | ->addColumn('content', 'text', ['null' => true])
76 | ->addColumn('created', 'datetime')
77 | ->create();
78 | }
79 | }
80 | ```
81 |
82 | Add the following line to your entities:
83 |
84 | ```php
85 | use \Josegonzalez\Version\Model\Behavior\Version\VersionTrait;
86 | ```
87 |
88 | And then include the trait in the entity class:
89 |
90 | ```php
91 | class PostEntity extends Entity {
92 | use VersionTrait;
93 | }
94 | ```
95 |
96 | Attach the behavior in the models you want with:
97 |
98 | ```php
99 | public function initialize(array $config) {
100 | $this->addBehavior('Josegonzalez/Version.Version');
101 | }
102 | ```
103 |
104 | Whenever an entity is persisted - whether via insert or update - that entity is also persisted to the `version` table. You can access a given revision by executing the following code:
105 |
106 | ```php
107 | // Will contain a generic `Entity` populated with data from the specified version.
108 | $version = $entity->version(1);
109 | ```
110 |
111 | You can optionally retrieve all the versions:
112 |
113 | ```php
114 | $versions = $entity->versions();
115 | ```
116 |
117 | ### Storing Additional Meta Data
118 |
119 | `cakephp-version` dispatches an event `Model.Version.beforeSave` which you can optionally handle to attach additional meta-data about the version.
120 |
121 | Add the necessary additional fields to your migration, for example:
122 |
123 | ```sql
124 | CREATE TABLE `version` (
125 | `id` int(11) NOT NULL AUTO_INCREMENT,
126 | `version_id` int(11) DEFAULT NULL,
127 | `model` varchar(255) NOT NULL,
128 | `foreign_key` int(10) NOT NULL,
129 | `field` varchar(255) NOT NULL,
130 | `content` text,
131 | `created` datetime NOT NULL,
132 | `custom_field1` varchar(255) NOT NULL, /* column to store our metadata */
133 | `custom_field2` varchar(255) NOT NULL, /* column to store our metadata */
134 | PRIMARY KEY (`id`)
135 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
136 | ```
137 |
138 | Then define an event listener to handle the event and pass in additional metadata, for example:
139 |
140 | ```php
141 | use Cake\Event\Event;
142 | use Cake\Event\EventListenerInterface;
143 |
144 | class VersionListener implements EventListenerInterface {
145 |
146 | public function implementedEvents() {
147 | return array(
148 | 'Model.Version.beforeSave' => 'insertAdditionalData',
149 | );
150 | }
151 |
152 | public function insertAdditionalData(Event $event) {
153 | return [
154 | 'custom_field1' => 'foo',
155 | 'custom_field2' => 'bar'
156 | ];
157 | }
158 | }
159 | ```
160 |
161 | Your event listener can then be attached in your project, for example:
162 |
163 | ```php
164 | use App\Event\VersionListener;
165 | use Cake\Event\EventManager;
166 |
167 | $VersionListener = new VersionListener();
168 | EventManager::instance()->on($VersionListener);
169 | ```
170 |
171 | Note that handling this event also allows you to modify/overwrite values generated by the plugin.
172 | This can provide useful functionality, but ensure that if your event listener returns array keys called
173 | `version_id`, `model`, `foreign_key`, `field`, `content` or `created` that this is the intended behavior.
174 |
175 | #### Storing user_id as Meta Data
176 | To store the `user_id` as additional meta data is easiest in combination with [Muffin/Footprint](https://github.com/UseMuffin/Footprint).
177 | The above `insertAdditionalData()` method could then look like this:
178 |
179 | ```php
180 | /**
181 | * @param \Cake\Event\Event $event
182 | *
183 | * @return array
184 | */
185 | public function insertAdditionalData(Event $event)
186 | {
187 | $data = [
188 | ...
189 | ];
190 |
191 | if ($event->data('_footprint')) {
192 | $user = $event->data('_footprint');
193 | $data += [
194 | 'user_id' => $user->id,
195 | ];
196 | }
197 |
198 | return $data;
199 | }
200 | ```
201 | Any controller with the `FootprintAwareTrait` used will then provide the `_footprint` data into the model layer for this event callback to use.
202 |
203 | ### Bake Integration
204 |
205 | If you load the plugin using `'bootstrap' => true`, this plugin can be used to autodetect usage via the properly named database table. To do so, simply create a table with the `version` schema above named after the table you'd like to revision plus the suffix `_versions`. For instance, to version the following table:
206 |
207 | ```sql
208 | CREATE TABLE `posts` (
209 | `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
210 | `category_id` int(11) DEFAULT NULL,
211 | `user_id` int(11) DEFAULT NULL,
212 | `status` varchar(255) NOT NULL DEFAULT 'published',
213 | `visibility` varchar(255) NOT NULL DEFAULT 'public',
214 | `title` varchar(255) NOT NULL DEFAULT '',
215 | `route` varchar(255) DEFAULT NULL,
216 | `content` text,
217 | `published_date` datetime DEFAULT NULL,
218 | `created` datetime NOT NULL,
219 | `modified` datetime NOT NULL,
220 | PRIMARY KEY (`id`)
221 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
222 | ```
223 |
224 | Create the following table:
225 |
226 | ```sql
227 | CREATE TABLE `posts_versions` (
228 | `id` int(11) NOT NULL AUTO_INCREMENT,
229 | `version_id` int(11) NOT NULL,
230 | `model` varchar(255) NOT NULL,
231 | `foreign_key` int(11) NOT NULL,
232 | `field` varchar(255) NOT NULL,
233 | `content` text,
234 | `created` datetime NOT NULL,
235 | PRIMARY KEY (`id`)
236 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
237 | ```
238 |
239 | You can create a migration for this with the following bake command:
240 |
241 | ```shell
242 | bin/cake bake migration create_posts_versions version_id:integer model foreign_key:integer field content:text created
243 | ```
244 |
245 | > You'll also want to set the `content` field in this migration to nullable, otherwise you won't be able to version fields that can be nulled.
246 |
247 | To track the current version in the `posts` table, you can create a migration to add the `version_id` field to the table:
248 |
249 | ```shell
250 | bin/cake bake migration add_version_id_to_posts version_id:integer
251 | ```
252 |
253 | ### Configuration
254 |
255 | There are five behavior configurations that may be used:
256 |
257 | - `versionTable`: (Default: `version`) The name of the table to be used to store versioned data. It may be useful to use a different table when versioning multiple types of entities.
258 | - `versionField`: (Default: `version_id`) The name of the field in the versioned table that will store the current version. If missing, the plugin will continue to work as normal.
259 | - `additionalVersionFields`: (Default `['created']`) The additional or custom fields of the versioned table to be exposed as well. By default prefixed with `version_`, e.g. `'version_user_id'` for `'user_id'`.
260 | - `referenceName`: (Default: db table name) Discriminator used to identify records in the version table.
261 | - `onlyDirty`: (Default: false) Set to true to version only dirty properties.
262 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "josegonzalez/cakephp-version",
3 | "description": "CakePHP ORM behavior to allow versioning of records",
4 | "type": "cakephp-plugin",
5 | "keywords": ["cakephp", "orm", "revision", "version"],
6 | "homepage": "https://github.com/josegonzalez/cakephp-version",
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "Jose Diaz-Gonzalez",
11 | "email": "cakephp+version@josediazgonzalez.com"
12 | }
13 | ],
14 | "require": {
15 | "php": "^8.1",
16 | "cakephp/orm": "^5.0"
17 | },
18 | "require-dev": {
19 | "cakephp/cakephp": "^5.0",
20 | "cakephp/cakephp-codesniffer": "^5.1",
21 | "php-coveralls/php-coveralls": "^0.4.0",
22 | "phpstan/phpstan": "^1.10",
23 | "phpunit/phpunit": "^10.5"
24 | },
25 | "autoload": {
26 | "psr-4": {
27 | "Josegonzalez\\Version\\": "src/"
28 | }
29 | },
30 | "autoload-dev": {
31 | "psr-4": {
32 | "Josegonzalez\\Version\\Test\\": "tests/"
33 | }
34 | },
35 | "config": {
36 | "sort-packages": true,
37 | "allow-plugins": {
38 | "dealerdirect/phpcodesniffer-composer-installer": true
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/phpcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | tests/TestCase/
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | src/
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/Event/EventListener.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Event;
15 |
16 | use Cake\Event\EventDispatcherTrait;
17 | use Cake\Event\EventInterface;
18 | use Cake\Event\EventListenerInterface;
19 |
20 | /**
21 | * Class EventListener
22 | *
23 | * @category CakePHP-Plugin
24 | * @package Josegonzalez\Version\Event
25 | * @author Jose Diaz-Gonzalez
26 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
27 | * @link https://github.com/josegonzalez/cakephp-version
28 | */
29 | abstract class EventListener implements EventListenerInterface
30 | {
31 | use EventDispatcherTrait;
32 |
33 | /**
34 | * The EventInterface attached to this class
35 | *
36 | * @var \Cake\Event\EventInterface $event Event instance.
37 | */
38 | protected EventInterface $event;
39 |
40 | /**
41 | * Constructor.
42 | *
43 | * @param \Cake\Event\EventInterface $event Event instance.
44 | */
45 | public function __construct(EventInterface $event)
46 | {
47 | $this->event = $event;
48 | $this->getEventManager()->on($this);
49 | }
50 |
51 | /**
52 | * Dispatches all the attached events
53 | *
54 | * @return void
55 | */
56 | public function execute(): void
57 | {
58 | $methods = array_values($this->implementedEvents());
59 | foreach ($methods as $method) {
60 | $this->dispatchEvent(sprintf('Bake.%s', $method), [], $this->event->getSubject());
61 | }
62 | }
63 |
64 | /**
65 | * Check whether or not a bake call is a certain type.
66 | *
67 | * @param string $type The type of file you want to check.
68 | * @return bool Whether or not the bake template is the type you are checking.
69 | */
70 | public function isType(string $type): bool
71 | {
72 | $template = sprintf('Bake/%s.ctp', $type);
73 |
74 | return strpos($this->event->getData('0'), $template) !== false;
75 | }
76 |
77 | /**
78 | * Get the callbacks this class is interested in.
79 | *
80 | * @return array
81 | */
82 | public function implementedEvents(): array
83 | {
84 | $methodMap = [
85 | 'config/routes' => 'beforeRenderRoutes',
86 | 'Controller/component' => 'beforeRenderComponent',
87 | 'Controller/controller' => 'beforeRenderController',
88 | 'Model/behavior' => 'beforeRenderBehavior',
89 | 'Model/entity' => 'beforeRenderEntity',
90 | 'Model/table' => 'beforeRenderTable',
91 | 'Shell/shell' => 'beforeRenderShell',
92 | 'View/cell' => 'beforeRenderCell',
93 | 'View/helper' => 'beforeRenderHelper',
94 | 'tests/test_case' => 'beforeRenderTestCase',
95 | ];
96 |
97 | $events = [];
98 | foreach ($methodMap as $template => $method) {
99 | if ($this->isType($template) && method_exists($this, $method)) {
100 | $events[sprintf('Bake.%s', $method)] = $method;
101 | }
102 | }
103 |
104 | return $events;
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/Event/VersionListener.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Event;
15 |
16 | use Cake\Database\Connection;
17 | use Cake\Datasource\ConnectionManager;
18 | use Cake\Event\EventInterface;
19 | use Cake\Utility\Hash;
20 |
21 | /**
22 | * Class VersionListener
23 | *
24 | * @category CakePHP-Plugin
25 | * @package Josegonzalez\Version\Event
26 | * @author Jose Diaz-Gonzalez
27 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
28 | * @link https://github.com/josegonzalez/cakephp-version
29 | */
30 | class VersionListener extends EventListener
31 | {
32 | /**
33 | * Called before the entity template is rendered
34 | *
35 | * @param \Cake\Event\EventInterface $event An Event instance
36 | * @return void
37 | */
38 | public function beforeRenderEntity(EventInterface $event): void
39 | {
40 | $this->checkAssociation($event, 'versions');
41 | }
42 |
43 | /**
44 | * Called before the test case template is rendered
45 | *
46 | * @param \Cake\Event\EventInterface $event An Event instance
47 | * @return void
48 | */
49 | public function beforeRenderTestCase(EventInterface $event): void
50 | {
51 | $name = $event->getSubject()->viewVars['subject'];
52 | $pattern = '/^' . preg_quote($name) . '_(\w+)_version$/';
53 | foreach (array_keys($event->getSubject()->viewVars['fixtures']) as $key) {
54 | if (preg_match($pattern, $key)) {
55 | unset($event->getSubject()->viewVars['fixtures'][$key]);
56 | }
57 | }
58 | }
59 |
60 | /**
61 | * Called before the table template is rendered
62 | *
63 | * @param \Cake\Event\EventInterface $event An Event instance
64 | * @return void
65 | */
66 | public function beforeRenderTable(EventInterface $event): void
67 | {
68 | $this->checkAssociation($event, 'versions');
69 | $this->fixVersionTables($event);
70 | }
71 |
72 | /**
73 | * Removes unnecessary associations
74 | *
75 | * @param \Cake\Event\EventInterface $event An Event instance
76 | * @return void
77 | */
78 | protected function fixVersionTables(EventInterface $event): void
79 | {
80 | if (!preg_match('/Versions$/', $event->getSubject()->viewVars['name'])) {
81 | return;
82 | }
83 |
84 | unset($event->getSubject()->viewVars['rulesChecker']['version_id']);
85 | foreach ($event->getSubject()->viewVars['associations']['belongsTo'] as $i => $association) {
86 | if ($association['alias'] === 'Versions' && $association['foreignKey'] === 'version_id') {
87 | unset($event->getSubject()->viewVars['associations']['belongsTo'][$i]);
88 | }
89 | }
90 | }
91 |
92 | /**
93 | * Attaches the behavior and modifies associations as necessary
94 | *
95 | * @param \Cake\Event\EventInterface $event An Event instance
96 | * @param string $tableSuffix a suffix for the primary table
97 | * @return bool true if modified, false otherwise
98 | */
99 | protected function checkAssociation(EventInterface $event, string $tableSuffix): bool
100 | {
101 | $subject = $event->getSubject();
102 | $connection = ConnectionManager::get($subject->viewVars['connection']);
103 | assert($connection instanceof Connection);
104 | $schema = $connection->getSchemaCollection();
105 |
106 | $versionTable = sprintf('%s_%s', Hash::get($event->getSubject()->viewVars, 'table'), $tableSuffix);
107 | if (!in_array($versionTable, $schema->listTables())) {
108 | return false;
109 | }
110 |
111 | $event->getSubject()->viewVars['behaviors']['Josegonzalez/Version.Version'] = [
112 | sprintf("'versionTable' => '%s'", $versionTable),
113 | ];
114 |
115 | $event->getSubject()->viewVars['associations']['belongsTo'] = $this->modifyBelongsTo($event);
116 | $event->getSubject()->viewVars['rulesChecker'] = $this->modifyRulesChecker($event);
117 |
118 | return true;
119 | }
120 |
121 | /**
122 | * Removes unnecessary belongsTo associations
123 | *
124 | * @param \Cake\Event\EventInterface $event An Event instance
125 | * @return array
126 | */
127 | protected function modifyBelongsTo(EventInterface $event): array
128 | {
129 | $belongsTo = $event->getSubject()->viewVars['associations']['belongsTo'];
130 |
131 | foreach ($belongsTo as $i => $association) {
132 | if ($association['alias'] !== 'Versions' || $association['foreignKey'] !== 'version_id') {
133 | continue;
134 | }
135 |
136 | unset($belongsTo[$i]);
137 | }
138 |
139 | return $belongsTo;
140 | }
141 |
142 | /**
143 | * Removes unnecessary rulesChecker entries
144 | *
145 | * @param \Cake\Event\EventInterface $event An Event instance
146 | * @return array
147 | */
148 | protected function modifyRulesChecker(EventInterface $event): array
149 | {
150 | $rulesChecker = $event->getSubject()->viewVars['rulesChecker'];
151 |
152 | foreach ($rulesChecker as $key => $config) {
153 | if (Hash::get($config, 'extra') !== 'Versions' || $key !== 'version_id') {
154 | continue;
155 | }
156 |
157 | unset($rulesChecker[$key]);
158 | }
159 |
160 | return $rulesChecker;
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/Model/Behavior/Version/VersionTrait.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Model\Behavior\Version;
15 |
16 | use Cake\Datasource\FactoryLocator;
17 | use Cake\ORM\Entity;
18 |
19 | /**
20 | * Trait VersionTrait
21 | *
22 | * @category CakePHP-Plugin
23 | * @package Josegonzalez\Version\Model\Behavior\Version
24 | * @author Jose Diaz-Gonzalez
25 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
26 | * @link https://github.com/josegonzalez/cakephp-version
27 | */
28 | trait VersionTrait
29 | {
30 | /**
31 | * Retrieves a specified version for the current entity
32 | *
33 | * @param int $versionId The version number to retrieve
34 | * @param bool $reset If true, will re-retrieve the related version collection
35 | * @return \Cake\ORM\Entity|null
36 | */
37 | public function version(int $versionId, bool $reset = false): ?Entity
38 | {
39 | $versions = $this->versions($reset);
40 |
41 | return $versions[$versionId] ?? null;
42 | }
43 |
44 | /**
45 | * Retrieves the related versions for the current entity
46 | *
47 | * @param bool $reset If true, will re-retrieve the related version collection
48 | * @return array<\Cake\ORM\Entity>
49 | */
50 | public function versions(bool $reset = false): array
51 | {
52 | if ($reset === false && $this->hasValue('_versions')) {
53 | return $this->get('_versions');
54 | }
55 |
56 | /*
57 | * @var \Josegonzalez\Version\Model\Behavior\VersionBehavior $table
58 | * @var \Cake\Datasource\EntityInterface $this
59 | */
60 | $table = FactoryLocator::get('Table')->get($this->getSource());
61 | $versions = $table->getVersions($this);
62 | $this->set('_versions', $versions);
63 |
64 | return $this->get('_versions');
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/Model/Behavior/VersionBehavior.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Model\Behavior;
15 |
16 | use ArrayObject;
17 | use Cake\Collection\Collection;
18 | use Cake\Collection\CollectionInterface;
19 | use Cake\Database\TypeFactory;
20 | use Cake\Datasource\EntityInterface;
21 | use Cake\Datasource\ResultSetInterface;
22 | use Cake\Event\Event;
23 | use Cake\ORM\Association;
24 | use Cake\ORM\Behavior;
25 | use Cake\ORM\Locator\LocatorAwareTrait;
26 | use Cake\ORM\Query;
27 | use Cake\Utility\Hash;
28 | use Cake\Utility\Inflector;
29 | use DateTime;
30 | use InvalidArgumentException;
31 | use function Cake\Core\namespaceSplit;
32 |
33 | /**
34 | * This behavior provides a way to version dynamic data by keeping versions
35 | * in a separate table linked to the original record from another one. Versioned
36 | * fields can be configured to override those in the main table when fetched or
37 | * put aside into another property for the same entity.
38 | *
39 | * If you want to retrieve all versions for each of the fetched records,
40 | * you can use the custom `versions` finders that is exposed to the table.
41 | *
42 | * @category CakePHP-Plugin
43 | * @package Josegonzalez\Version\Model\Behavior
44 | * @author Jose Diaz-Gonzalez
45 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
46 | * @link https://github.com/josegonzalez/cakephp-version
47 | */
48 | class VersionBehavior extends Behavior
49 | {
50 | use LocatorAwareTrait;
51 |
52 | /**
53 | * Default config
54 | *
55 | * These are merged with user-provided configuration when the behavior is used.
56 | *
57 | * @var array
58 | */
59 | protected array $_defaultConfig = [
60 | 'implementedFinders' => ['versions' => 'findVersions'],
61 | 'versionTable' => 'version',
62 | 'versionField' => 'version_id',
63 | 'additionalVersionFields' => ['created'],
64 | 'fields' => null,
65 | 'foreignKey' => 'foreign_key',
66 | 'referenceName' => null,
67 | 'onlyDirty' => false,
68 | ];
69 |
70 | /**
71 | * Constructor hook method.
72 | *
73 | * Implement this method to avoid having to overwrite
74 | * the constructor and call parent.
75 | *
76 | * @param array $config The configuration settings provided to this behavior.
77 | * @return void
78 | */
79 | public function initialize(array $config): void
80 | {
81 | if ($this->_config['referenceName'] == null) {
82 | $this->_config['referenceName'] = $this->referenceName();
83 | }
84 | $this->setupFieldAssociations($this->_config['versionTable']);
85 | }
86 |
87 | /**
88 | * Creates the associations between the bound table and every field passed to
89 | * this method.
90 | *
91 | * Additionally it creates a `version` HasMany association that will be
92 | * used for fetching all versions for each record in the bound table
93 | *
94 | * @param string $table the table name to use for storing each field version
95 | * @return void
96 | */
97 | public function setupFieldAssociations(string $table): void
98 | {
99 | $options = [
100 | 'table' => $table,
101 | ];
102 |
103 | foreach ($this->fields() as $field) {
104 | $this->versionAssociation($field, $options);
105 | }
106 |
107 | $this->versionAssociation(null, $options);
108 | }
109 |
110 | /**
111 | * Returns association object for all versions or single field version.
112 | *
113 | * @param string|null $field Field name for per-field association.
114 | * @param array $options Association options.
115 | * @return \Cake\ORM\Association
116 | */
117 | public function versionAssociation(?string $field = null, array $options = []): Association
118 | {
119 | $name = $this->associationName($field);
120 |
121 | if (!$this->_table->associations()->has($name)) {
122 | $model = $this->_config['referenceName'];
123 |
124 | $options += [
125 | 'className' => $this->_config['versionTable'],
126 | 'foreignKey' => $this->_config['foreignKey'],
127 | 'strategy' => 'subquery',
128 | 'dependent' => true,
129 | ];
130 |
131 | if ($field) {
132 | $options += [
133 | 'conditions' => [
134 | $name . '.model' => $model,
135 | $name . '.field' => $field,
136 | ],
137 | 'propertyName' => $field . '_version',
138 | ];
139 | } else {
140 | $options += [
141 | 'conditions' => [
142 | $name . '.model' => $model,
143 | ],
144 | 'propertyName' => '__version',
145 | ];
146 | }
147 |
148 | $this->_table->hasMany($name, $options);
149 | }
150 |
151 | return $this->_table->getAssociation($name);
152 | }
153 |
154 | /**
155 | * Modifies the entity before it is saved so that versioned fields are persisted
156 | * in the database too.
157 | *
158 | * @param \Cake\Event\Event $event The beforeSave event that was fired
159 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
160 | * @param \ArrayObject $options the options passed to the save method
161 | * @return void
162 | */
163 | public function beforeSave(Event $event, EntityInterface $entity, ArrayObject $options): void
164 | {
165 | $association = $this->versionAssociation();
166 | $name = $association->getName();
167 | $newOptions = [$name => ['validate' => false]];
168 | $options['associated'] = $newOptions + $options['associated'];
169 |
170 | $fields = $this->fields();
171 | $values = $entity->extract($fields, $this->_config['onlyDirty']);
172 |
173 | $primaryKey = (array)$this->_table->getPrimaryKey();
174 | $versionField = $this->_config['versionField'];
175 |
176 | if (isset($options['versionId'])) {
177 | $versionId = $options['versionId'];
178 | } else {
179 | $versionId = $this->getVersionId($entity) + 1;
180 | }
181 | $created = new DateTime();
182 | $new = [];
183 | $entityClass = $this->getTableLocator()->get($this->_config['versionTable'])->getEntityClass();
184 | foreach ($values as $field => $content) {
185 | if (in_array($field, $primaryKey) || $field == $versionField) {
186 | continue;
187 | }
188 |
189 | $converted = $this->convertFieldsToType([$field => $content], 'toDatabase');
190 |
191 | $data = [
192 | 'version_id' => $versionId,
193 | 'model' => $this->_config['referenceName'],
194 | 'field' => $field,
195 | 'content' => $converted[$field],
196 | 'created' => $created,
197 | ] + $this->extractForeignKey($entity);
198 |
199 | $userData = $this->_table->dispatchEvent('Model.Version.beforeSave', (array)$options);
200 | if ($userData !== null && $userData->getResult() !== null && is_array($userData->getResult())) {
201 | $data = array_merge($data, $userData->getResult());
202 | }
203 |
204 | $new[$field] = new $entityClass(
205 | $data,
206 | [
207 | 'useSetters' => false,
208 | 'markNew' => true,
209 | ]
210 | );
211 | }
212 |
213 | $entity->set($association->getProperty(), $new);
214 | if (!empty($versionField) && in_array($versionField, $this->_table->getSchema()->columns())) {
215 | $entity->set($this->_config['versionField'], $versionId);
216 | }
217 | }
218 |
219 | /**
220 | * Unsets the temporary `__version` property after the entity has been saved
221 | *
222 | * @param \Cake\Event\Event $event The beforeSave event that was fired
223 | * @param \Cake\Datasource\EntityInterface $entity The entity that is going to be saved
224 | * @return void
225 | */
226 | public function afterSave(Event $event, EntityInterface $entity): void
227 | {
228 | $property = $this->versionAssociation()->getProperty();
229 | $entity->unset($property);
230 | }
231 |
232 | /**
233 | * Return the last version id
234 | *
235 | * @param \Cake\Datasource\EntityInterface $entity Entity.
236 | * @return int
237 | */
238 | public function getVersionId(EntityInterface $entity): int
239 | {
240 | $table = $this->getTableLocator()->get($this->_config['versionTable']);
241 | $extractedKey = $this->extractForeignKey($entity);
242 |
243 | //If any extracted key is null (in case of new entity), don't trigger db-query.
244 | foreach ($extractedKey as $key) {
245 | if ($key === null) {
246 | $preexistent = [];
247 | continue;
248 | }
249 | }
250 |
251 | if (!isset($preexistent)) {
252 | $preexistent = $table->find()
253 | ->select(['version_id'])
254 | ->where(
255 | [
256 | 'model' => $this->_config['referenceName'],
257 | ] + $extractedKey
258 | )
259 | ->orderBy(['id desc'])
260 | ->limit(1)
261 | ->enableHydration(false)
262 | ->toArray();
263 | }
264 |
265 | return Hash::get($preexistent, '0.version_id', 0);
266 | }
267 |
268 | /**
269 | * Custom finder method used to retrieve all versions for the found records.
270 | *
271 | * Versioned values will be found for each entity under the property `_versions`.
272 | *
273 | * ### Example:
274 | *
275 | * {{{
276 | * $article = $articles->find('versions')->first();
277 | * $firstVersion = $article->get('_versions')[1];
278 | * }}}
279 | *
280 | * @param \Cake\ORM\Query $query The original query to modify
281 | * @param array $options Options
282 | * @return \Cake\ORM\Query
283 | */
284 | public function findVersions(Query $query, array $options): Query
285 | {
286 | $association = $this->versionAssociation();
287 | $name = $association->getName();
288 |
289 | return $query
290 | ->contain(
291 | [$name => function (Query $q) use ($name, $options) {
292 | if (!empty($options['primaryKey'])) {
293 | $foreignKey = (array)$this->_config['foreignKey'];
294 | $aliasedFK = [];
295 | foreach ($foreignKey as $field) {
296 | $aliasedFK[] = "$name.$field";
297 | }
298 | $conditions = array_combine($aliasedFK, (array)$options['primaryKey']);
299 | $q->where($conditions);
300 | }
301 | if (!empty($options['versionId'])) {
302 | $q->where(["$name.version_id IN" => $options['versionId']]);
303 | }
304 | $q->where(["$name.field IN" => $this->fields()]);
305 |
306 | return $q;
307 | }]
308 | )
309 | ->formatResults($this->groupVersions(...), $query::PREPEND);
310 | }
311 |
312 | /**
313 | * Modifies the results from a table find in order to merge full version records
314 | * into each entity under the `_versions` key
315 | *
316 | * @param \Cake\Datasource\ResultSetInterface $results Results to modify.
317 | * @return \Cake\Collection\CollectionInterface
318 | */
319 | public function groupVersions(ResultSetInterface $results): CollectionInterface
320 | {
321 | $property = $this->versionAssociation()->getProperty();
322 |
323 | return $results->map(
324 | function (EntityInterface $row) use ($property) {
325 | if ($row->has('_versions')) {
326 | return $row;
327 | }
328 |
329 | $versionField = $this->_config['versionField'];
330 | $versions = (array)$row->get($property);
331 | $grouped = new Collection($versions);
332 |
333 | $result = [];
334 | foreach ($grouped->combine('field', 'content', 'version_id') as $versionId => $keys) {
335 | $entityClass = $this->_table->getEntityClass();
336 | $versionData = [
337 | $versionField => $versionId,
338 | ];
339 |
340 | $keys = $this->convertFieldsToType($keys, 'toPHP');
341 |
342 | /** @var \Cake\Datasource\EntityInterface $versionRow */
343 | $versionRow = $grouped->match(['version_id' => $versionId])->first();
344 |
345 | foreach ($this->_config['additionalVersionFields'] as $mappedField => $field) {
346 | if (!is_string($mappedField)) {
347 | $mappedField = 'version_' . $field;
348 | }
349 | $versionData[$mappedField] = $versionRow->get($field);
350 | }
351 |
352 | $version = new $entityClass(
353 | $keys + $versionData,
354 | [
355 | 'markNew' => false,
356 | 'useSetters' => false,
357 | 'markClean' => true,
358 | ]
359 | );
360 | $result[$versionId] = $version;
361 | }
362 |
363 | $options = ['setter' => false, 'guard' => false];
364 | $row->set('_versions', $result, $options);
365 | unset($row[$property]);
366 | $row->clean();
367 |
368 | return $row;
369 | }
370 | );
371 | }
372 |
373 | /**
374 | * Returns the versions of a specific entity.
375 | *
376 | * @param \Cake\Datasource\EntityInterface $entity Entity.
377 | * @return array<\Cake\Datasource\EntityInterface>
378 | */
379 | public function getVersions(EntityInterface $entity): array
380 | {
381 | $primaryKey = (array)$this->_table->getPrimaryKey();
382 |
383 | $query = $this->_table->find('versions');
384 | $pkValue = $entity->extract($primaryKey);
385 | $conditions = [];
386 | foreach ($pkValue as $key => $value) {
387 | $field = current($query->aliasField($key));
388 | $conditions[$field] = $value;
389 | }
390 | $entities = $query->where($conditions)->all();
391 |
392 | if ($entities->isEmpty()) {
393 | return [];
394 | }
395 |
396 | $entity = $entities->first();
397 |
398 | return $entity->get('_versions');
399 | }
400 |
401 | /**
402 | * Returns an array of fields to be versioned.
403 | *
404 | * @return array
405 | */
406 | protected function fields(): array
407 | {
408 | $schema = $this->_table->getSchema();
409 | $fields = $schema->columns();
410 | if ($this->_config['fields'] !== null) {
411 | $fields = array_intersect($fields, (array)$this->_config['fields']);
412 | }
413 |
414 | return $fields;
415 | }
416 |
417 | /**
418 | * Returns an array with foreignKey value.
419 | *
420 | * @param \Cake\Datasource\EntityInterface $entity Entity.
421 | * @return array
422 | */
423 | protected function extractForeignKey(EntityInterface $entity): array
424 | {
425 | $foreignKey = (array)$this->_config['foreignKey'];
426 | $primaryKey = (array)$this->_table->getPrimaryKey();
427 | $pkValue = $entity->extract($primaryKey);
428 |
429 | return array_combine($foreignKey, $pkValue);
430 | }
431 |
432 | /**
433 | * Returns default version association name.
434 | *
435 | * @param string $field Field name.
436 | * @return string
437 | */
438 | protected function associationName(?string $field = null): string
439 | {
440 | $alias = Inflector::singularize($this->_table->getAlias());
441 | if ($field) {
442 | $field = Inflector::camelize($field);
443 | }
444 |
445 | return $alias . $field . 'Version';
446 | }
447 |
448 | /**
449 | * Returns reference name for identifying this model's records in version table.
450 | *
451 | * @return string
452 | */
453 | protected function referenceName(): string
454 | {
455 | $table = $this->_table;
456 | $name = namespaceSplit(get_class($table));
457 | $name = substr(end($name), 0, -5);
458 | if (empty($name)) {
459 | $name = $table->getTable() ?: $table->getAlias();
460 | $name = Inflector::camelize($name);
461 | }
462 |
463 | return $name;
464 | }
465 |
466 | /**
467 | * Converts fields to the appropriate type to be stored in the version, and
468 | * to be converted from the version record to the entity
469 | *
470 | * @param array $fields Fields to convert
471 | * @param string $direction Direction (toPHP or toDatabase)
472 | * @return array
473 | */
474 | protected function convertFieldsToType(array $fields, string $direction): array
475 | {
476 | if (!in_array($direction, ['toPHP', 'toDatabase'])) {
477 | $message = sprintf('Cannot convert type, Cake\Database\Type::%s does not exist', $direction);
478 | throw new InvalidArgumentException($message);
479 | }
480 |
481 | $driver = $this->_table->getConnection()->getDriver();
482 | foreach ($fields as $field => $content) {
483 | $column = $this->_table->getSchema()->getColumn($field);
484 | $type = TypeFactory::build($column['type']);
485 |
486 | $fields[$field] = $type->{$direction}($content, $driver);
487 | }
488 |
489 | return $fields;
490 | }
491 | }
492 |
--------------------------------------------------------------------------------
/src/VersionPlugin.php:
--------------------------------------------------------------------------------
1 | bootstrapCli($app);
20 | }
21 | }
22 |
23 | /**
24 | * CLI bootstrap
25 | *
26 | * @param \Cake\Core\PluginApplicationInterface $app Applicaction instance
27 | * @return void
28 | */
29 | public function bootstrapCli(PluginApplicationInterface $app): void
30 | {
31 | $app->getEventManager()->on('Bake.beforeRender', function (Event $event): void {
32 | (new VersionListener($event))->execute();
33 | });
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tests/Fixture/ArticlesFixture.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 | namespace Josegonzalez\Version\Test\Fixture;
14 |
15 | use Cake\TestSuite\Fixture\TestFixture;
16 |
17 | /**
18 | * Class ArticlesFixture
19 | *
20 | * @category CakePHP-Plugin
21 | * @package Josegonzalez\Version\Test\Fixture
22 | * @author Jose Diaz-Gonzalez
23 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
24 | * @link https://github.com/josegonzalez/cakephp-version
25 | */
26 | class ArticlesFixture extends TestFixture
27 | {
28 | public string $table = 'articles';
29 |
30 | /**
31 | * Records property
32 | *
33 | * @var array
34 | */
35 | public array $records = [
36 | ['author_id' => 1, 'version_id' => 2, 'title' => 'First Article Version 2', 'body' => 'First Article Body Version 2', 'published' => 'N'],
37 | ['author_id' => 2, 'version_id' => 3, 'title' => 'Second Article Version 3', 'body' => 'Second Article Body Version 3', 'published' => 'N'],
38 | ];
39 | }
40 |
--------------------------------------------------------------------------------
/tests/Fixture/ArticlesTagsFixture.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Test\Fixture;
15 |
16 | use Cake\TestSuite\Fixture\TestFixture;
17 |
18 | /**
19 | * Class ArticlesTagsFixture
20 | *
21 | * @category CakePHP-Plugin
22 | * @package Josegonzalez\Version\Test\Fixture
23 | * @author Jose Diaz-Gonzalez
24 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
25 | * @link https://github.com/josegonzalez/cakephp-version
26 | */
27 | class ArticlesTagsFixture extends TestFixture
28 | {
29 | public string $table = 'articles_tags';
30 |
31 | /**
32 | * Records property
33 | *
34 | * @var array
35 | */
36 | public array $records = [
37 | ['article_id' => 1, 'tag_id' => 1, 'version_id' => 2, 'sort_order' => 1],
38 | ];
39 | }
40 |
--------------------------------------------------------------------------------
/tests/Fixture/ArticlesTagsVersionsFixture.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Test\Fixture;
15 |
16 | use Cake\TestSuite\Fixture\TestFixture;
17 |
18 | /**
19 | * Class ArticlesTagsVersionsFixture
20 | *
21 | * @category CakePHP-Plugin
22 | * @package Josegonzalez\Version\Test\Fixture
23 | * @author Jose Diaz-Gonzalez
24 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
25 | * @link https://github.com/josegonzalez/cakephp-version
26 | */
27 | class ArticlesTagsVersionsFixture extends TestFixture
28 | {
29 | /**
30 | * Table property
31 | *
32 | * @var string
33 | */
34 | public string $table = 'articles_tags_versions';
35 |
36 | /**
37 | * Records property
38 | *
39 | * @var array
40 | */
41 | public array $records = [
42 | ['version_id' => 1, 'model' => 'ArticlesTags', 'article_id' => 1, 'tag_id' => 1, 'field' => 'sort_order', 'content' => 1],
43 | ['version_id' => 2, 'model' => 'ArticlesTags', 'article_id' => 1, 'tag_id' => 1, 'field' => 'sort_order', 'content' => 2],
44 | ];
45 | }
46 |
--------------------------------------------------------------------------------
/tests/Fixture/VersionsFixture.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 | namespace Josegonzalez\Version\Test\Fixture;
14 |
15 | use Cake\TestSuite\Fixture\TestFixture;
16 |
17 | /**
18 | * Class VersionsFixture
19 | *
20 | * @category CakePHP-Plugin
21 | * @package Josegonzalez\Version\Test\Fixture
22 | * @author Jose Diaz-Gonzalez
23 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
24 | * @link https://github.com/josegonzalez/cakephp-version
25 | */
26 | class VersionsFixture extends TestFixture
27 | {
28 | /**
29 | * Table property
30 | *
31 | * @var string
32 | */
33 | public string $table = 'version';
34 |
35 | /**
36 | * Records property
37 | *
38 | * @var array
39 | */
40 | public array $records = [
41 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'author_id', 'content' => 1],
42 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'First Article'],
43 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'First Article Body'],
44 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'published', 'content' => 'Y'],
45 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'author_id', 'content' => 1, 'custom_field' => 'foo'],
46 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'First Article Version 2', 'custom_field' => 'foo'],
47 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'First Article Body Version 2', 'custom_field' => 'foo'],
48 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'published', 'content' => 'N', 'custom_field' => 'foo'],
49 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'author_id', 'content' => 2],
50 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Second Article version 1'],
51 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Second Article Body'],
52 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'published', 'content' => 'Y'],
53 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'author_id', 'content' => 2],
54 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Second Article version 2'],
55 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Second Article Body'],
56 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'published', 'content' => 'Y'],
57 | ['version_id' => 3, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'author_id', 'content' => 2],
58 | ['version_id' => 3, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'title', 'content' => 'Second Article version 3'],
59 | ['version_id' => 3, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'body', 'content' => 'Second Article Body'],
60 | ['version_id' => 3, 'model' => 'Articles', 'foreign_key' => 2, 'field' => 'published', 'content' => 'Y'],
61 | ];
62 | }
63 |
--------------------------------------------------------------------------------
/tests/Fixture/VersionsWithUserFixture.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 | namespace Josegonzalez\Version\Test\Fixture;
14 |
15 | use Cake\TestSuite\Fixture\TestFixture;
16 |
17 | /**
18 | * Class VersionsWithUserFixture
19 | *
20 | * @category CakePHP-Plugin
21 | * @package Josegonzalez\Version\Test\Fixture
22 | * @author Jose Diaz-Gonzalez
23 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
24 | * @link https://github.com/josegonzalez/cakephp-version
25 | */
26 | class VersionsWithUserFixture extends TestFixture
27 | {
28 | /**
29 | * Table property
30 | *
31 | * @var string
32 | */
33 | public string $table = 'versions_with_user';
34 |
35 | /**
36 | * Records property
37 | *
38 | * @var array
39 | */
40 | public array $records = [
41 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'author_id', 'content' => 1, 'user_id' => 2],
42 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'First Article', 'user_id' => 2],
43 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'First Article Body', 'user_id' => 2],
44 | ['version_id' => 1, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'published', 'content' => 'Y', 'user_id' => 2],
45 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'author_id', 'content' => 1, 'custom_field' => 'foo', 'user_id' => 3],
46 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'title', 'content' => 'First Article Version 2', 'custom_field' => 'foo', 'user_id' => 3],
47 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'body', 'content' => 'First Article Body Version 2', 'custom_field' => 'foo', 'user_id' => 3],
48 | ['version_id' => 2, 'model' => 'Articles', 'foreign_key' => 1, 'field' => 'published', 'content' => 'N', 'custom_field' => 'foo', 'user_id' => 3],
49 | ];
50 | }
51 |
--------------------------------------------------------------------------------
/tests/TestApp/Model/Entity/Test.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Test\TestApp\Model\Entity;
15 |
16 | use Cake\ORM\Entity;
17 | use Josegonzalez\Version\Model\Behavior\Version\VersionTrait;
18 |
19 | /**
20 | * Class Test
21 | *
22 | * A test entity to test with.
23 | *
24 | * @category CakePHP-Plugin
25 | * @package Josegonzalez\Version\Test\TestCase\Model\Behavior
26 | * @author Jose Diaz-Gonzalez
27 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
28 | * @link https://github.com/josegonzalez/cakephp-version
29 | */
30 | class Test extends Entity
31 | {
32 | use VersionTrait;
33 | }
34 |
--------------------------------------------------------------------------------
/tests/TestCase/Model/Behavior/VersionBehaviorTest.php:
--------------------------------------------------------------------------------
1 |
10 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
11 | * @link https://github.com/josegonzalez/cakephp-version
12 | */
13 |
14 | namespace Josegonzalez\Version\Test\TestCase\Model\Behavior;
15 |
16 | use Cake\Event\EventManager;
17 | use Cake\TestSuite\TestCase;
18 | use InvalidArgumentException;
19 | use Josegonzalez\Version\Model\Behavior\VersionBehavior;
20 | use Josegonzalez\Version\Test\TestApp\Model\Entity\Test;
21 | use ReflectionObject;
22 |
23 | /**
24 | * Class VersionBehaviorTest
25 | * The tests for this package.
26 | *
27 | * @category CakePHP-Plugin
28 | * @package Josegonzalez\Version\Test\TestCase\Model\Behavior
29 | * @author Jose Diaz-Gonzalez
30 | * @license MIT License (https://github.com/josegonzalez/cakephp-version/blob/master/LICENSE.txt)
31 | * @link https://github.com/josegonzalez/cakephp-version
32 | */
33 | class VersionBehaviorTest extends TestCase
34 | {
35 | /**
36 | * The fixtures to load.
37 | *
38 | * @var array
39 | */
40 | public array $fixtures = [
41 | 'plugin.Josegonzalez/Version.Versions',
42 | 'plugin.Josegonzalez/Version.VersionsWithUser',
43 | 'plugin.Josegonzalez/Version.Articles',
44 | 'plugin.Josegonzalez/Version.ArticlesTagsVersions',
45 | 'plugin.Josegonzalez/Version.ArticlesTags',
46 | ];
47 |
48 | /**
49 | * Save a new version
50 | *
51 | * @return void
52 | */
53 | public function testSaveNew()
54 | {
55 | $table = $this->getTableLocator()->get(
56 | 'Articles',
57 | [
58 | 'entityClass' => Test::class,
59 | ],
60 | );
61 | $table->addBehavior('Josegonzalez/Version.Version');
62 | $article = $table->find('all')->first();
63 | $this->assertEquals(2, $article->version_id);
64 |
65 | $versionTable = $this->getTableLocator()->get('Version');
66 | $results = $versionTable->find('all')
67 | ->where(['foreign_key' => $article->id])
68 | ->enableHydration(false)
69 | ->toArray();
70 | $this->assertCount(8, $results);
71 |
72 | $article->title = 'Titulo';
73 | $table->save($article);
74 |
75 | $versionTable = $this->getTableLocator()->get('Version');
76 | $results = $versionTable->find('all')
77 | ->where(['foreign_key' => $article->id])
78 | ->enableHydration(false)
79 | ->toArray();
80 |
81 | $this->assertEquals(3, $article->version_id);
82 | $this->assertCount(13, $results);
83 | }
84 |
85 | /**
86 | * Find a specific version.
87 | *
88 | * @return void
89 | */
90 | public function testFindVersionSpecific()
91 | {
92 | $table = $this->getTableLocator()->get(
93 | 'Articles',
94 | [
95 | 'entityClass' => Test::class,
96 | ]
97 | );
98 | $table->addBehavior('Josegonzalez/Version.Version');
99 | $article = $table->find('all')->first();
100 | $version = $article->version(1);
101 |
102 | $this->assertEquals('First Article', $version->get('title'));
103 | }
104 |
105 | /**
106 | * Find a specific version.
107 | *
108 | * @return void
109 | */
110 | public function testFindVersionAdditional()
111 | {
112 | $table = $this->getTableLocator()->get(
113 | 'Articles',
114 | [
115 | 'entityClass' => Test::class,
116 | ]
117 | );
118 | $config = [
119 | 'additionalVersionFields' => [
120 | 'version_foo' => 'foooo',
121 | 'baaar',
122 | ],
123 | ];
124 | $table->addBehavior('Josegonzalez/Version.Version', $config);
125 | $article = $table->find('all')->first();
126 | $version = $article->version(1);
127 |
128 | $version = $version->toArray();
129 | $this->assertArrayHasKey('version_foo', $version);
130 | $this->assertArrayHasKey('version_baaar', $version);
131 | }
132 |
133 | /**
134 | * Find versions of an entity.
135 | *
136 | * @return void
137 | */
138 | public function testFindVersions()
139 | {
140 | $table = $this->getTableLocator()->get(
141 | 'Articles',
142 | [
143 | 'entityClass' => Test::class,
144 | ]
145 | );
146 | $table->addBehavior('Josegonzalez/Version.Version');
147 | $article = $table->find('all')->first();
148 |
149 | $versions = $article->versions();
150 | $this->assertCount(2, $versions);
151 | $this->assertEquals('First Article Version 2', $versions[2]->title);
152 | $versions = $article->versions();
153 | $this->assertCount(2, $versions);
154 | $this->assertEquals('First Article Version 2', $versions[2]->title);
155 |
156 | $article->title = 'Capitulo';
157 | $table->save($article);
158 |
159 | $versions = $article->versions();
160 | $this->assertCount(2, $versions);
161 | $this->assertEquals('First Article Version 2', $versions[2]->title);
162 | $versions = $article->versions(true);
163 | $this->assertCount(3, $versions);
164 | $this->assertEquals('Capitulo', $versions[3]->title);
165 |
166 | $article->title = 'Titulo';
167 | $table->save($article);
168 |
169 | $versions = $article->versions();
170 | $this->assertCount(3, $versions);
171 | $this->assertEquals('Capitulo', $versions[3]->title);
172 |
173 | $versions = $article->versions(true);
174 | $this->assertCount(4, $versions);
175 | $this->assertEquals('Titulo', $versions[4]->title);
176 | }
177 |
178 | /**
179 | * Save with limited fields.
180 | *
181 | * @return void
182 | */
183 | public function testSaveLimitFields()
184 | {
185 | $table = $this->getTableLocator()->get(
186 | 'Articles',
187 | [
188 | 'entityClass' => Test::class,
189 | ]
190 | );
191 | $table->addBehavior('Josegonzalez/Version.Version', ['fields' => 'title']);
192 | $article = $table->find('all')->first();
193 |
194 | $article->title = 'Titulo';
195 | $article->body = 'Hello world!';
196 | $table->save($article);
197 |
198 | $versionTable = $this->getTableLocator()->get('Version');
199 | $results = $versionTable->find('all')
200 | ->where(['foreign_key' => $article->id, 'version_id' => 3])
201 | ->enableHydration(false)
202 | ->toArray();
203 |
204 | $this->assertCount(1, $results);
205 | $this->assertEquals('title', $results[0]['field']);
206 | }
207 |
208 | /**
209 | * Save only dirty fields.
210 | *
211 | * @return void
212 | */
213 | public function testSaveDirtyFields()
214 | {
215 | $table = $this->getTableLocator()->get(
216 | 'Articles',
217 | [
218 | 'entityClass' => Test::class,
219 | ]
220 | );
221 | $table->addBehavior('Josegonzalez/Version.Version', ['onlyDirty' => true]);
222 | $article = $table->find('all')->first();
223 |
224 | $article->title = 'Titulo';
225 | $article->body = 'Hello world!';
226 | $table->save($article);
227 |
228 | $versionTable = $this->getTableLocator()->get('Version');
229 | $results = $versionTable->find('all')
230 | ->where(['foreign_key' => $article->id, 'version_id' => 3])
231 | ->enableHydration(false)
232 | ->toArray();
233 |
234 | $this->assertCount(2, $results);
235 | $this->assertEquals('title', $results[0]['field']);
236 | $this->assertEquals('body', $results[1]['field']);
237 | }
238 |
239 | /**
240 | * Find with limited fields.
241 | *
242 | * @return void
243 | */
244 | public function testFindVersionLimitFields()
245 | {
246 | $table = $this->getTableLocator()->get(
247 | 'Articles',
248 | [
249 | 'entityClass' => Test::class,
250 | ]
251 | );
252 | $table->addBehavior('Josegonzalez/Version.Version', ['fields' => 'title']);
253 | $article = $table->find('all')->first();
254 | $version = $article->version(1);
255 |
256 | $this->assertArrayHasKey('title', $version);
257 | $this->assertArrayNotHasKey('body', $version);
258 | }
259 |
260 | /**
261 | * Save with valid custom field (meta data like user_id)
262 | *
263 | * @return void
264 | */
265 | public function testSaveWithValidMetaData()
266 | {
267 | $table = $this->getTableLocator()->get(
268 | 'Articles',
269 | [
270 | 'entityClass' => Test::class,
271 | ]
272 | );
273 | $table->addBehavior('Josegonzalez/Version.Version');
274 | $article = $table->find('all')->first();
275 | EventManager::instance()->on(
276 | 'Model.Version.beforeSave',
277 | function ($event) {
278 | return [
279 | 'custom_field' => 'bar',
280 | ];
281 | }
282 | );
283 | $versionTable = $this->getTableLocator()->get('Version');
284 |
285 | $results = $versionTable->find('all')
286 | ->where(['foreign_key' => $article->id])
287 | ->enableHydration(false)
288 | ->toArray();
289 | $this->assertEquals('foo', $results[4]['custom_field']);
290 |
291 | $article->title = 'Titulo';
292 | $table->save($article);
293 |
294 | $results = $versionTable->find('all')
295 | ->where(['foreign_key' => $article->id])
296 | ->enableHydration(false)
297 | ->toArray();
298 | $this->assertEquals('bar', $results[9]['custom_field']);
299 | }
300 |
301 | /**
302 | * Save with invalid custom field (meta data like user_id)
303 | *
304 | * @return void
305 | */
306 | public function testSaveWithInvalidMetaData()
307 | {
308 | $table = $this->getTableLocator()->get(
309 | 'Articles',
310 | [
311 | 'entityClass' => Test::class,
312 | ]
313 | );
314 | $table->addBehavior('Josegonzalez/Version.Version');
315 | $article = $table->find('all')->first();
316 | EventManager::instance()->on(
317 | 'Model.Version.beforeSave',
318 | function ($event) {
319 | return [
320 | 'nonsense' => 'bar',
321 | ];
322 | }
323 | );
324 | $versionTable = $this->getTableLocator()->get('Version');
325 |
326 | $results = $versionTable->find('all')
327 | ->where(['foreign_key' => $article->id])
328 | ->enableHydration(false)
329 | ->toArray();
330 | $this->assertEquals('foo', $results[4]['custom_field']);
331 |
332 | $article->title = 'Titulo';
333 | $table->save($article);
334 |
335 | $results = $versionTable->find('all')
336 | ->where(['foreign_key' => $article->id])
337 | ->enableHydration(false)
338 | ->toArray();
339 | $this->assertNull($results[9]['custom_field']);
340 | }
341 |
342 | /**
343 | * Find version with composite keys (e.g. ArticlesTagsVersions)
344 | *
345 | * @return void
346 | */
347 | public function testFindWithCompositeKeys()
348 | {
349 | $table = $this->getTableLocator()->get(
350 | 'ArticlesTags',
351 | [
352 | 'entityClass' => Test::class,
353 | ]
354 | );
355 | $table->addBehavior(
356 | 'Josegonzalez/Version.Version',
357 | [
358 | 'fields' => 'sort_order',
359 | 'versionTable' => 'articles_tags_versions',
360 | 'foreignKey' => ['article_id', 'tag_id'],
361 | ]
362 | );
363 |
364 | $entity = $table->find()->first();
365 | $this->assertEquals(['sort_order' => 1, 'version_id' => 1, 'version_created' => null], $entity->version(1)->toArray());
366 | $this->assertEquals(['sort_order' => 2, 'version_id' => 2, 'version_created' => null], $entity->version(2)->toArray());
367 | }
368 |
369 | /**
370 | * Save with composite keys (e.g. ArticlesTagsVersions)
371 | *
372 | * @return void
373 | */
374 | public function testSaveWithCompositeKeys()
375 | {
376 | $table = $this->getTableLocator()->get(
377 | 'ArticlesTags',
378 | [
379 | 'entityClass' => Test::class,
380 | ]
381 | );
382 | $table->addBehavior(
383 | 'Josegonzalez/Version.Version',
384 | [
385 | 'fields' => 'sort_order',
386 | 'versionTable' => 'articles_tags_versions',
387 | 'foreignKey' => ['article_id', 'tag_id'],
388 | ]
389 | );
390 |
391 | $entity = $table->find()->first();
392 | $entity->sort_order = 3;
393 | $table->save($entity);
394 | //$this->assertEquals(3, $entity->version_id);
395 | $this->assertEquals(3, $entity->version_id);
396 | $this->assertEquals(['sort_order' => 3, 'version_id' => 3, 'version_created' => null], $entity->version(3)->toArray());
397 | }
398 |
399 | /**
400 | * Get the custom fields like user_id (additional meta data)
401 | *
402 | * @return void
403 | */
404 | public function testGetAdditionalMetaData()
405 | {
406 | $table = $this->getTableLocator()->get(
407 | 'Articles',
408 | [
409 | 'entityClass' => Test::class,
410 | ]
411 | );
412 | $table->addBehavior(
413 | 'Josegonzalez/Version.Version',
414 | [
415 | 'versionTable' => 'versions_with_user',
416 | 'additionalVersionFields' => ['created', 'user_id'],
417 | ]
418 | );
419 | $table->find('all')->first();
420 |
421 | $this->getTableLocator()->get('Version', ['table' => 'versions_with_user']);
422 |
423 | $results = $table->find('versions')->toArray();
424 |
425 | $this->assertSame(2, $results[0]['_versions'][1]['version_user_id']);
426 | $this->assertSame(3, $results[0]['_versions'][2]['version_user_id']);
427 | }
428 |
429 | /**
430 | * Associations correctly names / recognized?
431 | *
432 | * @return void
433 | */
434 | public function testAssociations()
435 | {
436 | $table = $this->getTableLocator()->get(
437 | 'Articles',
438 | [
439 | 'entityClass' => Test::class,
440 | ]
441 | );
442 | $table->addBehavior('Josegonzalez/Version.Version');
443 |
444 | $this->assertTrue($table->associations()->has('ArticleVersion'));
445 | $versions = $table->getAssociation('ArticleVersion');
446 | $this->assertInstanceOf('Cake\Orm\Association\HasMany', $versions);
447 | $this->assertEquals('__version', $versions->getProperty());
448 |
449 | $this->assertTrue($table->associations()->has('ArticleBodyVersion'));
450 | $bodyVersions = $table->getAssociation('ArticleBodyVersion');
451 | $this->assertInstanceOf('Cake\Orm\Association\HasMany', $bodyVersions);
452 | $this->assertEquals('body_version', $bodyVersions->getProperty());
453 | }
454 |
455 | /**
456 | * Get a specific version id
457 | *
458 | * @return void
459 | */
460 | public function testGetVersionId()
461 | {
462 | // init test data
463 | $table = $this->getTableLocator()->get(
464 | 'Articles',
465 | [
466 | 'entityClass' => Test::class,
467 | ]
468 | );
469 | $table->addBehavior('Josegonzalez/Version.Version');
470 | $article = $table->find('all')->where(['version_id' => 2])->first();
471 | $article->title = 'First Article Version 3';
472 | $table->save($article);
473 |
474 | // action in controller receiving outdated data
475 | $table->patchEntity($article, ['version_id' => 2]);
476 |
477 | $this->assertEquals(2, $article->version_id);
478 | $this->assertEquals(3, $table->getVersionId($article));
479 | }
480 |
481 | /**
482 | * Tests saving a non scalar db type, such as JSON
483 | *
484 | * @return void
485 | */
486 | public function testSaveNonScalarType()
487 | {
488 | $table = $this->getTableLocator()->get(
489 | 'Articles',
490 | [
491 | 'entityClass' => Test::class,
492 | ]
493 | );
494 | $schema = $table->getSchema();
495 | $schema->setColumnType('settings', 'json');
496 | $table->setSchema($schema);
497 | $table->addBehavior('Josegonzalez/Version.Version');
498 |
499 | $data = ['test' => 'array'];
500 | $article = $table->get(1);
501 | $article->settings = $data;
502 | $table->saveOrFail($article);
503 |
504 | $version = $article->version($article->version_id);
505 | $this->assertSame($data, $version->settings);
506 | }
507 |
508 | /**
509 | * Tests versions convert types
510 | *
511 | * @return void
512 | */
513 | public function testVersionConvertsType()
514 | {
515 | $table = $this->getTableLocator()->get(
516 | 'Articles',
517 | [
518 | 'entityClass' => Test::class,
519 | ]
520 | );
521 | $table->addBehavior('Josegonzalez/Version.Version');
522 |
523 | $article = $table->get(1);
524 | $version = $article->version($article->version_id);
525 | $this->assertIsInt($version->author_id);
526 | }
527 |
528 | /**
529 | * Tests _convertFieldsToType
530 | *
531 | * @return void
532 | */
533 | public function testConvertFieldsToType()
534 | {
535 | $table = $this->getTableLocator()->get(
536 | 'Articles',
537 | [
538 | 'entityClass' => Test::class,
539 | ]
540 | );
541 | $schema = $table->getSchema();
542 | $schema->setColumnType('settings', 'json');
543 | $table->setSchema($schema);
544 | $behavior = new VersionBehavior($table);
545 |
546 | $reflection = new ReflectionObject($behavior);
547 | $method = $reflection->getMethod('convertFieldsToType');
548 | $method->setAccessible(true);
549 |
550 | $data = ['test' => 'array'];
551 | $fields = [
552 | 'settings' => json_encode($data),
553 | 'author_id' => '1',
554 | 'body' => 'text',
555 | ];
556 | $fields = $method->invokeArgs($behavior, [$fields, 'toPHP']);
557 | $this->assertIsArray($fields['settings']);
558 | $this->assertSame($data, $fields['settings']);
559 | $this->assertIsInt($fields['author_id']);
560 | $this->assertIsString('string', $fields['body']);
561 |
562 | $data = ['test' => 'array'];
563 | $fields = [
564 | 'settings' => ['test' => 'array'],
565 | 'author_id' => 1,
566 | 'body' => 'text',
567 | ];
568 | $fields = $method->invokeArgs($behavior, [$fields, 'toDatabase']);
569 | $this->assertIsString($fields['settings']);
570 | $this->assertSame(json_encode($data), $fields['settings']);
571 | $this->assertIsInt($fields['author_id']);
572 | $this->assertIsString($fields['body']);
573 | }
574 |
575 | /**
576 | * Tests passing an invalid direction to _convertFieldsToType
577 | *
578 | * @return void
579 | */
580 | public function testConvertFieldsToTypeInvalidDirection()
581 | {
582 | $this->expectException(InvalidArgumentException::class);
583 |
584 | $table = $this->getTableLocator()->get(
585 | 'Articles',
586 | [
587 | 'entityClass' => Test::class,
588 | ]
589 | );
590 | $behavior = new VersionBehavior($table);
591 |
592 | $reflection = new ReflectionObject($behavior);
593 | $method = $reflection->getMethod('convertFieldsToType');
594 | $method->setAccessible(true);
595 |
596 | $method->invokeArgs($behavior, [[], 'invalidDirection']);
597 | }
598 | }
599 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | 'App']);
48 | Configure::write('debug', true);
49 |
50 | $filesystem = new Filesystem();
51 | $filesystem->mkdir(TMP . 'cache/models', 0777);
52 | $filesystem->mkdir(TMP . 'cache/persistent', 0777);
53 | $filesystem->mkdir(TMP . 'cache/views', 0777);
54 |
55 | $cache = [
56 | 'default' => [
57 | 'engine' => 'File',
58 | ],
59 | '_cake_core_' => [
60 | 'className' => 'File',
61 | 'prefix' => 'version_myapp_cake_core_',
62 | 'path' => CACHE . 'persistent/',
63 | 'serialize' => true,
64 | 'duration' => '+10 seconds',
65 | ],
66 | '_cake_model_' => [
67 | 'className' => 'File',
68 | 'prefix' => 'version_my_app_cake_model_',
69 | 'path' => CACHE . 'models/',
70 | 'serialize' => 'File',
71 | 'duration' => '+10 seconds',
72 | ],
73 | ];
74 |
75 | Cache::setConfig($cache);
76 | Configure::write('Session', [
77 | 'defaults' => 'php',
78 | ]);
79 |
80 | // Ensure default test connection is defined
81 | if (!getenv('db_dsn')) {
82 | putenv('db_dsn=sqlite:///:memory:');
83 | }
84 |
85 | ConnectionManager::setConfig('test', [
86 | 'url' => getenv('db_dsn'),
87 | 'timezone' => 'UTC',
88 | ]);
89 |
90 | $loader = new SchemaLoader();
91 | $loader->loadInternalFile(__DIR__ . '/schema.php');
92 |
--------------------------------------------------------------------------------
/tests/schema.php:
--------------------------------------------------------------------------------
1 | [
6 | 'columns' => [
7 | 'id' => ['type' => 'integer'],
8 | 'author_id' => ['type' => 'integer', 'null' => true],
9 | 'version_id' => ['type' => 'integer', 'null' => true],
10 | 'title' => ['type' => 'string', 'null' => true],
11 | 'body' => 'text',
12 | 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'],
13 | 'settings' => ['type' => 'json', 'null' => true],
14 | ],
15 | 'constraints' => ['primary' => ['type' => 'primary', 'columns' => ['id']]],
16 | ],
17 | 'articles_tags' => [
18 | 'columns' => [
19 | 'article_id' => ['type' => 'integer'],
20 | 'tag_id' => ['type' => 'integer'],
21 | 'version_id' => ['type' => 'integer', 'null' => true],
22 | 'sort_order' => ['type' => 'integer', 'default' => 1],
23 | ],
24 | 'constraints' => ['primary' => ['type' => 'primary', 'columns' => ['article_id', 'tag_id']]],
25 | ],
26 | 'articles_tags_versions' => [
27 | 'columns' => [
28 | 'id' => ['type' => 'integer'],
29 | 'version_id' => ['type' => 'integer'],
30 | 'model' => ['type' => 'string', 'null' => false],
31 | 'article_id' => ['type' => 'integer', 'null' => false],
32 | 'tag_id' => ['type' => 'integer', 'null' => false],
33 | 'field' => ['type' => 'string', 'null' => false],
34 | 'content' => ['type' => 'text'],
35 | 'custom_field' => ['type' => 'text'],
36 | ],
37 | 'constraints' => [
38 | 'primary' => ['type' => 'primary', 'columns' => ['id']],
39 | ],
40 | ],
41 | 'version' => [
42 | 'columns' => [
43 | 'id' => ['type' => 'integer'],
44 | 'version_id' => ['type' => 'integer'],
45 | 'model' => ['type' => 'string', 'null' => false],
46 | 'foreign_key' => ['type' => 'integer', 'null' => false],
47 | 'field' => ['type' => 'string', 'null' => false],
48 | 'content' => ['type' => 'text'],
49 | 'custom_field' => ['type' => 'text'],
50 | ],
51 | 'constraints' => [
52 | 'primary' => ['type' => 'primary', 'columns' => ['id']],
53 | ],
54 | ],
55 | 'versions_with_user' => [
56 | 'columns' => [
57 | 'id' => ['type' => 'integer'],
58 | 'version_id' => ['type' => 'integer'],
59 | 'user_id' => ['type' => 'integer'],
60 | 'model' => ['type' => 'string', 'null' => false],
61 | 'foreign_key' => ['type' => 'integer', 'null' => false],
62 | 'field' => ['type' => 'string', 'null' => false],
63 | 'content' => ['type' => 'text'],
64 | ],
65 | 'constraints' => [
66 | 'primary' => ['type' => 'primary', 'columns' => ['id']],
67 | ],
68 | ],
69 | ];
70 |
--------------------------------------------------------------------------------