├── .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 | [![Build Status](https://img.shields.io/travis/josegonzalez/cakephp-version/master.svg?style=flat-square)](https://travis-ci.org/josegonzalez/cakephp-version) 2 | [![Coverage Status](https://img.shields.io/coveralls/josegonzalez/cakephp-version.svg?style=flat-square)](https://coveralls.io/r/josegonzalez/cakephp-version?branch=master) 3 | [![Total Downloads](https://img.shields.io/packagist/dt/josegonzalez/cakephp-version.svg?style=flat-square)](https://packagist.org/packages/josegonzalez/cakephp-version) 4 | [![Latest Stable Version](https://img.shields.io/packagist/v/josegonzalez/cakephp-version.svg?style=flat-square)](https://packagist.org/packages/josegonzalez/cakephp-version) 5 | [![Documentation Status](https://readthedocs.org/projects/cakephp-version/badge/?version=latest&style=flat-square)](https://readthedocs.org/projects/cakephp-version/?badge=latest) 6 | [![Gratipay](https://img.shields.io/gratipay/josegonzalez.svg?style=flat-square)](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 | --------------------------------------------------------------------------------