diff --git a/composer.json b/composer.json index 24e48854..80f8992d 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "php": ">=8.4", "spryker/cakephp-statemachine": "dev-master", "cakephp/plugin-installer": "^2.0.1", - "cakephp/cakephp": "dev-5.next as 5.3.0", + "cakephp/cakephp": "dev-5.next-dto-projection as 5.3.0", "cakephp/bake": "^3.0.2", "mobiledetect/mobiledetectlib": "4.*", "dereuromark/cakephp-comments": "dev-master", @@ -33,7 +33,7 @@ "dereuromark/cakephp-setup": "dev-master", "dereuromark/cakephp-markup": "dev-master", "dereuromark/cakephp-captcha": "dev-master", - "cakephp/migrations": "5.x-dev as 4.9.1", + "cakephp/migrations": "^4.9", "markstory/asset_compress": "^5.0.0", "natxet/cssmin": "dev-master", "linkorb/jsmin-php": "dev-master", @@ -97,7 +97,8 @@ "dereuromark/cakephp-ide-helper-extra": "dev-master", "dereuromark/cakephp-test-helper": "dev-master", "phpunit/phpunit": "^12.1", - "cakedc/cakephp-phpstan": "^4.0" + "cakedc/cakephp-phpstan": "^4.0", + "admad/entity": "dev-master" }, "autoload": { "psr-4": { @@ -156,16 +157,20 @@ "support": { "source": "https://github.com/dereuromark/cakephp-sandbox" }, - "repositories": [ - { + "repositories": { + "admad-entity": { + "type": "vcs", + "url": "https://github.com/ADmad/cakephp-entity" + }, + "0": { "type": "git", "url": "https://github.com/dereuromark/cakephp-statemachine.git" }, - { + "1": { "type": "git", "url": "https://github.com/dereuromark/audit-stash.git" } - ], + }, "prefer-stable": true, "minimum-stability": "dev", "config": { diff --git a/composer.lock b/composer.lock index a9d013fe..68c50743 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c900199a4dd76901495863f7b45f402b", + "content-hash": "b582b5010346180c9e6aa76115fadac9", "packages": [ { "name": "brick/math", @@ -307,16 +307,16 @@ }, { "name": "cakephp/cakephp", - "version": "dev-5.next", + "version": "dev-5.next-dto-projection", "source": { "type": "git", "url": "https://github.com/cakephp/cakephp.git", - "reference": "4ea255d3dd9ea429f1f190aafcfe6a66b42159f4" + "reference": "d71f584f537a5a9b4ec2bca26da57ca621f52275" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/cakephp/zipball/4ea255d3dd9ea429f1f190aafcfe6a66b42159f4", - "reference": "4ea255d3dd9ea429f1f190aafcfe6a66b42159f4", + "url": "https://api.github.com/repos/cakephp/cakephp/zipball/d71f584f537a5a9b4ec2bca26da57ca621f52275", + "reference": "d71f584f537a5a9b4ec2bca26da57ca621f52275", "shasum": "" }, "require": { @@ -424,7 +424,7 @@ "issues": "https://github.com/cakephp/cakephp/issues", "source": "https://github.com/cakephp/cakephp" }, - "time": "2025-12-15T04:13:44+00:00" + "time": "2025-12-17T15:04:53+00:00" }, { "name": "cakephp/chronos", @@ -605,29 +605,30 @@ }, { "name": "cakephp/migrations", - "version": "5.x-dev", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/cakephp/migrations.git", - "reference": "47f960d0d010f4177b0154dd4a69b2e3b5152e00" + "reference": "0fcd2e2e4b4e99c449e6586586ae870d8d8d757e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cakephp/migrations/zipball/47f960d0d010f4177b0154dd4a69b2e3b5152e00", - "reference": "47f960d0d010f4177b0154dd4a69b2e3b5152e00", + "url": "https://api.github.com/repos/cakephp/migrations/zipball/0fcd2e2e4b4e99c449e6586586ae870d8d8d757e", + "reference": "0fcd2e2e4b4e99c449e6586586ae870d8d8d757e", "shasum": "" }, "require": { - "cakephp/cache": "dev-5.next as 5.3.0", - "cakephp/database": "dev-5.next as 5.3.0", - "cakephp/orm": "dev-5.next as 5.3.0", - "php": ">=8.2" + "cakephp/cache": "^5.2.9", + "cakephp/database": "^5.2.9", + "cakephp/orm": "^5.2.9", + "php": ">=8.1", + "robmorgan/phinx": "^0.16.10" }, "require-dev": { "cakephp/bake": "^3.3", - "cakephp/cakephp": "dev-5.next as 5.3.0", + "cakephp/cakephp": "^5.2.9", "cakephp/cakephp-codesniffer": "^5.0", - "phpunit/phpunit": "^11.5.3 || ^12.1.3" + "phpunit/phpunit": "^10.5.5 || ^11.1.3 || ^12.2.4" }, "suggest": { "cakephp/bake": "If you want to generate migrations.", @@ -649,7 +650,7 @@ "homepage": "https://github.com/cakephp/migrations/graphs/contributors" } ], - "description": "Database Migration plugin for CakePHP", + "description": "Database Migration plugin for CakePHP based on Phinx", "homepage": "https://github.com/cakephp/migrations", "keywords": [ "cakephp", @@ -662,7 +663,7 @@ "issues": "https://github.com/cakephp/migrations/issues", "source": "https://github.com/cakephp/migrations" }, - "time": "2025-12-12T03:55:38+00:00" + "time": "2025-12-15T03:52:13+00:00" }, { "name": "cakephp/plugin-installer", @@ -6358,12 +6359,12 @@ "source": { "type": "git", "url": "https://github.com/php-collective/dto.git", - "reference": "c086285da437cfe62b66f48a088040d66eabceec" + "reference": "7dd25a7814e68cbc96c9336188af2111d90d4635" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-collective/dto/zipball/c086285da437cfe62b66f48a088040d66eabceec", - "reference": "c086285da437cfe62b66f48a088040d66eabceec", + "url": "https://api.github.com/repos/php-collective/dto/zipball/7dd25a7814e68cbc96c9336188af2111d90d4635", + "reference": "7dd25a7814e68cbc96c9336188af2111d90d4635", "shasum": "" }, "require": { @@ -6419,7 +6420,7 @@ "issues": "https://github.com/php-collective/dto/issues", "source": "https://github.com/php-collective/dto/tree/master" }, - "time": "2025-12-15T21:44:55+00:00" + "time": "2025-12-16T13:54:37+00:00" }, { "name": "php-collective/file-storage", @@ -7407,6 +7408,93 @@ ], "time": "2025-08-19T18:57:03+00:00" }, + { + "name": "robmorgan/phinx", + "version": "0.16.10", + "source": { + "type": "git", + "url": "https://github.com/cakephp/phinx.git", + "reference": "83f83ec105e55e3abba7acc23c0272b5fcf66929" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cakephp/phinx/zipball/83f83ec105e55e3abba7acc23c0272b5fcf66929", + "reference": "83f83ec105e55e3abba7acc23c0272b5fcf66929", + "shasum": "" + }, + "require": { + "cakephp/database": "^5.0.2", + "composer-runtime-api": "^2.0", + "php-64bit": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/config": "^4.0|^5.0|^6.0|^7.0", + "symfony/console": "^6.0|^7.0" + }, + "require-dev": { + "cakephp/cakephp-codesniffer": "^5.0", + "cakephp/i18n": "^5.0", + "ext-json": "*", + "ext-pdo": "*", + "phpunit/phpunit": "^9.5.19", + "symfony/yaml": "^4.0|^5.0|^6.0|^7.0" + }, + "suggest": { + "ext-json": "Install if using JSON configuration format", + "ext-pdo": "PDO extension is needed", + "symfony/yaml": "Install if using YAML configuration format" + }, + "bin": [ + "bin/phinx" + ], + "type": "library", + "autoload": { + "psr-4": { + "Phinx\\": "src/Phinx/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Rob Morgan", + "email": "robbym@gmail.com", + "homepage": "https://robmorgan.id.au", + "role": "Lead Developer" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com", + "homepage": "https://shadowhand.me", + "role": "Developer" + }, + { + "name": "Richard Quadling", + "email": "rquadling@gmail.com", + "role": "Developer" + }, + { + "name": "CakePHP Community", + "homepage": "https://github.com/cakephp/phinx/graphs/contributors", + "role": "Developer" + } + ], + "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.", + "homepage": "https://phinx.org", + "keywords": [ + "database", + "database migrations", + "db", + "migrations", + "phinx" + ], + "support": { + "issues": "https://github.com/cakephp/phinx/issues", + "source": "https://github.com/cakephp/phinx/tree/0.16.10" + }, + "time": "2025-07-08T18:55:28+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "v8.9.0", @@ -7960,41 +8048,128 @@ "description": "CakePHP StateMachine Plugin", "time": "2025-12-15T09:36:07+00:00" }, + { + "name": "symfony/config", + "version": "v7.4.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/2c323304c354a43a48b61c5fa760fc4ed60ce495", + "reference": "2c323304c354a43a48b61c5fa760fc4ed60ce495", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-12-05T07:52:08+00:00" + }, { "name": "symfony/console", - "version": "v8.0.1", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fcb73f69d655b48fcb894a262f074218df08bd58" + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fcb73f69d655b48fcb894a262f074218df08bd58", - "reference": "fcb73f69d655b48fcb894a262f074218df08bd58", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", "shasum": "" }, "require": { - "php": ">=8.4", - "symfony/polyfill-mbstring": "^1.0", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.4|^8.0" + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^7.4|^8.0", - "symfony/dependency-injection": "^7.4|^8.0", - "symfony/event-dispatcher": "^7.4|^8.0", - "symfony/http-foundation": "^7.4|^8.0", - "symfony/http-kernel": "^7.4|^8.0", - "symfony/lock": "^7.4|^8.0", - "symfony/messenger": "^7.4|^8.0", - "symfony/process": "^7.4|^8.0", - "symfony/stopwatch": "^7.4|^8.0", - "symfony/var-dumper": "^7.4|^8.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -8028,7 +8203,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.1" + "source": "https://github.com/symfony/console/tree/v7.4.1" }, "funding": [ { @@ -8048,7 +8223,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:25:33+00:00" + "time": "2025-12-05T15:23:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -9822,6 +9997,64 @@ } ], "packages-dev": [ + { + "name": "admad/entity", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/ADmad/cakephp-entity.git", + "reference": "c0d105602f7dee53360db0a7a1e602540f8c7bfe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ADmad/cakephp-entity/zipball/c0d105602f7dee53360db0a7a1e602540f8c7bfe", + "reference": "c0d105602f7dee53360db0a7a1e602540f8c7bfe", + "shasum": "" + }, + "require": { + "cakephp/cakephp": "^5.3@RC", + "php": ">=8.4" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^12.5.3" + }, + "default-branch": true, + "type": "cakephp-plugin", + "autoload": { + "psr-4": { + "ADmad\\Entity\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ADmad\\Entity\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", + "TestApp\\": "tests/test_app/TestApp/", + "TestPlugin\\": "tests/test_app/Plugin/TestPlugin/src/", + "TestPlugin\\Test\\": "tests/test_app/Plugin/TestPlugin/tests/", + "TestPluginTwo\\": "tests/test_app/Plugin/TestPluginTwo/src/", + "Company\\TestPluginThree\\": "tests/test_app/Plugin/Company/TestPluginThree/src/", + "Company\\TestPluginThree\\Test\\": "tests/test_app/Plugin/Company/TestPluginThree/tests/", + "Named\\": "tests/test_app/Plugin/Named/src/" + } + }, + "license": [ + "MIT" + ], + "description": "ADmad/Entity plugin for CakePHP", + "support": { + "source": "https://github.com/ADmad/cakephp-entity/tree/master", + "issues": "https://github.com/ADmad/cakephp-entity/issues" + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/ADmad" + } + ], + "time": "2025-12-14T17:27:12+00:00" + }, { "name": "cakedc/cakephp-phpstan", "version": "4.1.1", @@ -12432,16 +12665,10 @@ "aliases": [ { "package": "cakephp/cakephp", - "version": "dev-5.next", + "version": "dev-5.next-dto-projection", "alias": "5.3.0", "alias_normalized": "5.3.0.0" }, - { - "package": "cakephp/migrations", - "version": "5.9999999.9999999.9999999-dev", - "alias": "4.9.1", - "alias_normalized": "4.9.1.0" - }, { "package": "dereuromark/cakephp-dto", "version": "9999999-dev", @@ -12505,8 +12732,8 @@ ], "minimum-stability": "dev", "stability-flags": { + "admad/entity": 20, "cakephp/cakephp": 20, - "cakephp/migrations": 20, "dereuromark/cakephp-ajax": 20, "dereuromark/cakephp-audit-stash": 20, "dereuromark/cakephp-bouncer": 20, diff --git a/config/auth_allow.ini b/config/auth_allow.ini index 893fab4d..5c0c6730 100644 --- a/config/auth_allow.ini +++ b/config/auth_allow.ini @@ -9,7 +9,9 @@ Account = login, logout, register, activate, lostPassword, changePassword Overview = index -Misc = index, convertText, analyzeTest +Misc = index, convertText, analyzeTest, dtoProjection + +DtoProjection = * ; /plugins/ dir Sandbox.AjaxExamples = * diff --git a/config/dto/projection.dto.xml b/config/dto/projection.dto.xml new file mode 100644 index 00000000..7c820061 --- /dev/null +++ b/config/dto/projection.dto.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/Sandbox/config/dto.xml b/plugins/Sandbox/config/dto.xml index 1872edbb..645699b0 100644 --- a/plugins/Sandbox/config/dto.xml +++ b/plugins/Sandbox/config/dto.xml @@ -53,4 +53,13 @@ + + + + + + + + + diff --git a/plugins/Sandbox/src/Dto/Github/BaseDto.php b/plugins/Sandbox/src/Dto/Github/BaseDto.php index 586095ad..07ada734 100644 --- a/plugins/Sandbox/src/Dto/Github/BaseDto.php +++ b/plugins/Sandbox/src/Dto/Github/BaseDto.php @@ -154,6 +154,43 @@ class BaseDto extends AbstractDto { 'repo' => 'setRepo', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['ref'])) { + $this->ref = $data['ref']; + $this->_touchedFields['ref'] = true; + } + if (isset($data['sha'])) { + $this->sha = $data['sha']; + $this->_touchedFields['sha'] = true; + } + if (isset($data['user'])) { + $value = $data['user']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\UserDto($value, true); + } + $this->user = $value; + $this->_touchedFields['user'] = true; + } + if (isset($data['repo'])) { + $value = $data['repo']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\RepoDto($value, true); + } + $this->repo = $value; + $this->_touchedFields['repo'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/Github/HeadDto.php b/plugins/Sandbox/src/Dto/Github/HeadDto.php index 49291abb..09228821 100644 --- a/plugins/Sandbox/src/Dto/Github/HeadDto.php +++ b/plugins/Sandbox/src/Dto/Github/HeadDto.php @@ -154,6 +154,43 @@ class HeadDto extends AbstractDto { 'repo' => 'setRepo', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['ref'])) { + $this->ref = $data['ref']; + $this->_touchedFields['ref'] = true; + } + if (isset($data['sha'])) { + $this->sha = $data['sha']; + $this->_touchedFields['sha'] = true; + } + if (isset($data['user'])) { + $value = $data['user']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\UserDto($value, true); + } + $this->user = $value; + $this->_touchedFields['user'] = true; + } + if (isset($data['repo'])) { + $value = $data['repo']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\RepoDto($value, true); + } + $this->repo = $value; + $this->_touchedFields['repo'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/Github/LabelDto.php b/plugins/Sandbox/src/Dto/Github/LabelDto.php index 09745788..44f17595 100644 --- a/plugins/Sandbox/src/Dto/Github/LabelDto.php +++ b/plugins/Sandbox/src/Dto/Github/LabelDto.php @@ -100,6 +100,27 @@ class LabelDto extends AbstractDto { 'color' => 'setColor', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['name'])) { + $this->name = $data['name']; + $this->_touchedFields['name'] = true; + } + if (isset($data['color'])) { + $this->color = $data['color']; + $this->_touchedFields['color'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/Github/PullRequestDto.php b/plugins/Sandbox/src/Dto/Github/PullRequestDto.php index 65783a3d..f400b397 100644 --- a/plugins/Sandbox/src/Dto/Github/PullRequestDto.php +++ b/plugins/Sandbox/src/Dto/Github/PullRequestDto.php @@ -321,6 +321,82 @@ class PullRequestDto extends AbstractDto { 'base' => 'setBase', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['url'])) { + $this->url = $data['url']; + $this->_touchedFields['url'] = true; + } + if (isset($data['number'])) { + $this->number = $data['number']; + $this->_touchedFields['number'] = true; + } + if (isset($data['state'])) { + $this->state = $data['state']; + $this->_touchedFields['state'] = true; + } + if (isset($data['title'])) { + $this->title = $data['title']; + $this->_touchedFields['title'] = true; + } + if (isset($data['body'])) { + $this->body = $data['body']; + $this->_touchedFields['body'] = true; + } + if (isset($data['user'])) { + $value = $data['user']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\UserDto($value, true); + } + $this->user = $value; + $this->_touchedFields['user'] = true; + } + if (isset($data['createdAt'])) { + $value = $data['createdAt']; + if (!is_object($value)) { + $value = $this->createWithConstructor('createdAt', $value, $this->_metadata['createdAt']); + } + $this->createdAt = $value; + $this->_touchedFields['createdAt'] = true; + } + if (isset($data['labels'])) { + $collection = []; + foreach ($data['labels'] as $key => $item) { + if (is_array($item)) { + $item = new \Sandbox\Dto\Github\LabelDto($item, true); + } + $collection[$key] = $item; + } + $this->labels = $collection; + $this->_touchedFields['labels'] = true; + } + if (isset($data['head'])) { + $value = $data['head']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\HeadDto($value, true); + } + $this->head = $value; + $this->_touchedFields['head'] = true; + } + if (isset($data['base'])) { + $value = $data['base']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\BaseDto($value, true); + } + $this->base = $value; + $this->_touchedFields['base'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/Github/RepoDto.php b/plugins/Sandbox/src/Dto/Github/RepoDto.php index 78a73baa..d113d538 100644 --- a/plugins/Sandbox/src/Dto/Github/RepoDto.php +++ b/plugins/Sandbox/src/Dto/Github/RepoDto.php @@ -154,6 +154,39 @@ class RepoDto extends AbstractDto { 'owner' => 'setOwner', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['name'])) { + $this->name = $data['name']; + $this->_touchedFields['name'] = true; + } + if (isset($data['htmlUrl'])) { + $this->htmlUrl = $data['htmlUrl']; + $this->_touchedFields['htmlUrl'] = true; + } + if (isset($data['private'])) { + $this->private = $data['private']; + $this->_touchedFields['private'] = true; + } + if (isset($data['owner'])) { + $value = $data['owner']; + if (is_array($value)) { + $value = new \Sandbox\Dto\Github\UserDto($value, true); + } + $this->owner = $value; + $this->_touchedFields['owner'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/Github/UserDto.php b/plugins/Sandbox/src/Dto/Github/UserDto.php index ec59c340..da5d4d85 100644 --- a/plugins/Sandbox/src/Dto/Github/UserDto.php +++ b/plugins/Sandbox/src/Dto/Github/UserDto.php @@ -127,6 +127,31 @@ class UserDto extends AbstractDto { 'type' => 'setType', ]; + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['login'])) { + $this->login = $data['login']; + $this->_touchedFields['login'] = true; + } + if (isset($data['htmlUrl'])) { + $this->htmlUrl = $data['htmlUrl']; + $this->_touchedFields['htmlUrl'] = true; + } + if (isset($data['type'])) { + $this->type = $data['type']; + $this->_touchedFields['type'] = true; + } + } + /** * Optimized setDefaults - only processes fields with default values. diff --git a/plugins/Sandbox/src/Dto/SandboxUserProjectionDto.php b/plugins/Sandbox/src/Dto/SandboxUserProjectionDto.php new file mode 100644 index 00000000..1fba1462 --- /dev/null +++ b/plugins/Sandbox/src/Dto/SandboxUserProjectionDto.php @@ -0,0 +1,542 @@ +> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'username' => [ + 'name' => 'username', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'email' => [ + 'name' => 'email', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'status' => [ + 'name' => 'status', + 'type' => '\Sandbox\Model\Enum\UserStatus', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + 'isClass' => true, + 'enum' => 'int', + ], + 'created' => [ + 'name' => 'created', + 'type' => '\Cake\I18n\DateTime', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + 'isClass' => true, + 'enum' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + 'status' => 'status', + 'created' => 'created', + ], + 'dashed' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + 'status' => 'status', + 'created' => 'created', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'username' => 'withUsername', + 'email' => 'withEmail', + 'status' => 'withStatus', + 'created' => 'withCreated', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['username'])) { + $this->username = $data['username']; + $this->_touchedFields['username'] = true; + } + if (isset($data['email'])) { + $this->email = $data['email']; + $this->_touchedFields['email'] = true; + } + if (isset($data['status'])) { + $value = $data['status']; + if (!is_object($value)) { + $value = $this->createEnum('status', $value); + } + $this->status = $value; + $this->_touchedFields['status'] = true; + } + if (isset($data['created'])) { + $value = $data['created']; + if (!is_object($value)) { + $value = $this->createWithConstructor('created', $value, $this->_metadata['created']); + } + $this->created = $value; + $this->_touchedFields['created'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $username + * + * @return static + */ + public function withUsername(?string $username = null) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @param string $username + * + * @return static + */ + public function withUsernameOrFail(string $username) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getUsername(): ?string { + return $this->username; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getUsernameOrFail(): string { + if ($this->username === null) { + throw new \RuntimeException('Value not set for field `username` (expected to be not null)'); + } + + return $this->username; + } + + /** + * @return bool + */ + public function hasUsername(): bool { + return $this->username !== null; + } + + /** + * @param string|null $email + * + * @return static + */ + public function withEmail(?string $email = null) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @param string $email + * + * @return static + */ + public function withEmailOrFail(string $email) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getEmail(): ?string { + return $this->email; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getEmailOrFail(): string { + if ($this->email === null) { + throw new \RuntimeException('Value not set for field `email` (expected to be not null)'); + } + + return $this->email; + } + + /** + * @return bool + */ + public function hasEmail(): bool { + return $this->email !== null; + } + + /** + * @param \Sandbox\Model\Enum\UserStatus|null $status + * + * @return static + */ + public function withStatus(?\Sandbox\Model\Enum\UserStatus $status = null) { + $new = clone $this; + $new->status = $status; + $new->_touchedFields[static::FIELD_STATUS] = true; + + return $new; + } + + /** + * @param \Sandbox\Model\Enum\UserStatus $status + * + * @return static + */ + public function withStatusOrFail(\Sandbox\Model\Enum\UserStatus $status) { + $new = clone $this; + $new->status = $status; + $new->_touchedFields[static::FIELD_STATUS] = true; + + return $new; + } + + /** + * @return \Sandbox\Model\Enum\UserStatus|null + */ + public function getStatus(): ?\Sandbox\Model\Enum\UserStatus { + return $this->status; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return \Sandbox\Model\Enum\UserStatus + */ + public function getStatusOrFail(): \Sandbox\Model\Enum\UserStatus { + if ($this->status === null) { + throw new \RuntimeException('Value not set for field `status` (expected to be not null)'); + } + + return $this->status; + } + + /** + * @return bool + */ + public function hasStatus(): bool { + return $this->status !== null; + } + + /** + * @param \Cake\I18n\DateTime|null $created + * + * @return static + */ + public function withCreated(?\Cake\I18n\DateTime $created = null) { + $new = clone $this; + $new->created = $created; + $new->_touchedFields[static::FIELD_CREATED] = true; + + return $new; + } + + /** + * @param \Cake\I18n\DateTime $created + * + * @return static + */ + public function withCreatedOrFail(\Cake\I18n\DateTime $created) { + $new = clone $this; + $new->created = $created; + $new->_touchedFields[static::FIELD_CREATED] = true; + + return $new; + } + + /** + * @return \Cake\I18n\DateTime|null + */ + public function getCreated(): ?\Cake\I18n\DateTime { + return $this->created; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return \Cake\I18n\DateTime + */ + public function getCreatedOrFail(): \Cake\I18n\DateTime { + if ($this->created === null) { + throw new \RuntimeException('Value not set for field `created` (expected to be not null)'); + } + + return $this->created; + } + + /** + * @return bool + */ + public function hasCreated(): bool { + return $this->created !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, username: string|null, email: string|null, status: \Sandbox\Model\Enum\UserStatus|null, created: \Cake\I18n\DateTime|null} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, username: string|null, email: string|null, status: \Sandbox\Model\Enum\UserStatus|null, created: \Cake\I18n\DateTime|null} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, username: string|null, email: string|null, status: \Sandbox\Model\Enum\UserStatus|null, created: \Cake\I18n\DateTime|null} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Controller/DtoProjectionController.php b/src/Controller/DtoProjectionController.php new file mode 100644 index 00000000..7de2664f --- /dev/null +++ b/src/Controller/DtoProjectionController.php @@ -0,0 +1,245 @@ +fetchTable('Users'); + + // Traditional Entity approach + $entities = $usersTable->find() + ->contain(['Roles']) + ->limit(5) + ->toArray(); + + // DTO projection using DtoMapper (reflection-based) + $dtosSimple = $usersTable->find() + ->contain(['Roles']) + ->limit(5) + ->projectAs(SimpleUserDto::class) + ->toArray(); + + // DTO projection using cakephp-dto plugin (createFromArray) + $dtosPlugin = $usersTable->find() + ->contain(['Roles']) + ->limit(5) + ->projectAs(UserProjectionDto::class) + ->toArray(); + + $this->set(compact('entities', 'dtosSimple', 'dtosPlugin')); + } + + /** + * HasMany association demo - Roles with Users. + * + * Shows: + * - HasMany collection hydrated using #[CollectionOf] attribute + * + * @return void + */ + public function hasMany(): void { + $rolesTable = $this->fetchTable('Roles'); + + // Traditional Entities + $entities = $rolesTable->find() + ->contain(['Users']) + ->limit(3) + ->toArray(); + + // DTO projection with HasMany + $dtos = $rolesTable->find() + ->contain(['Users']) + ->limit(3) + ->projectAs(SimpleRoleDto::class) + ->toArray(); + + $this->set(compact('entities', 'dtos')); + } + + /** + * BelongsToMany with _joinData demo - Posts with Tags. + * + * Shows: + * - BelongsToMany association + * - _joinData (pivot table data) hydrated into nested DTO + * + * @return void + */ + public function belongsToMany(): void { + $postsTable = $this->fetchTable('Sandbox.SandboxPosts'); + + // Ensure demo data exists + $postsTable->ensureDemoData(); + + // Traditional Entities with Tags + $entities = $postsTable->find() + ->contain(['Tags']) + ->limit(3) + ->toArray(); + + // DTO projection with BelongsToMany + // Note: _joinData is automatically hydrated into TagDto->_joinData + $dtos = $postsTable->find() + ->contain(['Tags']) + ->limit(3) + ->projectAs(PostDto::class) + ->toArray(); + + $this->set(compact('entities', 'dtos')); + } + + /** + * Matching query demo - Users matching specific Role. + * + * Shows: + * - matching() finder with _matchingData + * - How matched association data appears in DTOs + * - DTO without _matchingData vs DTO with _matchingData + * + * @return void + */ + public function matching(): void { + $usersTable = $this->fetchTable('Users'); + + // Traditional Entity with matching + $entities = $usersTable->find() + ->matching('Roles', function ($q) { + return $q->where(['Roles.id' => 1]); + }) + ->limit(5) + ->toArray(); + + // DTO projection WITHOUT _matchingData property + // The _matchingData from the query is ignored (not in DTO constructor) + $dtosWithout = $usersTable->find() + ->matching('Roles', function ($q) { + return $q->where(['Roles.id' => 1]); + }) + ->limit(5) + ->projectAs(SimpleUserDto::class) + ->toArray(); + + // DTO projection WITH _matchingData as array + // The _matchingData is included because UserWithMatchingDto has the property + $dtosWithArray = $usersTable->find() + ->matching('Roles', function ($q) { + return $q->where(['Roles.id' => 1]); + }) + ->limit(5) + ->projectAs(UserWithMatchingDto::class) + ->toArray(); + + // DTO projection WITH _matchingData as typed DTO + // The _matchingData is recursively mapped to MatchingDataDto->SimpleRoleDto + $dtosWithTyped = $usersTable->find() + ->matching('Roles', function ($q) { + return $q->where(['Roles.id' => 1]); + }) + ->limit(5) + ->projectAs(UserWithMatchingDtoTyped::class) + ->toArray(); + + // Get raw array to show _matchingData structure + $rawArrays = $usersTable->find() + ->matching('Roles', function ($q) { + return $q->where(['Roles.id' => 1]); + }) + ->limit(5) + ->disableHydration() + ->toArray(); + + $this->set(compact('entities', 'dtosWithout', 'dtosWithArray', 'dtosWithTyped', 'rawArrays')); + } + + /** + * Performance comparison demo. + * + * Shows memory and time comparison between: + * - Entities + * - DTOs via DtoMapper + * - DTOs via cakephp-dto plugin + * - Plain arrays + * + * @return void + */ + public function benchmark(): void { + $usersTable = $this->fetchTable('Users'); + + $iterations = 100; + $results = []; + + // Entities + gc_collect_cycles(); + $start = hrtime(true); + $memBefore = memory_get_usage(); + for ($i = 0; $i < $iterations; $i++) { + $usersTable->find()->contain(['Roles'])->limit(50)->toArray(); + } + $results['Entity'] = [ + 'time' => (hrtime(true) - $start) / 1_000_000, + 'memory' => memory_get_usage() - $memBefore, + ]; + + // SimpleUserDto (DtoMapper) + gc_collect_cycles(); + $start = hrtime(true); + $memBefore = memory_get_usage(); + for ($i = 0; $i < $iterations; $i++) { + $usersTable->find()->contain(['Roles'])->limit(50)->projectAs(SimpleUserDto::class)->toArray(); + } + $results['DtoMapper'] = [ + 'time' => (hrtime(true) - $start) / 1_000_000, + 'memory' => memory_get_usage() - $memBefore, + ]; + + // UserProjectionDto (cakephp-dto plugin) + gc_collect_cycles(); + $start = hrtime(true); + $memBefore = memory_get_usage(); + for ($i = 0; $i < $iterations; $i++) { + $usersTable->find()->contain(['Roles'])->limit(50)->projectAs(UserProjectionDto::class)->toArray(); + } + $results['cakephp-dto'] = [ + 'time' => (hrtime(true) - $start) / 1_000_000, + 'memory' => memory_get_usage() - $memBefore, + ]; + + // Plain arrays + gc_collect_cycles(); + $start = hrtime(true); + $memBefore = memory_get_usage(); + for ($i = 0; $i < $iterations; $i++) { + $usersTable->find()->contain(['Roles'])->limit(50)->enableHydration(false)->toArray(); + } + $results['Array'] = [ + 'time' => (hrtime(true) - $start) / 1_000_000, + 'memory' => memory_get_usage() - $memBefore, + ]; + + $this->set(compact('results', 'iterations')); + } + +} diff --git a/src/Controller/MiscController.php b/src/Controller/MiscController.php index 17d37df9..9a69d5cf 100644 --- a/src/Controller/MiscController.php +++ b/src/Controller/MiscController.php @@ -2,6 +2,10 @@ namespace App\Controller; +use App\Dto\RoleProjectionDto; +use App\Dto\UserProjectionDto; +use Sandbox\Dto\SandboxUserProjectionDto; + class MiscController extends AppController { /** @@ -107,4 +111,76 @@ protected function _autoDetect($text) { return 1; } + /** + * Test DTO projection with BelongsTo association. + * + * @return \Cake\Http\Response|null + */ + public function dtoProjection() { + $usersTable = $this->fetchTable('Users'); + + // Test 1: Simple projection (no associations) + $simpleUsers = $usersTable->find() + ->projectAs(UserProjectionDto::class) + ->limit(3) + ->all() + ->toArray(); + + // Test 2: Projection with BelongsTo (Users -> Roles) + $usersWithRoles = $usersTable->find() + ->contain(['Roles']) + ->projectAs(UserProjectionDto::class) + ->limit(3) + ->all() + ->toArray(); + + // Test 3: Projection with HasMany (Roles -> Users) + $rolesTable = $this->fetchTable('Roles'); + $rolesWithUsers = $rolesTable->find() + ->contain(['Users']) + ->projectAs(RoleProjectionDto::class) + ->limit(3) + ->all() + ->toArray(); + + // Test 4: Compare with Entity hydration + $usersAsEntities = $usersTable->find() + ->contain(['Roles']) + ->limit(3) + ->all() + ->toArray(); + + // Test 5: Compare with disableHydration (raw arrays) + $usersAsArrays = $usersTable->find() + ->contain(['Roles']) + ->disableHydration() + ->limit(3) + ->all() + ->toArray(); + + // Test 6: Projection with enum field (SandboxUsers -> UserStatus enum) + $sandboxUsersTable = $this->fetchTable('Sandbox.SandboxUsers'); + $sandboxUsers = $sandboxUsersTable->find() + ->projectAs(SandboxUserProjectionDto::class) + ->limit(5) + ->all() + ->toArray(); + + // Test 7: Entity hydration with enum (comparison) + $sandboxUsersAsEntities = $sandboxUsersTable->find() + ->limit(5) + ->all() + ->toArray(); + + $this->set(compact( + 'simpleUsers', + 'usersWithRoles', + 'rolesWithUsers', + 'usersAsEntities', + 'usersAsArrays', + 'sandboxUsers', + 'sandboxUsersAsEntities', + )); + } + } diff --git a/src/Dto/ArticleBenchmarkDto.php b/src/Dto/ArticleBenchmarkDto.php new file mode 100644 index 00000000..fe079889 --- /dev/null +++ b/src/Dto/ArticleBenchmarkDto.php @@ -0,0 +1,542 @@ + $comments + */ +class ArticleBenchmarkDto extends AbstractImmutableDto { + + /** + * @var string + */ + public const FIELD_ID = 'id'; + /** + * @var string + */ + public const FIELD_TITLE = 'title'; + /** + * @var string + */ + public const FIELD_BODY = 'body'; + /** + * @var string + */ + public const FIELD_AUTHOR = 'author'; + /** + * @var string + */ + public const FIELD_COMMENTS = 'comments'; + + /** + * @var int|null + */ + protected $id; + + /** + * @var string|null + */ + protected $title; + + /** + * @var string|null + */ + protected $body; + + /** + * @var \App\Dto\AuthorBenchmarkDto|null + */ + protected $author; + + /** + * @var array + */ + protected $comments; + + /** + * Some data is only for debugging for now. + * + * @var array> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'title' => [ + 'name' => 'title', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'body' => [ + 'name' => 'body', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'author' => [ + 'name' => 'author', + 'type' => '\App\Dto\AuthorBenchmarkDto', + 'required' => false, + 'defaultValue' => null, + 'dto' => 'AuthorBenchmark', + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'comments' => [ + 'name' => 'comments', + 'type' => '\App\Dto\CommentBenchmarkDto[]', + 'collectionType' => 'array', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + 'singularType' => '\App\Dto\CommentBenchmarkDto', + 'singularNullable' => false, + 'singularTypeHint' => '\App\Dto\CommentBenchmarkDto', + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'title' => 'title', + 'body' => 'body', + 'author' => 'author', + 'comments' => 'comments', + ], + 'dashed' => [ + 'id' => 'id', + 'title' => 'title', + 'body' => 'body', + 'author' => 'author', + 'comments' => 'comments', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'title' => 'withTitle', + 'body' => 'withBody', + 'author' => 'withAuthor', + 'comments' => 'withComments', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['title'])) { + $this->title = $data['title']; + $this->_touchedFields['title'] = true; + } + if (isset($data['body'])) { + $this->body = $data['body']; + $this->_touchedFields['body'] = true; + } + if (isset($data['author'])) { + $value = $data['author']; + if (is_array($value)) { + $value = new \App\Dto\AuthorBenchmarkDto($value, true); + } + $this->author = $value; + $this->_touchedFields['author'] = true; + } + if (isset($data['comments'])) { + $collection = []; + foreach ($data['comments'] as $key => $item) { + if (is_array($item)) { + $item = new \App\Dto\CommentBenchmarkDto($item, true); + } + $collection[$key] = $item; + } + $this->comments = $collection; + $this->_touchedFields['comments'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $title + * + * @return static + */ + public function withTitle(?string $title = null) { + $new = clone $this; + $new->title = $title; + $new->_touchedFields[static::FIELD_TITLE] = true; + + return $new; + } + + /** + * @param string $title + * + * @return static + */ + public function withTitleOrFail(string $title) { + $new = clone $this; + $new->title = $title; + $new->_touchedFields[static::FIELD_TITLE] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getTitle(): ?string { + return $this->title; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getTitleOrFail(): string { + if ($this->title === null) { + throw new \RuntimeException('Value not set for field `title` (expected to be not null)'); + } + + return $this->title; + } + + /** + * @return bool + */ + public function hasTitle(): bool { + return $this->title !== null; + } + + /** + * @param string|null $body + * + * @return static + */ + public function withBody(?string $body = null) { + $new = clone $this; + $new->body = $body; + $new->_touchedFields[static::FIELD_BODY] = true; + + return $new; + } + + /** + * @param string $body + * + * @return static + */ + public function withBodyOrFail(string $body) { + $new = clone $this; + $new->body = $body; + $new->_touchedFields[static::FIELD_BODY] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getBody(): ?string { + return $this->body; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getBodyOrFail(): string { + if ($this->body === null) { + throw new \RuntimeException('Value not set for field `body` (expected to be not null)'); + } + + return $this->body; + } + + /** + * @return bool + */ + public function hasBody(): bool { + return $this->body !== null; + } + + /** + * @param \App\Dto\AuthorBenchmarkDto|null $author + * + * @return static + */ + public function withAuthor(?\App\Dto\AuthorBenchmarkDto $author = null) { + $new = clone $this; + $new->author = $author; + $new->_touchedFields[static::FIELD_AUTHOR] = true; + + return $new; + } + + /** + * @param \App\Dto\AuthorBenchmarkDto $author + * + * @return static + */ + public function withAuthorOrFail(\App\Dto\AuthorBenchmarkDto $author) { + $new = clone $this; + $new->author = $author; + $new->_touchedFields[static::FIELD_AUTHOR] = true; + + return $new; + } + + /** + * @return \App\Dto\AuthorBenchmarkDto|null + */ + public function getAuthor(): ?\App\Dto\AuthorBenchmarkDto { + return $this->author; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return \App\Dto\AuthorBenchmarkDto + */ + public function getAuthorOrFail(): \App\Dto\AuthorBenchmarkDto { + if ($this->author === null) { + throw new \RuntimeException('Value not set for field `author` (expected to be not null)'); + } + + return $this->author; + } + + /** + * @return bool + */ + public function hasAuthor(): bool { + return $this->author !== null; + } + + /** + * @param array $comments + * + * @return static + */ + public function withComments(array $comments) { + $new = clone $this; + $new->comments = $comments; + $new->_touchedFields[static::FIELD_COMMENTS] = true; + + return $new; + } + + /** + * @return array + */ + public function getComments(): array { + if ($this->comments === null) { + return []; + } + + return $this->comments; + } + + /** + * @return bool + */ + public function hasComments(): bool { + if ($this->comments === null) { + return false; + } + + return count($this->comments) > 0; + } + /** + * @param \App\Dto\CommentBenchmarkDto $comment + * @return static + */ + public function withAddedComment(\App\Dto\CommentBenchmarkDto $comment) { + $new = clone $this; + + if ($new->comments === null) { + $new->comments = []; + } + + $new->comments[] = $comment; + $new->_touchedFields[static::FIELD_COMMENTS] = true; + + return $new; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, title: string|null, body: string|null, author: array{id: int|null, name: string|null}|null, comments: array} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, title: string|null, body: string|null, author: array{id: int|null, name: string|null}|null, comments: array} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, title: string|null, body: string|null, author: array{id: int|null, name: string|null}|null, comments: array} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/AuthorBenchmarkDto.php b/src/Dto/AuthorBenchmarkDto.php new file mode 100644 index 00000000..6fa5bf0a --- /dev/null +++ b/src/Dto/AuthorBenchmarkDto.php @@ -0,0 +1,278 @@ +> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'name' => [ + 'name' => 'name', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'name' => 'name', + ], + 'dashed' => [ + 'id' => 'id', + 'name' => 'name', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'name' => 'withName', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['name'])) { + $this->name = $data['name']; + $this->_touchedFields['name'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $name + * + * @return static + */ + public function withName(?string $name = null) { + $new = clone $this; + $new->name = $name; + $new->_touchedFields[static::FIELD_NAME] = true; + + return $new; + } + + /** + * @param string $name + * + * @return static + */ + public function withNameOrFail(string $name) { + $new = clone $this; + $new->name = $name; + $new->_touchedFields[static::FIELD_NAME] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getName(): ?string { + return $this->name; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getNameOrFail(): string { + if ($this->name === null) { + throw new \RuntimeException('Value not set for field `name` (expected to be not null)'); + } + + return $this->name; + } + + /** + * @return bool + */ + public function hasName(): bool { + return $this->name !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, name: string|null} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, name: string|null} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, name: string|null} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/CommentBenchmarkDto.php b/src/Dto/CommentBenchmarkDto.php new file mode 100644 index 00000000..415c276e --- /dev/null +++ b/src/Dto/CommentBenchmarkDto.php @@ -0,0 +1,446 @@ +> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'comment' => [ + 'name' => 'comment', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'articleId' => [ + 'name' => 'articleId', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'userId' => [ + 'name' => 'userId', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'comment' => 'comment', + 'article_id' => 'articleId', + 'user_id' => 'userId', + ], + 'dashed' => [ + 'id' => 'id', + 'comment' => 'comment', + 'article-id' => 'articleId', + 'user-id' => 'userId', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'comment' => 'withComment', + 'articleId' => 'withArticleid', + 'userId' => 'withUserid', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['comment'])) { + $this->comment = $data['comment']; + $this->_touchedFields['comment'] = true; + } + if (isset($data['articleId'])) { + $this->articleId = $data['articleId']; + $this->_touchedFields['articleId'] = true; + } + if (isset($data['userId'])) { + $this->userId = $data['userId']; + $this->_touchedFields['userId'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $comment + * + * @return static + */ + public function withComment(?string $comment = null) { + $new = clone $this; + $new->comment = $comment; + $new->_touchedFields[static::FIELD_COMMENT] = true; + + return $new; + } + + /** + * @param string $comment + * + * @return static + */ + public function withCommentOrFail(string $comment) { + $new = clone $this; + $new->comment = $comment; + $new->_touchedFields[static::FIELD_COMMENT] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getComment(): ?string { + return $this->comment; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getCommentOrFail(): string { + if ($this->comment === null) { + throw new \RuntimeException('Value not set for field `comment` (expected to be not null)'); + } + + return $this->comment; + } + + /** + * @return bool + */ + public function hasComment(): bool { + return $this->comment !== null; + } + + /** + * @param int|null $articleId + * + * @return static + */ + public function withArticleId(?int $articleId = null) { + $new = clone $this; + $new->articleId = $articleId; + $new->_touchedFields[static::FIELD_ARTICLE_ID] = true; + + return $new; + } + + /** + * @param int $articleId + * + * @return static + */ + public function withArticleIdOrFail(int $articleId) { + $new = clone $this; + $new->articleId = $articleId; + $new->_touchedFields[static::FIELD_ARTICLE_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getArticleId(): ?int { + return $this->articleId; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getArticleIdOrFail(): int { + if ($this->articleId === null) { + throw new \RuntimeException('Value not set for field `articleId` (expected to be not null)'); + } + + return $this->articleId; + } + + /** + * @return bool + */ + public function hasArticleId(): bool { + return $this->articleId !== null; + } + + /** + * @param int|null $userId + * + * @return static + */ + public function withUserId(?int $userId = null) { + $new = clone $this; + $new->userId = $userId; + $new->_touchedFields[static::FIELD_USER_ID] = true; + + return $new; + } + + /** + * @param int $userId + * + * @return static + */ + public function withUserIdOrFail(int $userId) { + $new = clone $this; + $new->userId = $userId; + $new->_touchedFields[static::FIELD_USER_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getUserId(): ?int { + return $this->userId; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getUserIdOrFail(): int { + if ($this->userId === null) { + throw new \RuntimeException('Value not set for field `userId` (expected to be not null)'); + } + + return $this->userId; + } + + /** + * @return bool + */ + public function hasUserId(): bool { + return $this->userId !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, comment: string|null, articleId: int|null, userId: int|null} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, comment: string|null, articleId: int|null, userId: int|null} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, comment: string|null, articleId: int|null, userId: int|null} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/MatchingDataDto.php b/src/Dto/MatchingDataDto.php new file mode 100644 index 00000000..5be948df --- /dev/null +++ b/src/Dto/MatchingDataDto.php @@ -0,0 +1,17 @@ + $tags BelongsToMany Tags (each has _joinData) + */ + public function __construct( + public int $id, + public string $title, + public ?string $content = null, + public ?string $slug = null, + #[CollectionOf(TagDto::class)] + public array $tags = [], + ) { + } +} diff --git a/src/Dto/RoleProjectionDto.php b/src/Dto/RoleProjectionDto.php new file mode 100644 index 00000000..58cce6da --- /dev/null +++ b/src/Dto/RoleProjectionDto.php @@ -0,0 +1,370 @@ + $users + */ +class RoleProjectionDto extends AbstractImmutableDto { + + /** + * @var string + */ + public const FIELD_ID = 'id'; + /** + * @var string + */ + public const FIELD_NAME = 'name'; + /** + * @var string + */ + public const FIELD_USERS = 'users'; + + /** + * @var int|null + */ + protected $id; + + /** + * @var string|null + */ + protected $name; + + /** + * @var array + */ + protected $users; + + /** + * Some data is only for debugging for now. + * + * @var array> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'name' => [ + 'name' => 'name', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'users' => [ + 'name' => 'users', + 'type' => '\App\Dto\UserSimpleProjectionDto[]', + 'collectionType' => 'array', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + 'singularType' => '\App\Dto\UserSimpleProjectionDto', + 'singularNullable' => false, + 'singularTypeHint' => '\App\Dto\UserSimpleProjectionDto', + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'name' => 'name', + 'users' => 'users', + ], + 'dashed' => [ + 'id' => 'id', + 'name' => 'name', + 'users' => 'users', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'name' => 'withName', + 'users' => 'withUsers', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['name'])) { + $this->name = $data['name']; + $this->_touchedFields['name'] = true; + } + if (isset($data['users'])) { + $collection = []; + foreach ($data['users'] as $key => $item) { + if (is_array($item)) { + $item = new \App\Dto\UserSimpleProjectionDto($item, true); + } + $collection[$key] = $item; + } + $this->users = $collection; + $this->_touchedFields['users'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $name + * + * @return static + */ + public function withName(?string $name = null) { + $new = clone $this; + $new->name = $name; + $new->_touchedFields[static::FIELD_NAME] = true; + + return $new; + } + + /** + * @param string $name + * + * @return static + */ + public function withNameOrFail(string $name) { + $new = clone $this; + $new->name = $name; + $new->_touchedFields[static::FIELD_NAME] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getName(): ?string { + return $this->name; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getNameOrFail(): string { + if ($this->name === null) { + throw new \RuntimeException('Value not set for field `name` (expected to be not null)'); + } + + return $this->name; + } + + /** + * @return bool + */ + public function hasName(): bool { + return $this->name !== null; + } + + /** + * @param array $users + * + * @return static + */ + public function withUsers(array $users) { + $new = clone $this; + $new->users = $users; + $new->_touchedFields[static::FIELD_USERS] = true; + + return $new; + } + + /** + * @return array + */ + public function getUsers(): array { + if ($this->users === null) { + return []; + } + + return $this->users; + } + + /** + * @return bool + */ + public function hasUsers(): bool { + if ($this->users === null) { + return false; + } + + return count($this->users) > 0; + } + /** + * @param \App\Dto\UserSimpleProjectionDto $user + * @return static + */ + public function withAddedUser(\App\Dto\UserSimpleProjectionDto $user) { + $new = clone $this; + + if ($new->users === null) { + $new->users = []; + } + + $new->users[] = $user; + $new->_touchedFields[static::FIELD_USERS] = true; + + return $new; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, name: string|null, users: array} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, name: string|null, users: array} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, name: string|null, users: array} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/SimpleRoleDto.php b/src/Dto/SimpleRoleDto.php new file mode 100644 index 00000000..72f68909 --- /dev/null +++ b/src/Dto/SimpleRoleDto.php @@ -0,0 +1,25 @@ + $users HasMany Users + */ + public function __construct( + public int $id, + public string $name, + #[CollectionOf(SimpleUserDto::class)] + public array $users = [], + ) { + } +} diff --git a/src/Dto/SimpleUserDto.php b/src/Dto/SimpleUserDto.php new file mode 100644 index 00000000..67c2f6df --- /dev/null +++ b/src/Dto/SimpleUserDto.php @@ -0,0 +1,21 @@ +> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'username' => [ + 'name' => 'username', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'email' => [ + 'name' => 'email', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'role' => [ + 'name' => 'role', + 'type' => '\App\Dto\RoleProjectionDto', + 'required' => false, + 'defaultValue' => null, + 'dto' => 'RoleProjection', + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'created' => [ + 'name' => 'created', + 'type' => '\Cake\I18n\DateTime', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + 'isClass' => true, + 'enum' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + 'role' => 'role', + 'created' => 'created', + ], + 'dashed' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + 'role' => 'role', + 'created' => 'created', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'username' => 'withUsername', + 'email' => 'withEmail', + 'role' => 'withRole', + 'created' => 'withCreated', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['username'])) { + $this->username = $data['username']; + $this->_touchedFields['username'] = true; + } + if (isset($data['email'])) { + $this->email = $data['email']; + $this->_touchedFields['email'] = true; + } + if (isset($data['role'])) { + $value = $data['role']; + if (is_array($value)) { + $value = new \App\Dto\RoleProjectionDto($value, true); + } + $this->role = $value; + $this->_touchedFields['role'] = true; + } + if (isset($data['created'])) { + $value = $data['created']; + if (!is_object($value)) { + $value = $this->createWithConstructor('created', $value, $this->_metadata['created']); + } + $this->created = $value; + $this->_touchedFields['created'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $username + * + * @return static + */ + public function withUsername(?string $username = null) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @param string $username + * + * @return static + */ + public function withUsernameOrFail(string $username) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getUsername(): ?string { + return $this->username; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getUsernameOrFail(): string { + if ($this->username === null) { + throw new \RuntimeException('Value not set for field `username` (expected to be not null)'); + } + + return $this->username; + } + + /** + * @return bool + */ + public function hasUsername(): bool { + return $this->username !== null; + } + + /** + * @param string|null $email + * + * @return static + */ + public function withEmail(?string $email = null) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @param string $email + * + * @return static + */ + public function withEmailOrFail(string $email) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getEmail(): ?string { + return $this->email; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getEmailOrFail(): string { + if ($this->email === null) { + throw new \RuntimeException('Value not set for field `email` (expected to be not null)'); + } + + return $this->email; + } + + /** + * @return bool + */ + public function hasEmail(): bool { + return $this->email !== null; + } + + /** + * @param \App\Dto\RoleProjectionDto|null $role + * + * @return static + */ + public function withRole(?\App\Dto\RoleProjectionDto $role = null) { + $new = clone $this; + $new->role = $role; + $new->_touchedFields[static::FIELD_ROLE] = true; + + return $new; + } + + /** + * @param \App\Dto\RoleProjectionDto $role + * + * @return static + */ + public function withRoleOrFail(\App\Dto\RoleProjectionDto $role) { + $new = clone $this; + $new->role = $role; + $new->_touchedFields[static::FIELD_ROLE] = true; + + return $new; + } + + /** + * @return \App\Dto\RoleProjectionDto|null + */ + public function getRole(): ?\App\Dto\RoleProjectionDto { + return $this->role; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return \App\Dto\RoleProjectionDto + */ + public function getRoleOrFail(): \App\Dto\RoleProjectionDto { + if ($this->role === null) { + throw new \RuntimeException('Value not set for field `role` (expected to be not null)'); + } + + return $this->role; + } + + /** + * @return bool + */ + public function hasRole(): bool { + return $this->role !== null; + } + + /** + * @param \Cake\I18n\DateTime|null $created + * + * @return static + */ + public function withCreated(?\Cake\I18n\DateTime $created = null) { + $new = clone $this; + $new->created = $created; + $new->_touchedFields[static::FIELD_CREATED] = true; + + return $new; + } + + /** + * @param \Cake\I18n\DateTime $created + * + * @return static + */ + public function withCreatedOrFail(\Cake\I18n\DateTime $created) { + $new = clone $this; + $new->created = $created; + $new->_touchedFields[static::FIELD_CREATED] = true; + + return $new; + } + + /** + * @return \Cake\I18n\DateTime|null + */ + public function getCreated(): ?\Cake\I18n\DateTime { + return $this->created; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return \Cake\I18n\DateTime + */ + public function getCreatedOrFail(): \Cake\I18n\DateTime { + if ($this->created === null) { + throw new \RuntimeException('Value not set for field `created` (expected to be not null)'); + } + + return $this->created; + } + + /** + * @return bool + */ + public function hasCreated(): bool { + return $this->created !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, username: string|null, email: string|null, role: array{id: int|null, name: string|null, users: array}|null, created: \Cake\I18n\DateTime|null} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, username: string|null, email: string|null, role: array{id: int|null, name: string|null, users: array}|null, created: \Cake\I18n\DateTime|null} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, username: string|null, email: string|null, role: array{id: int|null, name: string|null, users: array}|null, created: \Cake\I18n\DateTime|null} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/UserSimpleProjectionDto.php b/src/Dto/UserSimpleProjectionDto.php new file mode 100644 index 00000000..8b0dc145 --- /dev/null +++ b/src/Dto/UserSimpleProjectionDto.php @@ -0,0 +1,362 @@ +> + */ + protected array $_metadata = [ + 'id' => [ + 'name' => 'id', + 'type' => 'int', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'username' => [ + 'name' => 'username', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + 'email' => [ + 'name' => 'email', + 'type' => 'string', + 'required' => false, + 'defaultValue' => null, + 'dto' => null, + 'collectionType' => null, + 'associative' => false, + 'key' => null, + 'serialize' => null, + 'factory' => null, + 'mapFrom' => null, + 'mapTo' => null, + ], + ]; + + /** + * @var array> + */ + protected array $_keyMap = [ + 'underscored' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + ], + 'dashed' => [ + 'id' => 'id', + 'username' => 'username', + 'email' => 'email', + ], + ]; + + /** + * Whether this DTO is immutable. + */ + protected const IS_IMMUTABLE = true; + + /** + * Pre-computed setter method names for fast lookup. + * + * @var array + */ + protected static array $_setters = [ + 'id' => 'withId', + 'username' => 'withUsername', + 'email' => 'withEmail', + ]; + + /** + * Optimized array assignment without dynamic method calls. + * + * This method is only called in lenient mode (ignoreMissing=true), + * where unknown fields are silently ignored. + * + * @param array $data + * + * @return void + */ + protected function setFromArrayFast(array $data): void { + if (isset($data['id'])) { + $this->id = $data['id']; + $this->_touchedFields['id'] = true; + } + if (isset($data['username'])) { + $this->username = $data['username']; + $this->_touchedFields['username'] = true; + } + if (isset($data['email'])) { + $this->email = $data['email']; + $this->_touchedFields['email'] = true; + } + } + + + /** + * Optimized setDefaults - only processes fields with default values. + * + * @return $this + */ + protected function setDefaults() { + + return $this; + } + + /** + * Optimized validate - only checks required fields. + * + * @throws \InvalidArgumentException + * + * @return void + */ + protected function validate(): void { + } + + + /** + * @param int|null $id + * + * @return static + */ + public function withId(?int $id = null) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @param int $id + * + * @return static + */ + public function withIdOrFail(int $id) { + $new = clone $this; + $new->id = $id; + $new->_touchedFields[static::FIELD_ID] = true; + + return $new; + } + + /** + * @return int|null + */ + public function getId(): ?int { + return $this->id; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return int + */ + public function getIdOrFail(): int { + if ($this->id === null) { + throw new \RuntimeException('Value not set for field `id` (expected to be not null)'); + } + + return $this->id; + } + + /** + * @return bool + */ + public function hasId(): bool { + return $this->id !== null; + } + + /** + * @param string|null $username + * + * @return static + */ + public function withUsername(?string $username = null) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @param string $username + * + * @return static + */ + public function withUsernameOrFail(string $username) { + $new = clone $this; + $new->username = $username; + $new->_touchedFields[static::FIELD_USERNAME] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getUsername(): ?string { + return $this->username; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getUsernameOrFail(): string { + if ($this->username === null) { + throw new \RuntimeException('Value not set for field `username` (expected to be not null)'); + } + + return $this->username; + } + + /** + * @return bool + */ + public function hasUsername(): bool { + return $this->username !== null; + } + + /** + * @param string|null $email + * + * @return static + */ + public function withEmail(?string $email = null) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @param string $email + * + * @return static + */ + public function withEmailOrFail(string $email) { + $new = clone $this; + $new->email = $email; + $new->_touchedFields[static::FIELD_EMAIL] = true; + + return $new; + } + + /** + * @return string|null + */ + public function getEmail(): ?string { + return $this->email; + } + + /** + * @throws \RuntimeException If value is not set. + * + * @return string + */ + public function getEmailOrFail(): string { + if ($this->email === null) { + throw new \RuntimeException('Value not set for field `email` (expected to be not null)'); + } + + return $this->email; + } + + /** + * @return bool + */ + public function hasEmail(): bool { + return $this->email !== null; + } + + /** + * @param string|null $type + * @param array|null $fields + * @param bool $touched + * + * @return array{id: int|null, username: string|null, email: string|null} + */ + public function toArray(?string $type = null, ?array $fields = null, bool $touched = false): array { + /** @var array{id: int|null, username: string|null, email: string|null} $result */ + $result = $this->_toArrayInternal($type, $fields, $touched); + + return $result; + } + + /** + * @param array{id: int|null, username: string|null, email: string|null} $data + * @phpstan-param array $data + * @param bool $ignoreMissing + * @param string|null $type + * + * @return static + */ + public static function createFromArray(array $data, bool $ignoreMissing = false, ?string $type = null): static { + return static::_createFromArrayInternal($data, $ignoreMissing, $type); + } + +} diff --git a/src/Dto/UserWithMatchingDto.php b/src/Dto/UserWithMatchingDto.php new file mode 100644 index 00000000..8eba5d07 --- /dev/null +++ b/src/Dto/UserWithMatchingDto.php @@ -0,0 +1,31 @@ +|null $_matchingData Matched association data + */ + public function __construct( + public int $id, + public string $username, + public ?string $email = null, + public ?DateTime $created = null, + public ?array $_matchingData = null, + ) { + } +} diff --git a/src/Dto/UserWithMatchingDtoTyped.php b/src/Dto/UserWithMatchingDtoTyped.php new file mode 100644 index 00000000..53c25e6d --- /dev/null +++ b/src/Dto/UserWithMatchingDtoTyped.php @@ -0,0 +1,21 @@ + $entities + * @var array<\App\Dto\PostDto> $dtos + */ +?> +

DTO Projection Demo: BelongsToMany with _joinData

+ +

+ This demo shows projectAs() with Posts having BelongsToMany Tags. + The pivot table data (_joinData) is automatically hydrated into the DTO. +

+ +

Navigation

+
    +
  • Html->link('BelongsTo', ['action' => 'index']) ?>
  • +
  • Html->link('HasMany', ['action' => 'hasMany']) ?>
  • +
  • Html->link('BelongsToMany with _joinData (this page)', ['action' => 'belongsToMany']) ?>
  • +
  • Html->link('Matching with _matchingData', ['action' => 'matching']) ?>
  • +
  • Html->link('Benchmark', ['action' => 'benchmark']) ?>
  • +
+ +

Traditional Entities

+
id}: {$entity->title}\n";
+    echo "  Tags: " . count($entity->tags) . "\n";
+    foreach ($entity->tags as $tag) {
+        echo "    - {$tag->label}";
+        if ($tag->_joinData) {
+            echo " (_joinData id: {$tag->_joinData->id}, type: " . get_class($tag->_joinData) . ")";
+        }
+        echo "\n";
+    }
+}
+?>
+ +

DTOs with _joinData

+
id}: {$dto->title}\n";
+    echo "  Tags: " . count($dto->tags) . "\n";
+    foreach ($dto->tags as $tag) {
+        echo "    - {$tag->label}";
+        if ($tag->_joinData) {
+            echo " (_joinData id: {$tag->_joinData->id}, type: " . get_class($tag->_joinData) . ")";
+        }
+        echo "\n";
+    }
+}
+?>
+ +

DTO Definitions

+
// PostDto with tags collection
+readonly class PostDto
+{
+    public function __construct(
+        public int $id,
+        public string $title,
+        public ?string $content = null,
+        #[CollectionOf(TagDto::class)]
+        public array $tags = [],
+    ) {}
+}
+
+// TagDto with _joinData for pivot table
+readonly class TagDto
+{
+    public function __construct(
+        public int $id,
+        public string $label,
+        public ?TaggedDto $_joinData = null,  // Pivot table data
+    ) {}
+}
+
+// TaggedDto for the pivot/junction table
+readonly class TaggedDto
+{
+    public function __construct(
+        public int $id,
+        public int $tag_id,
+        public int $fk_id,
+        public ?string $fk_model = null,
+    ) {}
+}
diff --git a/templates/DtoProjection/benchmark.php b/templates/DtoProjection/benchmark.php new file mode 100644 index 00000000..74e97ede --- /dev/null +++ b/templates/DtoProjection/benchmark.php @@ -0,0 +1,73 @@ + $results + * @var int $iterations + */ +?> +

DTO Projection Demo: Performance Benchmark

+ +

+ Comparing performance of different hydration approaches. + Query: Users->find()->contain(['Roles'])->limit(50) × iterations. +

+ +

Navigation

+
    +
  • Html->link('BelongsTo', ['action' => 'index']) ?>
  • +
  • Html->link('HasMany', ['action' => 'hasMany']) ?>
  • +
  • Html->link('BelongsToMany with _joinData', ['action' => 'belongsToMany']) ?>
  • +
  • Html->link('Matching with _matchingData', ['action' => 'matching']) ?>
  • +
  • Html->link('Benchmark (this page)', ['action' => 'benchmark']) ?>
  • +
+ +

Results

+ + + + + + + + + + + $data): ?> + + + + + + + + +
MethodTime (ms)Memory DeltaNotes
ms bytes + 0 ? $data['time'] / $baseTime : 0; + if ($ratio < 1) { + echo sprintf('%.1fx faster than Entity', 1 / $ratio); + } elseif ($ratio > 1) { + echo sprintf('%.1fx slower than Entity', $ratio); + } else { + echo 'baseline'; + } + ?> +
+ +

