diff --git a/.roave-backward-compatibility-check.xml b/.roave-backward-compatibility-check.xml index 36ee453fe..dc74f4e4d 100644 --- a/.roave-backward-compatibility-check.xml +++ b/.roave-backward-compatibility-check.xml @@ -9,5 +9,6 @@ #(.*)Zenstruck\\Foundry\\Utils\\Rector(.*)# #(.*)initializeInternal(.*)# #\[BC\] CHANGED: Method getIdentifierValues\(\) of class Zenstruck\\Foundry\\Persistence\\PersistenceStrategy became final# + #"Zenstruck\\Foundry\\Test\\(CommonResetDatabase|CommonFactories)" could not be found in the located source(.*)# diff --git a/bin/tools/bc-check/composer.json b/bin/tools/bc-check/composer.json index 54f4ce2f2..835419a34 100644 --- a/bin/tools/bc-check/composer.json +++ b/bin/tools/bc-check/composer.json @@ -1,5 +1,5 @@ { "require": { - "roave/backward-compatibility-check": "^8.15" + "roave/backward-compatibility-check": "^8.17" } } diff --git a/bin/tools/bc-check/composer.lock b/bin/tools/bc-check/composer.lock index ed808c7e7..6339d0089 100644 --- a/bin/tools/bc-check/composer.lock +++ b/bin/tools/bc-check/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": "120355f87acd7a5304f83060fcc63639", + "content-hash": "fcc461da762c8ce11050eac34df0cfe2", "packages": [ { "name": "azjezz/psl", diff --git a/bin/tools/psalm/composer.lock b/bin/tools/psalm/composer.lock index f627661a5..b3b762f64 100644 --- a/bin/tools/psalm/composer.lock +++ b/bin/tools/psalm/composer.lock @@ -599,24 +599,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -651,9 +654,15 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", @@ -1038,22 +1047,22 @@ }, { "name": "danog/advanced-json-rpc", - "version": "v3.2.2", + "version": "v3.2.3", "source": { "type": "git", "url": "https://github.com/danog/php-advanced-json-rpc.git", - "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb" + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/aadb1c4068a88c3d0530cfe324b067920661efcb", - "reference": "aadb1c4068a88c3d0530cfe324b067920661efcb", + "url": "https://api.github.com/repos/danog/php-advanced-json-rpc/zipball/ae703ea7b4811797a10590b6078de05b3b33dd91", + "reference": "ae703ea7b4811797a10590b6078de05b3b33dd91", "shasum": "" }, "require": { "netresearch/jsonmapper": "^5", "php": ">=8.1", - "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0" + "phpdocumentor/reflection-docblock": "^4.3.4 || ^5.0.0 || ^6" }, "replace": { "felixfbecker/php-advanced-json-rpc": "^3" @@ -1084,9 +1093,9 @@ "description": "A more advanced JSONRPC implementation", "support": { "issues": "https://github.com/danog/php-advanced-json-rpc/issues", - "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.2" + "source": "https://github.com/danog/php-advanced-json-rpc/tree/v3.2.3" }, - "time": "2025-02-14T10:55:15+00:00" + "time": "2026-01-12T21:07:10+00:00" }, { "name": "daverandom/libdns", @@ -1171,29 +1180,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -1213,9 +1222,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "felixfbecker/language-server-protocol", @@ -1394,20 +1403,20 @@ }, { "name": "league/uri", - "version": "7.7.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", - "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.7", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -1421,11 +1430,11 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "ext-uri": "to use the PHP native URI class", - "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", - "league/uri-components": "Needed to easily manipulate URI objects components", - "league/uri-polyfill": "Needed to backport the PHP URI extension for older versions of PHP", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1480,7 +1489,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.7.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -1488,20 +1497,20 @@ "type": "github" } ], - "time": "2025-12-07T16:02:06+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.7.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", - "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -1514,7 +1523,7 @@ "ext-gmp": "to improve IPV4 host parsing", "ext-intl": "to handle IDN host with the best performance", "php-64bit": "to improve IPV4 host parsing", - "rowbot/url": "to handle WHATWG URL", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" }, "type": "library", @@ -1564,7 +1573,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -1572,20 +1581,20 @@ "type": "github" } ], - "time": "2025-12-07T16:03:21+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "netresearch/jsonmapper", - "version": "v5.0.0", + "version": "v5.0.1", "source": { "type": "git", "url": "https://github.com/cweiske/jsonmapper.git", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c" + "reference": "980674efdda65913492d29a8fd51c82270dd37bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", - "reference": "8c64d8d444a5d764c641ebe97e0e3bc72b25bf6c", + "url": "https://api.github.com/repos/cweiske/jsonmapper/zipball/980674efdda65913492d29a8fd51c82270dd37bb", + "reference": "980674efdda65913492d29a8fd51c82270dd37bb", "shasum": "" }, "require": { @@ -1621,9 +1630,9 @@ "support": { "email": "cweiske@cweiske.de", "issues": "https://github.com/cweiske/jsonmapper/issues", - "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.0" + "source": "https://github.com/cweiske/jsonmapper/tree/v5.0.1" }, - "time": "2024-09-08T10:20:00+00:00" + "time": "2026-02-22T16:28:03+00:00" }, { "name": "nikic/php-parser", @@ -1738,16 +1747,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.6", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", - "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -1755,8 +1764,8 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { @@ -1766,7 +1775,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -1796,44 +1806,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-12-22T21:13:58+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -1854,22 +1864,22 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -1901,9 +1911,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "psr/container", @@ -2190,29 +2200,29 @@ }, { "name": "sebastian/diff", - "version": "7.0.0", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + "reference": "9c957d730257f49c873f3761674559bd90098a7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", - "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/9c957d730257f49c873f3761674559bd90098a7d", + "reference": "9c957d730257f49c873f3761674559bd90098a7d", "shasum": "" }, "require": { - "php": ">=8.3" + "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^12.0", + "phpunit/phpunit": "^13.0", "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -2245,15 +2255,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + "source": "https://github.com/sebastianbergmann/diff/tree/8.1.0" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" } ], - "time": "2025-02-07T04:55:46+00:00" + "time": "2026-04-05T12:02:33+00:00" }, { "name": "spatie/array-to-xml", @@ -2325,16 +2347,16 @@ }, { "name": "symfony/console", - "version": "v8.0.3", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587" + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6145b304a5c1ea0bdbd0b04d297a5864f9a7d587", - "reference": "6145b304a5c1ea0bdbd0b04d297a5864f9a7d587", + "url": "https://api.github.com/repos/symfony/console/zipball/5b66d385dc58f69652e56f78a4184615e3f2b7f7", + "reference": "5b66d385dc58f69652e56f78a4184615e3f2b7f7", "shasum": "" }, "require": { @@ -2391,7 +2413,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.3" + "source": "https://github.com/symfony/console/tree/v8.0.8" }, "funding": [ { @@ -2411,7 +2433,7 @@ "type": "tidelift" } ], - "time": "2025-12-23T14:52:06+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2482,16 +2504,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.1", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d" + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", - "reference": "d937d400b980523dc9ee946bb69972b5e619058d", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", "shasum": "" }, "require": { @@ -2528,7 +2550,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.1" + "source": "https://github.com/symfony/filesystem/tree/v8.0.8" }, "funding": [ { @@ -2548,7 +2570,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", @@ -3054,16 +3076,16 @@ }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -3120,7 +3142,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -3140,20 +3162,20 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "vimeo/psalm", - "version": "6.14.3", + "version": "6.16.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a" + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/d0b040a91f280f071c1abcb1b77ce3822058725a", - "reference": "d0b040a91f280f071c1abcb1b77ce3822058725a", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", "shasum": "" }, "require": { @@ -3177,7 +3199,7 @@ "netresearch/jsonmapper": "^5.0", "nikic/php-parser": "^5.0.0", "php": "~8.1.31 || ~8.2.27 || ~8.3.16 || ~8.4.3 || ~8.5.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", "spatie/array-to-xml": "^2.17.0 || ^3.0", "symfony/console": "^6.0 || ^7.0 || ^8.0", "symfony/filesystem": "~6.3.12 || ~6.4.3 || ^7.0.3 || ^8.0", @@ -3258,20 +3280,20 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-12-23T15:36:48+00:00" + "time": "2026-03-19T10:56:09+00:00" }, { "name": "webmozart/assert", - "version": "2.1.1", + "version": "2.1.6", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "bdbabc199a7ba9965484e4725d66170e5711323b" + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/bdbabc199a7ba9965484e4725d66170e5711323b", - "reference": "bdbabc199a7ba9965484e4725d66170e5711323b", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/ff31ad6efc62e66e518fbab1cde3453d389bcdc8", + "reference": "ff31ad6efc62e66e518fbab1cde3453d389bcdc8", "shasum": "" }, "require": { @@ -3318,9 +3340,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.1" + "source": "https://github.com/webmozarts/assert/tree/2.1.6" }, - "time": "2026-01-08T11:28:40+00:00" + "time": "2026-02-27T10:28:38+00:00" } ], "packages-dev": [], diff --git a/docs/index.rst b/docs/index.rst index 864481a3f..b8b409159 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1319,9 +1319,7 @@ them during object creation to avoid this. :: - use App\Entity\Post; use App\Factory\PostFactory; - use function Zenstruck\Foundry\Persistence\persistent_factory; // disable ALL Doctrine event listeners during creation $post = PostFactory::new()->withoutDoctrineEvents()->create(); // returns Post @@ -1342,6 +1340,10 @@ If you'd like your factory to always disable Doctrine events, override its ``ini ; } +.. note:: + + ``withoutDoctrineEvents()`` cannot be used inside ``flush_after()``. + Array factories ~~~~~~~~~~~~~~~ diff --git a/src/Mongo/MongoPersistenceStrategy.php b/src/Mongo/MongoPersistenceStrategy.php index f89e9ec12..f9237a794 100644 --- a/src/Mongo/MongoPersistenceStrategy.php +++ b/src/Mongo/MongoPersistenceStrategy.php @@ -11,6 +11,7 @@ namespace Zenstruck\Foundry\Mongo; +use Doctrine\Common\EventManager; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\MappingException as MongoMappingException; use Doctrine\Persistence\Mapping\MappingException; @@ -94,4 +95,29 @@ public function getIdentifierValues(object $object): array { return $this->classMetadata($object::class)->getIdentifierValues($object); } + + public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed + { + $eventManager = $this->objectManagerFor($entityClass)->getEventManager(); + $removed = []; + + foreach ($eventManager->getAllListeners() as $eventName => $listeners) { + foreach ($listeners as $listener) { + if ([] === $disabledClasses || \in_array($listener::class, $disabledClasses, true)) { + $eventManager->removeEventListener([$eventName], $listener); + $removed[$eventName][] = $listener; + } + } + } + + try { + return $callback(); + } finally { + foreach ($removed as $eventName => $listeners) { + foreach ($listeners as $listener) { + $eventManager->addEventListener([$eventName], $listener); + } + } + } + } } diff --git a/src/ORM/AbstractORMPersistenceStrategy.php b/src/ORM/AbstractORMPersistenceStrategy.php index bd303cad9..3395fb72f 100644 --- a/src/ORM/AbstractORMPersistenceStrategy.php +++ b/src/ORM/AbstractORMPersistenceStrategy.php @@ -11,9 +11,11 @@ namespace Zenstruck\Foundry\ORM; +use Doctrine\Common\EventManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\Mapping\MappingException; +use Doctrine\Persistence\ObjectManager; use Zenstruck\Foundry\Persistence\PersistenceStrategy; /** @@ -118,4 +120,103 @@ function(mixed $value) use ($object) { $identifiers ); } + + public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed + { + $om = $this->objectManagerFor($entityClass); + + // Entity listeners first: getClassMetadata() triggers loadClassMetadata which registers + // #[AsEntityListener] listeners. Global listeners must still be active at that point. + $entityListenersBackup = $this->removeEntityListeners($om, $entityClass, $disabledClasses); + $globalListenersBackup = $this->removeGlobalListeners($om, $disabledClasses); + + try { + return $callback(); + } finally { + $this->restoreGlobalListeners($om, $globalListenersBackup); + $this->restoreEntityListeners($om, $entityClass, $entityListenersBackup); + } + } + + /** + * @param list $disabledClasses + * + * @return array> + */ + private function removeGlobalListeners(EntityManagerInterface $om, array $disabledClasses): array + { + $eventManager = $om->getEventManager(); + $removed = []; + + foreach ($eventManager->getAllListeners() as $eventName => $listeners) { + foreach ($listeners as $listener) { + if ([] === $disabledClasses || \in_array($listener::class, $disabledClasses, true)) { + $eventManager->removeEventListener([$eventName], $listener); + $removed[$eventName][] = $listener; + } + } + } + + return $removed; + } + + /** + * @param array> $removedListeners + */ + private function restoreGlobalListeners(EntityManagerInterface $om, array $removedListeners): void + { + $eventManager = $om->getEventManager(); + + foreach ($removedListeners as $eventName => $listeners) { + foreach ($listeners as $listener) { + $eventManager->addEventListener([$eventName], $listener); + } + } + } + + /** + * @param class-string $entityClass + * @param list $disabledClasses + * + * @return array> + */ + private function removeEntityListeners(EntityManagerInterface $om, string $entityClass, array $disabledClasses): array + { + $metadata = $om->getClassMetadata($entityClass); + $original = $metadata->entityListeners; + + if ([] === $original) { + return []; + } + + if ([] === $disabledClasses) { + $metadata->entityListeners = []; + + return $original; + } + + $metadata->entityListeners = \array_filter( + \array_map( + static fn(array $listeners) => \array_values(\array_filter( + $listeners, + static fn(array $listener) => !\in_array($listener['class'], $disabledClasses, true), + )), + $original, + ), + static fn(array $listeners) => [] !== $listeners, + ); + + return $original; + } + + /** + * @param class-string $entityClass + * @param array> $original + */ + private function restoreEntityListeners(EntityManagerInterface $om, string $entityClass, array $original): void + { + if ([] !== $original) { + $om->getClassMetadata($entityClass)->entityListeners = $original; + } + } } diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index a23d42db7..bd64dbffc 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -11,8 +11,6 @@ namespace Zenstruck\Foundry\Persistence; -use Doctrine\Common\EventManager; -use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; @@ -41,24 +39,6 @@ final class PersistenceManager /** @var list */ private array $afterPersistCallbacks = []; - /** - * Event listener classes to keep disabled until the next real flush, accumulated across - * multiple scheduleForInsert() calls inside a flush_after() context. - * Keyed by spl_object_id of the ObjectManager. - * - * @var array|null> - */ - private array $pendingEventClassesToDisable = []; - - /** - * Entity-listener overrides to apply during the next real flush, accumulated across - * multiple scheduleForInsert() calls inside a flush_after() context. - * Keyed by spl_object_id of the ObjectManager. - * - * @var array|null}>> - */ - private array $pendingEntityListenerOverrides = []; - /** * @param iterable $strategies */ @@ -86,34 +66,19 @@ public function enablePersisting(): void /** * @template T of object * - * @param T $object - * @param list|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled + * @param T $object * * @return T */ - public function save(object $object, ?array $disabledDoctrineEventClasses = null): object + public function save(object $object): object { if ($object instanceof Proxy) { return $object->_save(); } $om = $this->strategyFor($object::class)->objectManagerFor($object::class); - - // Disable for prePersist (fires during persist()) - // Note: disableEntityListeners must be called first, as it calls getClassMetadata() which - // triggers the loadClassMetadata event to register #[AsEntityListener] listeners. If we - // disabled global events first, that event would fire without the EntityListenerRegistry - // handler, leaving entityListeners permanently empty in the metadata cache. - $entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses); - $removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses); - $om->persist($object); - - $this->restoreDoctrineEvents($om, $removedListeners); - $this->restoreEntityListeners($om, $object::class, $entityListenerBackup); - - // Disable for postPersist / preUpdate (fire during flush()) - $this->flush($om, $disabledDoctrineEventClasses); + $this->flush($om); $shouldFlush = $this->callPostPersistCallbacks(); @@ -129,41 +94,18 @@ public function save(object $object, ?array $disabledDoctrineEventClasses = null * * @param T $object * @param list $afterPersistCallbacks - * @param list|null $disabledDoctrineEventClasses null = no events disabled, [] = all disabled, [Foo::class] = specific classes disabled * * @return T */ - public function scheduleForInsert(object $object, array $afterPersistCallbacks = [], ?array $disabledDoctrineEventClasses = null): object + public function scheduleForInsert(object $object, array $afterPersistCallbacks = []): object { if ($object instanceof Proxy) { $object = ProxyGenerator::unwrap($object); } $om = $this->strategyFor($object::class)->objectManagerFor($object::class); - - // Disable for prePersist (fires during persist()) - // Note: disableEntityListeners must be called first — same reason as in save(). - $entityListenerBackup = $this->disableEntityListeners($om, $object::class, $disabledDoctrineEventClasses); - $removedListeners = $this->disableDoctrineEvents($om, $disabledDoctrineEventClasses); - $om->persist($object); - $this->restoreDoctrineEvents($om, $removedListeners); - $this->restoreEntityListeners($om, $object::class, $entityListenerBackup); - - // Accumulate for postPersist / preUpdate (fire during the deferred flush()) - if (null !== $disabledDoctrineEventClasses) { - $omId = spl_object_id($om); - $this->pendingEventClassesToDisable[$omId] = $this->mergeEventClasses( - $this->pendingEventClassesToDisable[$omId] ?? null, - $disabledDoctrineEventClasses, - ); - $this->pendingEntityListenerOverrides[$omId][] = [ - 'entityClass' => $object::class, - 'disabledClasses' => $disabledDoctrineEventClasses, - ]; - } - $this->afterPersistCallbacks = [...$this->afterPersistCallbacks, ...$afterPersistCallbacks]; return $object; @@ -195,40 +137,10 @@ public function flushAfter(callable $callback): mixed return $result; } - /** - * @param list|null $eventClassesToDisable - */ - public function flush(ObjectManager $om, ?array $eventClassesToDisable = null): void + public function flush(ObjectManager $om): void { - if (!$this->flush) { - return; - } - - $omId = spl_object_id($om); - - // Merge caller-supplied classes with anything accumulated from scheduleForInsert() - /** @var list|null $pendingClasses */ - $pendingClasses = $this->pendingEventClassesToDisable[$omId] ?? null; - $eventClassesToDisable = $this->mergeEventClasses($eventClassesToDisable, $pendingClasses); - unset($this->pendingEventClassesToDisable[$omId]); - - $removedListeners = $this->disableDoctrineEvents($om, $eventClassesToDisable); - - // Apply entity-listener overrides accumulated from scheduleForInsert() - $entityListenerBackups = []; - foreach ($this->pendingEntityListenerOverrides[$omId] ?? [] as $override) { - $entityListenerBackups[] = [ - 'entityClass' => $override['entityClass'], - 'backup' => $this->disableEntityListeners($om, $override['entityClass'], $override['disabledClasses']), - ]; - } - unset($this->pendingEntityListenerOverrides[$omId]); - - $om->flush(); - - $this->restoreDoctrineEvents($om, $removedListeners); - foreach ($entityListenerBackups as ['entityClass' => $entityClass, 'backup' => $backup]) { - $this->restoreEntityListeners($om, $entityClass, $backup); + if ($this->flush) { + $om->flush(); } } @@ -558,134 +470,21 @@ private function callPostPersistCallbacks(): bool } /** - * Temporarily removes Doctrine event listeners from the EventManager. - * - * @param list|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed + * @template TCallback * - * @return array> map of eventName => removed listeners, for later restoration - */ - private function disableDoctrineEvents(ObjectManager $om, ?array $eventClassesToDisable): array - { - if (null === $eventClassesToDisable || !method_exists($om, 'getEventManager')) { - return []; - } - - /** @var EventManager $eventManager */ - $eventManager = $om->getEventManager(); - $removed = []; - - foreach ($eventManager->getAllListeners() as $eventName => $listeners) { - foreach ($listeners as $listener) { - if ([] === $eventClassesToDisable || \in_array($listener::class, $eventClassesToDisable, true)) { - $eventManager->removeEventListener([$eventName], $listener); - $removed[$eventName][] = $listener; - } - } - } - - return $removed; - } - - /** - * Re-adds Doctrine event listeners previously removed by disableDoctrineEvents(). + * @param class-string $entityClass + * @param list $disabledClasses [] = all, [Foo::class] = specific + * @param callable():TCallback $callback * - * @param array> $removedListeners + * @return TCallback */ - private function restoreDoctrineEvents(ObjectManager $om, array $removedListeners): void + public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed { - if ([] === $removedListeners || !method_exists($om, 'getEventManager')) { - return; - } - - /** @var EventManager $eventManager */ - $eventManager = $om->getEventManager(); - - foreach ($removedListeners as $eventName => $listeners) { - foreach ($listeners as $listener) { - $eventManager->addEventListener([$eventName], $listener); - } - } - } - - /** - * Temporarily removes Doctrine entity listeners by modifying the entity's ClassMetadata. - * - * @param class-string $entityClass - * @param list|null $eventClassesToDisable null = nothing removed, [] = all removed, [Foo::class] = specific classes removed - * - * @return array> original entityListeners for later restoration - */ - private function disableEntityListeners(ObjectManager $om, string $entityClass, ?array $eventClassesToDisable): array - { - if (null === $eventClassesToDisable || !$om instanceof EntityManagerInterface) { - return []; - } - - $metadata = $om->getClassMetadata($entityClass); - $original = $metadata->entityListeners; - - if ([] === $original) { - return []; - } - - if ([] === $eventClassesToDisable) { - $metadata->entityListeners = []; - } else { - $filtered = []; - foreach ($original as $event => $listeners) { - foreach ($listeners as $listener) { - if (!\in_array($listener['class'], $eventClassesToDisable, true)) { - $filtered[$event][] = $listener; - } - } - } - $metadata->entityListeners = $filtered; - } - - return $original; - } - - /** - * Restores entity listeners previously removed by disableEntityListeners(). - * - * @param class-string $entityClass - * @param array> $original - */ - private function restoreEntityListeners(ObjectManager $om, string $entityClass, array $original): void - { - if ([] === $original || !$om instanceof EntityManagerInterface) { - return; - } - - $om->getClassMetadata($entityClass)->entityListeners = $original; - } - - /** - * Merges two sets of event classes to disable, following these rules: - * - null + anything = anything (null means "no disabling requested") - * - [] + anything = [] ([] means "disable all", takes precedence) - * - [A] + [B] = [A, B] (union of specific classes) - * - * @param list|null $a - * @param list|null $b - * - * @return list|null - */ - private function mergeEventClasses(?array $a, ?array $b): ?array - { - if (null === $a) { - return $b; - } - - if (null === $b) { - return $a; - } - - if ([] === $a || [] === $b) { - return []; + if (!$this->flush) { + throw new \LogicException('withoutDoctrineEvents() cannot be used inside flush_after().'); } - return \array_values(\array_unique([...$a, ...$b])); + return $this->strategyFor($entityClass)->withoutDoctrineEvents($entityClass, $disabledClasses, $callback); } /** diff --git a/src/Persistence/PersistenceStrategy.php b/src/Persistence/PersistenceStrategy.php index 1cb146bec..c7d680399 100644 --- a/src/Persistence/PersistenceStrategy.php +++ b/src/Persistence/PersistenceStrategy.php @@ -99,4 +99,17 @@ abstract public function embeddablePropertiesFor(object $object, string $owner): abstract public function isEmbeddable(object $object): bool; abstract public function isScheduledForInsert(object $object): bool; + + /** + * Execute a callback with Doctrine event listeners temporarily disabled. + * + * @template T + * + * @param class-string $entityClass + * @param list $disabledClasses [] = disable all, [Foo::class] = disable specific + * @param callable():T $callback + * + * @return T + */ + abstract public function withoutDoctrineEvents(string $entityClass, array $disabledClasses, callable $callback): mixed; } diff --git a/src/Persistence/PersistentObjectFactory.php b/src/Persistence/PersistentObjectFactory.php index fecbbccd1..34168be87 100644 --- a/src/Persistence/PersistentObjectFactory.php +++ b/src/Persistence/PersistentObjectFactory.php @@ -238,6 +238,24 @@ final public static function truncate(): void * @return T */ public function create(callable|array $attributes = []): object + { + if (null !== $this->disabledDoctrineEventClasses) { + return Configuration::instance()->persistence()->withoutDoctrineEvents( + static::class(), + $this->disabledDoctrineEventClasses, + fn() => $this->doCreate($attributes), + ); + } + + return $this->doCreate($attributes); + } + + /** + * @phpstan-param callable(int):Parameters|Parameters $attributes + * + * @return T + */ + private function doCreate(callable|array $attributes): object { $configuration = Configuration::instance(); @@ -264,7 +282,7 @@ public function create(callable|array $attributes = []): object throw new \LogicException('Persistence cannot be used in unit tests.'); } - $configuration->persistence()->save($object, $this->disabledDoctrineEventClasses); + $configuration->persistence()->save($object); return $object; } @@ -562,7 +580,7 @@ static function(object $object, array $parameters, PersistentObjectFactory $fact }; } - Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks, $factoryUsed->disabledDoctrineEventClasses); + Configuration::instance()->persistence()->scheduleForInsert($object, $afterPersistCallbacks); }, self::PRIORITY_SCHEDULE_FOR_INSERT ) diff --git a/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php new file mode 100644 index 000000000..4cc4f1f33 --- /dev/null +++ b/tests/Fixture/DoctrineEvents/ChildEntityForDoctrineEventsFactory.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\ChildEntityForDoctrineEvents; + +/** + * @extends PersistentObjectFactory + */ +final class ChildEntityForDoctrineEventsFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return ChildEntityForDoctrineEvents::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + 'parent' => ParentEntityForDoctrineEventsFactory::new(), + ]; + } +} diff --git a/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php b/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php index ecff4be0a..25cf3f566 100644 --- a/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php +++ b/tests/Fixture/DoctrineEvents/DoctrineEventsSubscriber.php @@ -17,7 +17,9 @@ use Doctrine\ORM\Event\PrePersistEventArgs; use Doctrine\ORM\Event\PreUpdateEventArgs; use Doctrine\ORM\Events; +use Zenstruck\Foundry\Tests\Fixture\Entity\ChildEntityForDoctrineEvents; use Zenstruck\Foundry\Tests\Fixture\Entity\EntityForDoctrineEvents; +use Zenstruck\Foundry\Tests\Fixture\Entity\ParentEntityForDoctrineEvents; #[AsDoctrineListener(event: Events::prePersist)] #[AsDoctrineListener(event: Events::preUpdate)] @@ -27,11 +29,12 @@ public function prePersist(PrePersistEventArgs $eventArgs): void { $object = $eventArgs->getObject(); - if (!$object instanceof EntityForDoctrineEvents) { - return; + if ($object instanceof EntityForDoctrineEvents + || $object instanceof ParentEntityForDoctrineEvents + || $object instanceof ChildEntityForDoctrineEvents + ) { + $object->name .= ' (from Doctrine event)'; } - - $object->name .= ' (from Doctrine event)'; } public function preUpdate(PreUpdateEventArgs $eventArgs): void diff --git a/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php new file mode 100644 index 000000000..2f99d0618 --- /dev/null +++ b/tests/Fixture/DoctrineEvents/DocumentForDoctrineEventsFactory.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Document\DocumentForDoctrineEvents; + +/** + * @extends PersistentObjectFactory + */ +final class DocumentForDoctrineEventsFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return DocumentForDoctrineEvents::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php b/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php new file mode 100644 index 000000000..cf839b62b --- /dev/null +++ b/tests/Fixture/DoctrineEvents/MongoDoctrineEventsListener.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents; + +use Doctrine\Bundle\MongoDBBundle\Attribute\AsDocumentListener; +use Doctrine\ODM\MongoDB\Event\LifecycleEventArgs; +use Doctrine\ODM\MongoDB\Events; +use Zenstruck\Foundry\Tests\Fixture\Document\DocumentForDoctrineEvents; + +#[AsDocumentListener(event: Events::prePersist)] +final class MongoDoctrineEventsListener +{ + public function prePersist(LifecycleEventArgs $eventArgs): void + { + $object = $eventArgs->getDocument(); + + if (!$object instanceof DocumentForDoctrineEvents) { + return; + } + + $object->name .= ' (from Mongo event)'; + } +} diff --git a/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php b/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php new file mode 100644 index 000000000..78dc0190c --- /dev/null +++ b/tests/Fixture/DoctrineEvents/ParentEntityForDoctrineEventsFactory.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\DoctrineEvents; + +use Zenstruck\Foundry\Persistence\PersistentObjectFactory; +use Zenstruck\Foundry\Tests\Fixture\Entity\ParentEntityForDoctrineEvents; + +/** + * @extends PersistentObjectFactory + */ +final class ParentEntityForDoctrineEventsFactory extends PersistentObjectFactory +{ + public static function class(): string + { + return ParentEntityForDoctrineEvents::class; + } + + protected function defaults(): array + { + return [ + 'name' => self::faker()->word(), + ]; + } +} diff --git a/tests/Fixture/Document/DocumentForDoctrineEvents.php b/tests/Fixture/Document/DocumentForDoctrineEvents.php new file mode 100644 index 000000000..b7597b7c8 --- /dev/null +++ b/tests/Fixture/Document/DocumentForDoctrineEvents.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as MongoDB; + +#[MongoDB\Document] +class DocumentForDoctrineEvents +{ + #[MongoDB\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null; + + public function __construct( + #[MongoDB\Field(type: 'string')] + public string $name, + ) { + } +} diff --git a/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php b/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php new file mode 100644 index 000000000..eb0f1c1f0 --- /dev/null +++ b/tests/Fixture/Entity/ChildEntityForDoctrineEvents.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +#[ORM\Entity] +class ChildEntityForDoctrineEvents extends Base +{ + public function __construct( + #[ORM\Column] + public string $name, + #[ORM\ManyToOne(targetEntity: ParentEntityForDoctrineEvents::class, inversedBy: 'children')] + public ?ParentEntityForDoctrineEvents $parent = null, + ) { + } +} diff --git a/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php b/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php new file mode 100644 index 000000000..2acea8f55 --- /dev/null +++ b/tests/Fixture/Entity/ParentEntityForDoctrineEvents.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Fixture\Entity; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Zenstruck\Foundry\Tests\Fixture\Model\Base; + +#[ORM\Entity] +class ParentEntityForDoctrineEvents extends Base +{ + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ChildEntityForDoctrineEvents::class, mappedBy: 'parent', cascade: ['persist'])] + private Collection $children; + + public function __construct( + #[ORM\Column] + public string $name, + ) { + $this->children = new ArrayCollection(); + } + + public function addChild(ChildEntityForDoctrineEvents $child): void + { + if (!$this->children->contains($child)) { + $this->children->add($child); + $child->parent = $this; + } + } + + public function removeChild(ChildEntityForDoctrineEvents $child): void + { + $this->children->removeElement($child); + } + + /** @return Collection */ + public function getChildren(): Collection + { + return $this->children; + } +} diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 876107937..c3753bb0c 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -22,10 +22,14 @@ use Zenstruck\Foundry\Tests\Fixture\App\Controller\HelloWorld; use Zenstruck\Foundry\Tests\Fixture\App\Controller\UpdateGenericModel; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\AsEntityListenerListener; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ChildEntityForDoctrineEventsFactory; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ParentEntityForDoctrineEventsFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DoctrineEventsSubscriber; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DocumentForDoctrineEventsFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityForDoctrineEventsFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithAsEntityListenerFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithOrmEntityListenerFactory; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\MongoDoctrineEventsListener; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\OrmEntityListener; use Zenstruck\Foundry\Tests\Fixture\Events\FoundryEventListener; use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory; @@ -71,12 +75,22 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(InMemoryContactRepository::class)->setAutowired(true)->setAutoconfigured(true); $c->register(FoundryEventListener::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(DoctrineEventsSubscriber::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(EntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(OrmEntityListener::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(EntityWithOrmEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(AsEntityListenerListener::class)->setAutowired(true)->setAutoconfigured(true); - $c->register(EntityWithAsEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true); + + if (self::hasORM()) { + $c->register(DoctrineEventsSubscriber::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(EntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(OrmEntityListener::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(EntityWithOrmEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(AsEntityListenerListener::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(EntityWithAsEntityListenerFactory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(ParentEntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(ChildEntityForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true); + } + + if (self::hasMongo()) { + $c->register(MongoDoctrineEventsListener::class)->setAutowired(true)->setAutoconfigured(true); + $c->register(DocumentForDoctrineEventsFactory::class)->setAutowired(true)->setAutoconfigured(true); + } $c->register(DeleteGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); $c->register(UpdateGenericModel::class)->setAutowired(true)->setAutoconfigured(true)->addTag('controller.service_arguments'); diff --git a/tests/Integration/Mongo/WithoutDoctrineEventsTest.php b/tests/Integration/Mongo/WithoutDoctrineEventsTest.php new file mode 100644 index 000000000..6e8b3074f --- /dev/null +++ b/tests/Integration/Mongo/WithoutDoctrineEventsTest.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Tests\Integration\Mongo; + +use PHPUnit\Framework\Attributes\Test; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DocumentForDoctrineEventsFactory; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\MongoDoctrineEventsListener; +use Zenstruck\Foundry\Tests\Integration\RequiresMongo; + +use function Zenstruck\Foundry\Persistence\flush_after; + +final class WithoutDoctrineEventsTest extends KernelTestCase +{ + use Factories, RequiresMongo, ResetDatabase; + + #[Test] + public function testMongoEventsAreCalledByDefault(): void + { + $document = DocumentForDoctrineEventsFactory::createOne(['name' => 'test']); + + self::assertSame('test (from Mongo event)', $document->name); + } + + #[Test] + public function testItCanDisableAllMongoEvents(): void + { + $document = DocumentForDoctrineEventsFactory::new() + ->withoutDoctrineEvents() + ->create(['name' => 'test']); + + self::assertSame('test', $document->name); + } + + #[Test] + public function testItCanDisableSpecificMongoEventListener(): void + { + $document = DocumentForDoctrineEventsFactory::new() + ->withoutDoctrineEvents(MongoDoctrineEventsListener::class) + ->create(['name' => 'test']); + + self::assertSame('test', $document->name); + } + + #[Test] + public function testMongoEventsAreRestoredAfterCreation(): void + { + DocumentForDoctrineEventsFactory::new() + ->withoutDoctrineEvents() + ->create(['name' => 'first']); + + $document = DocumentForDoctrineEventsFactory::createOne(['name' => 'second']); + + self::assertSame('second (from Mongo event)', $document->name); + } + + #[Test] + public function testItThrowsWhenUsedInsideFlushAfter(): void + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('withoutDoctrineEvents() cannot be used inside flush_after().'); + + flush_after(static function(): void { + DocumentForDoctrineEventsFactory::new() + ->withoutDoctrineEvents() + ->create(['name' => 'test']); + }); + } +} diff --git a/tests/Integration/WithoutDoctrineEventsTest.php b/tests/Integration/ORM/WithoutDoctrineEventsTest.php similarity index 67% rename from tests/Integration/WithoutDoctrineEventsTest.php rename to tests/Integration/ORM/WithoutDoctrineEventsTest.php index c410c72a0..ff5d576fe 100644 --- a/tests/Integration/WithoutDoctrineEventsTest.php +++ b/tests/Integration/ORM/WithoutDoctrineEventsTest.php @@ -11,18 +11,21 @@ * file that was distributed with this source code. */ -namespace Zenstruck\Foundry\Tests\Integration; +namespace Zenstruck\Foundry\Tests\Integration\ORM; use PHPUnit\Framework\Attributes\Test; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Test\Factories; use Zenstruck\Foundry\Test\ResetDatabase; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\AsEntityListenerListener; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ChildEntityForDoctrineEventsFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\DoctrineEventsSubscriber; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityForDoctrineEventsFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithAsEntityListenerFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\EntityWithOrmEntityListenerFactory; use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\OrmEntityListener; +use Zenstruck\Foundry\Tests\Fixture\DoctrineEvents\ParentEntityForDoctrineEventsFactory; +use Zenstruck\Foundry\Tests\Integration\RequiresORM; use function Zenstruck\Foundry\Persistence\flush_after; @@ -74,42 +77,16 @@ public function testDoctrineEventsAreRestoredAfterCreation(): void // --- flush_after() --- #[Test] - public function testItCanDisableAllDoctrineEventsInsideFlushAfter(): void + public function testItThrowsWhenUsedInsideFlushAfter(): void { - $entity = flush_after(static function(): mixed { - return EntityForDoctrineEventsFactory::new() - ->withoutDoctrineEvents() - ->create(['name' => 'test']); - }); - - self::assertSame('test', $entity->name); - } - - #[Test] - public function testItCanDisableSpecificListenerInsideFlushAfter(): void - { - $entity = flush_after(static function(): mixed { - return EntityForDoctrineEventsFactory::new() - ->withoutDoctrineEvents(DoctrineEventsSubscriber::class) - ->create(['name' => 'test']); - }); - - self::assertSame('test', $entity->name); - } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('withoutDoctrineEvents() cannot be used inside flush_after().'); - #[Test] - public function testDoctrineEventsAreRestoredAfterFlushAfter(): void - { flush_after(static function(): void { EntityForDoctrineEventsFactory::new() ->withoutDoctrineEvents() - ->create(['name' => 'first']); + ->create(['name' => 'test']); }); - - // Events must be restored for subsequent factories - $entity = EntityForDoctrineEventsFactory::createOne(['name' => 'second']); - - self::assertSame('second (from Doctrine event)', $entity->name); } // --- #[ORM\EntityListeners] --- @@ -195,4 +172,63 @@ public function testAsEntityListenerIsRestoredAfterCreation(): void self::assertSame('second (from AsEntityListener)', $entity->name); } + + // --- Relations: ManyToOne (child → parent) --- + + #[Test] + public function testEventsAreCalledByDefaultOnChildAndParent(): void + { + $child = ChildEntityForDoctrineEventsFactory::createOne(['name' => 'child']); + + self::assertSame('child (from Doctrine event)', $child->name); + self::assertNotNull($child->parent); + self::assertStringEndsWith('(from Doctrine event)', $child->parent->name); + } + + #[Test] + public function testWithoutDoctrineEventsPropagatesFromChildToParent(): void + { + $child = ChildEntityForDoctrineEventsFactory::new() + ->withoutDoctrineEvents() + ->create(['name' => 'child']); + + self::assertSame('child', $child->name); + self::assertNotNull($child->parent); + self::assertStringNotContainsString('(from Doctrine event)', $child->parent->name); + } + + // --- Relations: OneToMany (parent → children) --- + + #[Test] + public function testEventsAreCalledByDefaultOnParentAndChildren(): void + { + $parent = ParentEntityForDoctrineEventsFactory::createOne([ + 'name' => 'parent', + 'children' => ChildEntityForDoctrineEventsFactory::new()->many(2), + ]); + + self::assertSame('parent (from Doctrine event)', $parent->name); + self::assertCount(2, $parent->getChildren()); + + foreach ($parent->getChildren() as $child) { + self::assertStringEndsWith('(from Doctrine event)', $child->name); + } + } + + #[Test] + public function testWithoutDoctrineEventsPropagatesFromParentToChildren(): void + { + $parent = ParentEntityForDoctrineEventsFactory::new() + ->withoutDoctrineEvents() + ->create([ + 'name' => 'parent', + 'children' => ChildEntityForDoctrineEventsFactory::new()->many(2), + ]); + + self::assertSame('parent', $parent->name); + + foreach ($parent->getChildren() as $child) { + self::assertStringNotContainsString('(from Doctrine event)', $child->name); + } + } }