Summary

+
    +
  • Array: Fastest but no type safety or object features
  • +
  • cakephp-dto: Generated optimized code, ~2x faster than Entity
  • +
  • DtoMapper: Reflection-based, similar speed to Entity, zero boilerplate
  • +
  • Entity: Full ORM features including dirty tracking
  • +
+ +

Recommendations

+
    +
  • Use DTOs for read-heavy operations where you don't need dirty tracking
  • +
  • Use cakephp-dto plugin for maximum performance with generated code
  • +
  • Use DtoMapper for simple readonly DTOs without code generation
  • +
  • Use Entities when you need dirty tracking, validation, or save operations
  • +
diff --git a/templates/DtoProjection/has_many.php b/templates/DtoProjection/has_many.php new file mode 100644 index 00000000..b0eb1c73 --- /dev/null +++ b/templates/DtoProjection/has_many.php @@ -0,0 +1,55 @@ + $entities + * @var array<\App\Dto\SimpleRoleDto> $dtos + */ +?> +

DTO Projection Demo: HasMany

+ +

+ This demo shows projectAs() with Roles containing HasMany Users. + The #[CollectionOf] attribute specifies the DTO type for array collections. +

+ +

Navigation

+
    +
  • Html->link('BelongsTo', ['action' => 'index']) ?>
  • +
  • Html->link('HasMany (this page)', ['action' => 'hasMany']) ?>
  • +
  • Html->link('BelongsToMany with _joinData', ['action' => 'belongsToMany']) ?>
  • +
  • Html->link('Matching with _matchingData', ['action' => 'matching']) ?>
  • +
  • Html->link('Benchmark', ['action' => 'benchmark']) ?>
  • +
+ +

Traditional Entities

+
id}: {$entity->name}\n";
+    echo "  Users: " . count($entity->users) . "\n";
+    foreach ($entity->users as $user) {
+        echo "    - {$user->username} (" . get_class($user) . ")\n";
+    }
+}
+?>
+ +

DTOs via DtoMapper

+
id}: {$dto->name}\n";
+    echo "  Users: " . count($dto->users) . "\n";
+    foreach ($dto->users as $user) {
+        echo "    - {$user->username} (" . get_class($user) . ")\n";
+    }
+}
+?>
+ +

DTO Definition

+
readonly class SimpleRoleDto
+{
+    public function __construct(
+        public int $id,
+        public string $name,
+        #[CollectionOf(SimpleUserDto::class)]  // Required for array collections
+        public array $users = [],
+    ) {}
+}
diff --git a/templates/DtoProjection/index.php b/templates/DtoProjection/index.php new file mode 100644 index 00000000..cf630f04 --- /dev/null +++ b/templates/DtoProjection/index.php @@ -0,0 +1,73 @@ + $entities + * @var array<\App\Dto\SimpleUserDto> $dtosSimple + * @var array<\App\Dto\UserProjectionDto> $dtosPlugin + */ +?> +

DTO Projection Demo: BelongsTo

+ +

+ This demo shows projectAs() with Users containing BelongsTo Roles. +

+ +

Navigation

+
    +
  • Html->link('BelongsTo (this page)', ['action' => 'index']) ?>
  • +
  • Html->link('HasMany', ['action' => 'hasMany']) ?>
  • +
  • Html->link('BelongsToMany with _joinData', ['action' => 'belongsToMany']) ?>
  • +
  • Html->link('Matching with _matchingData', ['action' => 'matching']) ?>
  • +
  • Html->link('Benchmark', ['action' => 'benchmark']) ?>
  • +
+ +

Traditional Entities

+
id}: {$entity->username}";
+    if ($entity->role) {
+        echo " (Role: {$entity->role->name})";
+    }
+    echo "\n";
+    echo "  Type: " . get_class($entity) . "\n";
+}
+?>
+ +

DTOs via DtoMapper (SimpleUserDto)

+

Uses reflection-based mapping with typed constructor parameters.

+
id}: {$dto->username}";
+    if ($dto->role) {
+        echo " (Role: {$dto->role->name})";
+    }
+    echo "\n";
+    echo "  Type: " . get_class($dto) . "\n";
+}
+?>
+ +

DTOs via cakephp-dto Plugin (UserProjectionDto)

+

Uses generated createFromArray() factory method.

+
getId()}: {$dto->getUsername()}";
+    if ($dto->getRole()) {
+        echo " (Role: {$dto->getRole()->getName()})";
+    }
+    echo "\n";
+    echo "  Type: " . get_class($dto) . "\n";
+}
+?>
+ +

Code Example

+
// DtoMapper style (readonly class with typed constructor)
+$users = $usersTable->find()
+    ->contain(['Roles'])
+    ->projectAs(SimpleUserDto::class)
+    ->toArray();
+
+// cakephp-dto style (generated class with createFromArray)
+$users = $usersTable->find()
+    ->contain(['Roles'])
+    ->projectAs(UserProjectionDto::class)
+    ->toArray();
diff --git a/templates/DtoProjection/matching.php b/templates/DtoProjection/matching.php new file mode 100644 index 00000000..b2521bc8 --- /dev/null +++ b/templates/DtoProjection/matching.php @@ -0,0 +1,111 @@ + $entities + * @var array<\App\Dto\SimpleUserDto> $dtosWithout + * @var array<\App\Dto\UserWithMatchingDto> $dtosWithArray + * @var array<\App\Dto\UserWithMatchingDtoTyped> $dtosWithTyped + * @var array $rawArrays + */ +?> +

DTO Projection Demo: Matching with _matchingData

+ +

+ This demo shows projectAs() with matching() queries. + Key insight: _matchingData can be typed as array + OR as a nested DTO for full type safety. +

+ +

Navigation

+
    +
  • Html->link('BelongsTo', ['action' => 'index']) ?>
  • +
  • Html->link('HasMany', ['action' => 'hasMany']) ?>
  • +
  • Html->link('BelongsToMany with _joinData', ['action' => 'belongsToMany']) ?>
  • +
  • Html->link('Matching with _matchingData (this page)', ['action' => 'matching']) ?>
  • +
  • Html->link('Benchmark', ['action' => 'benchmark']) ?>
  • +
+ +

1. Traditional Entities

+
id}: {$entity->username}\n";
+    if (isset($entity->_matchingData['Roles'])) {
+        $role = $entity->_matchingData['Roles'];
+        echo "  _matchingData[Roles]: {$role->name} (" . get_class($role) . ")\n";
+    }
+}
+?>
+ +

2. DTO WITHOUT _matchingData

+

Using SimpleUserDto - matching data is silently ignored.

+
id}: {$dto->username}\n";
+    echo "  _matchingData: not available (no property)\n";
+}
+?>
+ +

3. DTO with _matchingData as array

+

Using UserWithMatchingDto - matching data as raw array.

+
id}: {$dto->username}\n";
+    if ($dto->_matchingData !== null) {
+        foreach ($dto->_matchingData as $alias => $data) {
+            $type = is_array($data) ? 'array' : get_class($data);
+            $name = is_array($data) ? ($data['name'] ?? '?') : $data->name;
+            echo "  _matchingData[{$alias}]: {$name} ({$type})\n";
+        }
+    }
+}
+?>
+ +

4. DTO with _matchingData as MatchingDataDto

+

Using UserWithMatchingDtoTyped - fully typed nested DTOs!

+
id}: {$dto->username}\n";
+    if ($dto->_matchingData !== null) {
+        echo "  _matchingData type: " . get_class($dto->_matchingData) . "\n";
+        if ($dto->_matchingData->Roles !== null) {
+            $role = $dto->_matchingData->Roles;
+            echo "  _matchingData->Roles: {$role->name} (" . get_class($role) . ")\n";
+        }
+    }
+}
+?>
+ +

DTO Definitions

+
// Option 1: _matchingData as array (simple)
+readonly class UserWithMatchingDto
+{
+    public function __construct(
+        public int $id,
+        public string $username,
+        public ?array $_matchingData = null,
+    ) {}
+}
+
+// Option 2: _matchingData as typed DTO (full type safety)
+readonly class MatchingDataDto
+{
+    public function __construct(
+        public ?SimpleRoleDto $Roles = null,  // Property name = association alias
+    ) {}
+}
+
+readonly class UserWithMatchingDtoTyped
+{
+    public function __construct(
+        public int $id,
+        public string $username,
+        public ?MatchingDataDto $_matchingData = null,
+    ) {}
+}
+ +

Key Takeaway

+
    +
  • ?array - Quick and simple, data stays as arrays
  • +
  • ?MatchingDataDto - Full type safety, nested DTOs auto-mapped
  • +
  • Property names in MatchingDataDto must match association aliases (e.g., Roles)
  • +
diff --git a/templates/Misc/dto_projection.php b/templates/Misc/dto_projection.php new file mode 100644 index 00000000..486ea77b --- /dev/null +++ b/templates/Misc/dto_projection.php @@ -0,0 +1,128 @@ + $simpleUsers + * @var array<\App\Dto\UserProjectionDto> $usersWithRoles + * @var array<\App\Dto\RoleProjectionDto> $rolesWithUsers + * @var array<\App\Model\Entity\User> $usersAsEntities + * @var array $usersAsArrays + * @var array<\Sandbox\Dto\SandboxUserProjectionDto> $sandboxUsers + * @var array<\Sandbox\Model\Entity\SandboxUser> $sandboxUsersAsEntities + */ +?> + +

DTO Projection POC

+ +

Test 1: Simple Projection (no associations)

+
getId() . "\n";
+	echo 'Username: ' . $user->getUsername() . "\n";
+	echo 'Email: ' . $user->getEmail() . "\n";
+	echo "---\n";
+}
+?>
+ +

Test 2: Projection with BelongsTo (Users -> Roles)

+
getId() . "\n";
+	echo 'Username: ' . $user->getUsername() . "\n";
+	$role = $user->getRole();
+	if ($role) {
+		echo 'Role Type: ' . get_class($role) . "\n";
+		echo 'Role ID: ' . $role->getId() . "\n";
+		echo 'Role Name: ' . $role->getName() . "\n";
+	} else {
+		echo "Role: NULL\n";
+	}
+	echo "---\n";
+}
+?>
+ +

Test 3: Projection with HasMany (Roles -> Users)

+
getId() . "\n";
+	echo 'Name: ' . $role->getName() . "\n";
+	$users = $role->getUsers();
+	echo 'Users count: ' . count($users) . "\n";
+	foreach ($users as $user) {
+		echo '  - User Type: ' . get_class($user) . "\n";
+		echo '    User: ' . $user->getUsername() . "\n";
+	}
+	echo "---\n";
+}
+?>
+ +

Test 4: Entity Hydration (comparison)

+
id . "\n";
+	echo 'Username: ' . $user->username . "\n";
+	if ($user->role) {
+		echo 'Role Type: ' . get_class($user->role) . "\n";
+		echo 'Role Name: ' . $user->role->name . "\n";
+	}
+	echo "---\n";
+}
+?>
+ +

Test 5: Raw Arrays (comparison)

+
+ +

Test 6: Projection with Enum Field (SandboxUsers)

+

This demonstrates DTO projection with CakePHP's BackedEnum type casting. The status field uses \Sandbox\Model\Enum\UserStatus enum.

+
getId() . "\n";
+	echo 'Username: ' . $user->getUsername() . "\n";
+	echo 'Email: ' . $user->getEmail() . "\n";
+	$status = $user->getStatus();
+	if ($status) {
+		echo 'Status Type: ' . get_class($status) . "\n";
+		echo 'Status Name: ' . $status->name . "\n";
+		echo 'Status Value: ' . $status->value . "\n";
+		echo 'Status Label: ' . $status->label() . "\n";
+	} else {
+		echo "Status: NULL\n";
+	}
+	echo "---\n";
+}
+?>
+ +

Test 7: Entity Hydration with Enum (comparison)

+
id . "\n";
+	echo 'Username: ' . $user->username . "\n";
+	echo 'Email: ' . $user->email . "\n";
+	$status = $user->status;
+	if ($status) {
+		echo 'Status Type: ' . get_class($status) . "\n";
+		echo 'Status Name: ' . $status->name . "\n";
+		echo 'Status Value: ' . $status->value . "\n";
+		echo 'Status Label: ' . $status->label() . "\n";
+	} else {
+		echo "Status: NULL\n";
+	}
+	echo "---\n";
+}
+?>