diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3681a3d..df951e8 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -14,4 +14,4 @@ jobs: with: path: lib/ tests/ level: 9 - php_version: 8.1 \ No newline at end of file + php_version: "8.2" \ No newline at end of file diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 93c7b1a..9cb5f2f 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -14,4 +14,9 @@ jobs: - name: PHPUnit Tests uses: php-actions/phpunit@v3 with: - configuration: phpunit.xml \ No newline at end of file + configuration: phpunit.xml + # Pin to the PHPUnit major the package targets (composer dev deps + # resolve 9.6): the action's default phar is PHPUnit 11+, which + # ignores @dataProvider annotations and silently skips those tests. + version: "9.6" + php_version: "8.2" \ No newline at end of file diff --git a/.wordlist.txt b/.wordlist.txt index 7261aef..ed014e1 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -23,4 +23,20 @@ Traceback nodejs npm fediverse -readme \ No newline at end of file +readme +getUnprefixedName +lookups +phpnomad +PrimaryKeyFactory +schemas +txt +WithDatastoreHandlerMethods +datastore +PHPNomad +PostDatabaseDatastoreHandler +TableSchemaService +DateModifiedFactory +IdentifiableDatabaseDatastoreHandler +PostsTable +DatabaseServiceProvider +DateCreatedFactory diff --git a/README.md b/README.md index e96c53d..95e3eed 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ The `QueryBuilder` turns that array into parameterized SQL and runs it through w - Extend `IdentifiableDatabaseDatastoreHandler` as the base for any handler keyed by a single `id` column - Use the `WithDatastoreHandlerMethods` trait for CRUD, cache reads, cache invalidation, and event dispatch - Build reads and writes through `QueryBuilder` and `ClauseBuilder` with condition arrays instead of raw SQL -- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, and `LoggerStrategy` +- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, and `LoggerStrategy`; each handler derives its own `RowCacheContextAdapter` for cache-key vocabulary +- Rows are cached under canonical table-identity keys with per-table generation tokens; override `shouldUseTableGenerations()` on a handler to trade the generation token's race protection for a higher hit rate on write-hot tables - Use column factories like `PrimaryKeyFactory`, `DateCreatedFactory`, `DateModifiedFactory`, and `ForeignKeyFactory` for common column patterns - Model many-to-many relationships with `JunctionTable`, which handles compound primary keys and foreign key constraints diff --git a/composer.json b/composer.json index dd909d2..f09a1e4 100644 --- a/composer.json +++ b/composer.json @@ -25,12 +25,14 @@ } ], "require": { + "php": ">=8.2", "phpnomad/utils": "^1.0", "phpnomad/cache": "^1.0", "phpnomad/chrono": "^1.0", "phpnomad/datastore": "^2.0", "phpnomad/singleton": "^1.0", - "phpnomad/logger": "^1.0" + "phpnomad/logger": "^1.0", + "phpnomad/event": "^1.0" }, "require-dev": { "phpnomad/tests": "^0.1.0 || ^0.3.0" @@ -38,6 +40,9 @@ "config": { "allow-plugins": { "phpstan/extension-installer": true + }, + "platform": { + "php": "8.2.0" } } } diff --git a/composer.lock b/composer.lock index 7e0ca1c..072f488 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bd34040462bfd6ac5f170bd83fa1d6b5", + "content-hash": "574441702fccdc43a506b58ea9565a21", "packages": [ { "name": "phpnomad/cache", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phpnomad/cache.git", - "reference": "5304a5cc0444e7ee3171b9dad43ee2d563e40a09" + "reference": "660875d83e7a7ed112371945fb8de86b48e3cdcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/cache/zipball/5304a5cc0444e7ee3171b9dad43ee2d563e40a09", - "reference": "5304a5cc0444e7ee3171b9dad43ee2d563e40a09", + "url": "https://api.github.com/repos/phpnomad/cache/zipball/660875d83e7a7ed112371945fb8de86b48e3cdcf", + "reference": "660875d83e7a7ed112371945fb8de86b48e3cdcf", "shasum": "" }, "require": { @@ -47,22 +47,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/cache/issues", - "source": "https://github.com/phpnomad/cache/tree/1.0.2" + "source": "https://github.com/phpnomad/cache/tree/1.0.3" }, - "time": "2025-01-15T11:37:08+00:00" + "time": "2026-06-12T10:56:20+00:00" }, { "name": "phpnomad/chrono", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpnomad/chrono.git", - "reference": "04a11f702e242a3042b7fe802706e935346fb1c3" + "reference": "acc1aab8b7b4b58ed3dc0d728862f63de13921cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/chrono/zipball/04a11f702e242a3042b7fe802706e935346fb1c3", - "reference": "04a11f702e242a3042b7fe802706e935346fb1c3", + "url": "https://api.github.com/repos/phpnomad/chrono/zipball/acc1aab8b7b4b58ed3dc0d728862f63de13921cc", + "reference": "acc1aab8b7b4b58ed3dc0d728862f63de13921cc", "shasum": "" }, "require": { @@ -100,22 +100,22 @@ ], "support": { "issues": "https://github.com/phpnomad/chrono/issues", - "source": "https://github.com/phpnomad/chrono/tree/1.0.0" + "source": "https://github.com/phpnomad/chrono/tree/1.0.1" }, - "time": "2026-05-27T19:22:35+00:00" + "time": "2026-06-12T10:56:24+00:00" }, { "name": "phpnomad/datastore", - "version": "2.0.0", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/phpnomad/datastore.git", - "reference": "261897be5d3eb737b8d4db4ab235f87dc6da2fcb" + "reference": "b44d269215b47381132210b31af972d8728eb6bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/datastore/zipball/261897be5d3eb737b8d4db4ab235f87dc6da2fcb", - "reference": "261897be5d3eb737b8d4db4ab235f87dc6da2fcb", + "url": "https://api.github.com/repos/phpnomad/datastore/zipball/b44d269215b47381132210b31af972d8728eb6bd", + "reference": "b44d269215b47381132210b31af972d8728eb6bd", "shasum": "" }, "require-dev": { @@ -140,22 +140,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/datastore/issues", - "source": "https://github.com/phpnomad/datastore/tree/2.0.0" + "source": "https://github.com/phpnomad/datastore/tree/2.0.3" }, - "time": "2024-12-25T20:10:21+00:00" + "time": "2026-06-12T10:56:31+00:00" }, { "name": "phpnomad/enum-polyfill", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/phpnomad/enum-polyfill.git", - "reference": "0502689aa07ddeff67421ed57120f693f096a6ed" + "reference": "1c36f2af9d4ed098744fb615974a949383a28448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/enum-polyfill/zipball/0502689aa07ddeff67421ed57120f693f096a6ed", - "reference": "0502689aa07ddeff67421ed57120f693f096a6ed", + "url": "https://api.github.com/repos/phpnomad/enum-polyfill/zipball/1c36f2af9d4ed098744fb615974a949383a28448", + "reference": "1c36f2af9d4ed098744fb615974a949383a28448", "shasum": "" }, "require": { @@ -183,29 +183,69 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/enum-polyfill/issues", - "source": "https://github.com/phpnomad/enum-polyfill/tree/1.0.1" + "source": "https://github.com/phpnomad/enum-polyfill/tree/1.0.2" + }, + "time": "2026-06-12T10:56:45+00:00" + }, + { + "name": "phpnomad/event", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpnomad/event.git", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpnomad/event/zipball/b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "shasum": "" + }, + "require-dev": { + "phpnomad/tests": "^0.1.0 || ^0.3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPNomad\\Events\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Standiford", + "email": "alex@standiford.us" + } + ], + "homepage": "https://github.com/phpnomad/core", + "support": { + "issues": "https://github.com/phpnomad/event/issues", + "source": "https://github.com/phpnomad/event/tree/1.0.1" }, - "time": "2025-01-13T11:51:33+00:00" + "time": "2026-06-12T10:56:48+00:00" }, { "name": "phpnomad/logger", - "version": "1.0.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/phpnomad/logger.git", - "reference": "4d6de87bc06ad5a70668578fa80bf9cd68fc58ec" + "reference": "82d294bf7720832edbff8714ccf21c468ee09be8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/logger/zipball/4d6de87bc06ad5a70668578fa80bf9cd68fc58ec", - "reference": "4d6de87bc06ad5a70668578fa80bf9cd68fc58ec", + "url": "https://api.github.com/repos/phpnomad/logger/zipball/82d294bf7720832edbff8714ccf21c468ee09be8", + "reference": "82d294bf7720832edbff8714ccf21c468ee09be8", "shasum": "" }, "require-dev": { - "phpnomad/di": "^1.0", + "phpnomad/di": "^2.0", "phpnomad/facade": "^1.0", "phpnomad/singleton": "^1.0", - "phpnomad/tests": "^0.1.0" + "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "type": "library", "autoload": { @@ -226,26 +266,26 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/logger/issues", - "source": "https://github.com/phpnomad/logger/tree/1.0.0" + "source": "https://github.com/phpnomad/logger/tree/1.2.1" }, - "time": "2024-12-18T17:18:00+00:00" + "time": "2026-06-12T10:57:22+00:00" }, { "name": "phpnomad/singleton", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpnomad/singleton.git", - "reference": "1770888712eec6d72c2e6f962d384fe6a2fb8307" + "reference": "3d5c64fa3670a1353d94efca9231e719af44d8b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/singleton/zipball/1770888712eec6d72c2e6f962d384fe6a2fb8307", - "reference": "1770888712eec6d72c2e6f962d384fe6a2fb8307", + "url": "https://api.github.com/repos/phpnomad/singleton/zipball/3d5c64fa3670a1353d94efca9231e719af44d8b6", + "reference": "3d5c64fa3670a1353d94efca9231e719af44d8b6", "shasum": "" }, "require-dev": { - "phpnomad/tests": "^0.1.0" + "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "type": "library", "autoload": { @@ -266,22 +306,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/singleton/issues", - "source": "https://github.com/phpnomad/singleton/tree/1.0.0" + "source": "https://github.com/phpnomad/singleton/tree/1.0.1" }, - "time": "2024-12-18T17:25:13+00:00" + "time": "2026-06-12T10:57:53+00:00" }, { "name": "phpnomad/utils", - "version": "1.0.2", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/phpnomad/utils.git", - "reference": "00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55" + "reference": "ad1d0fc1f5a93f1a401205bce3f18da6020ba528" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/utils/zipball/00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55", - "reference": "00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55", + "url": "https://api.github.com/repos/phpnomad/utils/zipball/ad1d0fc1f5a93f1a401205bce3f18da6020ba528", + "reference": "ad1d0fc1f5a93f1a401205bce3f18da6020ba528", "shasum": "" }, "require-dev": { @@ -306,9 +346,9 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/utils/issues", - "source": "https://github.com/phpnomad/utils/tree/1.0.2" + "source": "https://github.com/phpnomad/utils/tree/1.0.4" }, - "time": "2026-01-12T21:59:57+00:00" + "time": "2026-06-12T10:58:10+00:00" }, { "name": "psr/clock", @@ -505,16 +545,16 @@ }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -566,7 +606,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -576,13 +616,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/xdebug-handler", @@ -720,6 +756,75 @@ ], "time": "2022-12-30T00:23:10+00:00" }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -769,16 +874,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -788,10 +893,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -818,7 +923,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -826,61 +931,63 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.65.0", + "version": "v3.95.13", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f" + "reference": "ea941114a002eb5e5876f190223deec1066c482c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/79d4f3e77b250a7d8043d76c6af8f0695e8a469f", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c", + "reference": "ea941114a002eb5e5876f190223deec1066c482c", "shasum": "" }, "require": { - "clue/ndjson-react": "^1.0", + "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", + "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.2", + "fidry/cpu-core-counter": "^1.3", "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.5", - "react/event-loop": "^1.0", - "react/promise": "^2.0 || ^3.0", - "react/socket": "^1.0", - "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-mbstring": "^1.28", - "symfony/polyfill-php80": "^1.28", - "symfony/polyfill-php81": "^1.28", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.4", - "infection/infection": "^0.29.8", - "justinrainbow/json-schema": "^5.3 || ^6.0", - "keradus/cli-executor": "^2.1", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.7", - "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", - "phpunit/phpunit": "^9.6.21 || ^10.5.38 || ^11.4.3", - "symfony/var-dumper": "^5.4.47 || ^6.4.15 || ^7.1.8", - "symfony/yaml": "^5.4.45 || ^6.4.13 || ^7.1.6" + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -895,7 +1002,7 @@ "PhpCsFixer\\": "src/" }, "exclude-from-classmap": [ - "src/Fixer/Internal/*" + "src/**/Internal/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -921,7 +1028,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.65.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13" }, "funding": [ { @@ -929,24 +1036,24 @@ "type": "github" } ], - "time": "2024-11-25T00:39:24+00:00" + "time": "2026-07-10T09:23:21+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -954,8 +1061,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -978,9 +1085,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "mockery/mockery", @@ -1067,16 +1174,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -1115,7 +1222,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -1123,24 +1230,23 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -1155,7 +1261,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -1179,9 +1285,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -1399,16 +1505,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.13", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" - }, + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -1453,7 +1554,7 @@ "type": "github" } ], - "time": "2024-12-17T17:00:20+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpstan/phpstan-mockery", @@ -1826,27 +1927,27 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -1857,11 +1958,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -1909,23 +2010,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2026-07-06T14:48:07+00:00" }, { "name": "psr/container", @@ -2154,33 +2247,33 @@ }, { "name": "react/child-process", - "version": "v0.6.5", + "version": "v0.6.7", "source": { "type": "git", "url": "https://github.com/reactphp/child-process.git", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", "shasum": "" }, "require": { "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "php": ">=5.3.0", "react/event-loop": "^1.2", - "react/stream": "^1.2" + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/socket": "^1.8", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", "autoload": { "psr-4": { - "React\\ChildProcess\\": "src" + "React\\ChildProcess\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2217,32 +2310,28 @@ ], "support": { "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.5" + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" }, "funding": [ { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2022-09-16T13:41:56+00:00" + "time": "2025-12-23T15:25:20+00:00" }, { "name": "react/dns", - "version": "v1.13.0", + "version": "v1.14.0", "source": { "type": "git", "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { @@ -2297,7 +2386,7 @@ ], "support": { "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" + "source": "https://github.com/reactphp/dns/tree/v1.14.0" }, "funding": [ { @@ -2305,20 +2394,20 @@ "type": "open_collective" } ], - "time": "2024-06-13T14:18:03+00:00" + "time": "2025-11-18T19:34:28+00:00" }, { "name": "react/event-loop", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", "shasum": "" }, "require": { @@ -2369,7 +2458,7 @@ ], "support": { "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { @@ -2377,27 +2466,27 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { "name": "react/promise", - "version": "v3.2.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { "php": ">=7.1.0" }, "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpstan/phpstan": "1.12.28 || 1.4.10", "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", @@ -2442,7 +2531,7 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.2.0" + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { @@ -2450,20 +2539,20 @@ "type": "open_collective" } ], - "time": "2024-05-24T10:39:05+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { "name": "react/socket", - "version": "v1.16.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { @@ -2522,7 +2611,7 @@ ], "support": { "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { @@ -2530,7 +2619,7 @@ "type": "open_collective" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { "name": "react/stream", @@ -2779,16 +2868,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -2841,15 +2930,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "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/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -3039,16 +3140,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -3104,28 +3205,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "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/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -3168,15 +3281,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "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/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -3349,16 +3474,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { @@ -3400,15 +3525,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "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/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { "name": "sebastian/resource-operations", @@ -3575,23 +3712,24 @@ }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -3605,16 +3743,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.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": { @@ -3648,7 +3786,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -3659,25 +3797,29 @@ "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": "2024-12-11T03:49:26+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3685,12 +3827,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3715,7 +3857,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3726,25 +3868,29 @@ "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": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -3761,13 +3907,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3795,7 +3942,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -3806,25 +3953,29 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -3833,12 +3984,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3871,7 +4022,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3882,25 +4033,29 @@ "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": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v7.2.0", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -3909,7 +4064,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3937,7 +4092,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -3948,32 +4103,36 @@ "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": "2024-10-25T15:15:23+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", - "version": "v7.2.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4001,7 +4160,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.0" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -4012,25 +4171,29 @@ "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": "2024-10-23T06:56:12+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.2.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { @@ -4068,7 +4231,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -4079,25 +4242,29 @@ "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": "2024-11-20T11:17:29+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4147,7 +4314,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4158,25 +4325,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4225,7 +4396,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4236,25 +4407,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -4306,7 +4481,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4317,28 +4492,33 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -4386,7 +4566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4397,25 +4577,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4466,7 +4650,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4477,25 +4661,29 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -4542,7 +4730,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" }, "funding": [ { @@ -4553,25 +4741,109 @@ "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": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.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": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/process", - "version": "v7.2.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -4603,7 +4875,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -4614,25 +4886,29 @@ "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": "2024-11-06T14:24:19+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -4645,12 +4921,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -4686,7 +4962,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -4697,25 +4973,29 @@ "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": "2024-09-25T14:20:29+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.2.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "696f418b0d722a4225e1c3d95489d262971ca924" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/696f418b0d722a4225e1c3d95489d262971ca924", - "reference": "696f418b0d722a4225e1c3d95489d262971ca924", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { @@ -4748,7 +5028,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.2.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" }, "funding": [ { @@ -4759,31 +5039,36 @@ "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": "2024-09-25T14:21:43+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -4791,12 +5076,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4835,7 +5119,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -4846,25 +5130,29 @@ "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": "2024-11-13T13:31:26+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4893,7 +5181,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4901,7 +5189,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -4909,7 +5197,12 @@ "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, + "platform": { + "php": ">=8.2" + }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, "plugin-api-version": "2.9.0" } diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php new file mode 100644 index 0000000..d31df86 --- /dev/null +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -0,0 +1,342 @@ + + */ + protected string $model; + + /** + * @var ModelAdapter + */ + protected ModelAdapter $modelAdapter; + protected bool $useGenerations; + + /** + * @param class-string $model + * @param ModelAdapter $modelAdapter Used to verify models against lookup values. + * @param bool $useGenerations Whether contexts carry a per-table generation token. + */ + public function __construct(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true) + { + $this->table = $table; + $this->model = $model; + $this->modelAdapter = $modelAdapter; + $this->useGenerations = $useGenerations; + } + + /** + * Whether contexts built by this adapter carry a generation token. + */ + public function usesGenerations(): bool + { + return $this->useGenerations; + } + + /** + * True when the given compound key is exactly the table's identity field + * set (order-insensitive). + * + * @param array $ids + */ + public function isTableIdentity(array $ids): bool + { + $identityFields = $this->table->getFieldsForIdentity(); + + return count($ids) === count($identityFields) + && !array_diff(array_keys($ids), $identityFields); + } + + /** + * Converts row data to its canonical identity: the table's identity + * fields, in the table's declared order, with scalar values normalized + * to strings (an int identity from a hydrated write and a string + * identity from the query strategy must be the same identity). + * + * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). + * @return array|null Null when the row is missing an identity field — a + * partial context must never become a cache key. (Pure: + * the caller logs.) + */ + public function toRowIdentity(array $row): ?array + { + $identity = $this->toRawIdentity($row); + + return $identity === null ? null : $this->stringifyScalars($identity); + } + + /** + * Converts row data to its raw table-identity projection — same fields + * as toRowIdentity(), original value types. This feeds SQL conditions, + * where cache-key stringification must not leak into driver-typed + * comparisons. + * + * @param array $row + * @return array|null + */ + public function toRawIdentity(array $row): ?array + { + $identity = []; + + foreach ($this->table->getFieldsForIdentity() as $field) { + if (!array_key_exists($field, $row)) { + return null; + } + + $identity[$field] = $row[$field]; + } + + return $identity; + } + + /** + * Opaque equality key for a row's canonical identity — two rows are the + * same record exactly when their identity keys match. + * + * @param array $row + * @return string|null Null when the row cannot produce a full identity. + */ + public function toIdentityKey(array $row): ?string + { + $identity = $this->toRowIdentity($row); + + return $identity === null ? null : serialize($identity); + } + + /** + * Converts row data to the ONE cache context the row is stored under. + * + * @param array $row + * @param string|null $generation Pre-query generation snapshot. + * @return array|null Null when the row cannot produce a full identity. + */ + public function toRowContext(array $row, ?string $generation = null): ?array + { + $identity = $this->toRowIdentity($row); + + return $identity === null ? null : $this->toIdentityContext($identity, $generation); + } + + /** + * Wraps an already-canonical identity (from toRowIdentity()) in the row + * cache context. + * + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function toIdentityContext(array $identity, ?string $generation = null): array + { + return $this->toGenerationKeyedContext($this->baseContext() + ['identities' => $this->stringifyScalars($identity)], $generation); + } + + /** + * Converts a business-key lookup to its alias context: a pointer slot + * from the lookup to the row's canonical identity. Aliases store + * identities, never row data, so a stale alias self-heals — the row read + * it points to misses and falls through to the database. + * + * @param array $ids The caller's lookup key. + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function toAliasContext(array $ids, ?string $generation = null): array + { + $normalized = $this->stringifyScalars($ids); + + ksort($normalized); + + return $this->toGenerationKeyedContext($this->baseContext() + ['alias' => $normalized], $generation); + } + + /** + * The set-level context — one undiscriminated slot per table + * (estimatedCount today). A second whole-table value would need a + * discriminator added to the context. + * + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function toTableContext(?string $generation = null): array + { + return $this->toGenerationKeyedContext($this->baseContext(), $generation); + } + + /** + * The context the generation token itself lives under. Never carries a + * generation — it IS the generation. + * + * @return array + */ + public function toGenerationContext(): array + { + return $this->baseContext() + ['generation' => true]; + } + + /** + * The discriminators every context carries. Model class AND table name: + * nothing enforces a 1:1 model-to-table mapping, and two tables sharing + * a model class must never cross-serve rows with coinciding identities. + * + * @return array + */ + protected function baseContext(): array + { + return ['type' => $this->model, 'table' => $this->table->getName()]; + } + + /** + * Folds a generation token into a cache context. The token is replaced + * on every write, which orphans all previously written contexts for the + * table at once — the transaction-free invalidation primitive the whole + * design leans on. + * + * @param array $context + * @param string|null $generation Pre-query generation snapshot; required + * when generations are on (the handler + * threads it), ignored when off. + * @return array + * + * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin + */ + protected function toGenerationKeyedContext(array $context, ?string $generation): array + { + if (!$this->useGenerations) { + return $context; + } + + if ($generation === null) { + // A generation-keyed table must never build an unkeyed context: + // no bump could ever orphan it, and it would collide with the + // generation-disabled shape. Failing loudly beats fail-open. + throw new InvalidArgumentException( + 'A generation snapshot is required to build cache contexts for a generation-keyed table.' + ); + } + + $context['gen'] = $generation; + + return $context; + } + + /** + * Marks a freshly minted token as ephemeral — the shape + * isEphemeralGeneration() recognizes. The adapter owns the format; the + * handler owns the minting. + */ + public function toEphemeralGeneration(string $token): string + { + return self::EPHEMERAL_GENERATION_PREFIX . $token; + } + + /** + * True when the snapshot was minted during a token-read outage and no + * cache entry keyed under it can ever be served. + */ + public function isEphemeralGeneration(?string $generation): bool + { + return $generation !== null && strpos($generation, self::EPHEMERAL_GENERATION_PREFIX) === 0; + } + + /** + * True when a stored value reads as a usable generation token. + * + * @param mixed $token + */ + public function isValidGeneration($token): bool + { + return is_string($token) && $token !== ''; + } + + /** + * Compares a model's converted data against the caller's lookup values — + * the guard against an alias whose business key was rotated out from + * under it by an identity-keyed update. + * + * Tri-state so the caller owns policy: true = every lookup field + * verified and matching, false = a verified field mismatched, null = + * not fully verifiable (the model adapter hides at least one lookup + * field — and a field that cannot be checked is exactly the field a + * rotation may have changed). Exceptions thrown by the model adapter + * are domain errors and propagate. + * + * @param DataModel $model + * @param array $ids The caller's lookup key. + */ + public function matchesLookup(DataModel $model, array $ids): ?bool + { + $data = $this->modelAdapter->toArray($model); + $verified = 0; + + foreach ($ids as $field => $value) { + if (!array_key_exists($field, $data)) { + continue; + } + + $verified++; + + // Normalize both sides through the same rule cache keys use, so + // this comparison can never drift from key equality. + [$actual, $expected] = array_values($this->stringifyScalars([ + 'actual' => $data[$field], + 'expected' => $value, + ])); + + if ($actual !== $expected) { + return false; + } + } + + return $verified === count($ids) ? true : null; + } + + /** + * Normalizes scalar values to strings — the single normalization rule + * every cache key shape shares. + * + * @param array $values + * @return array Same keys; scalar values stringified. + */ + protected function stringifyScalars(array $values): array + { + foreach ($values as $key => $value) { + if (is_bool($value)) { + // (string) false is '' — explicit '0' keeps booleans from + // colliding with empty strings in cache keys. + $values[$key] = $value ? '1' : '0'; + } elseif (is_scalar($value)) { + $values[$key] = (string) $value; + } + } + + return $values; + } +} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index 922f084..b6cfd4b 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -13,6 +13,12 @@ class DatabaseServiceProvider { public LoggerStrategy $loggerStrategy; public QueryStrategy $queryStrategy; + + /** + * Row and alias caching in the datastore flows builds every context + * through RowCacheContextAdapter — never cache rows or aliases against + * this directly with hand-built keys, or invalidation cannot name them. + */ public CacheableService $cacheableService; public QueryBuilder $queryBuilder; diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index dbd3ac6..209c2ca 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -2,13 +2,15 @@ namespace PHPNomad\Database\Traits; -use PHPNomad\Cache\Enums\Operation; use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Datastore\Events\RecordUpdated; use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Providers\DatabaseServiceProvider; +use PHPNomad\Cache\Enums\Operation; +use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; use PHPNomad\Datastore\Exceptions\DuplicateEntryException; @@ -18,6 +20,7 @@ use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Utils\Helpers\Arr; use PHPNomad\Utils\Helpers\Obj; +use Throwable; trait WithDatastoreHandlerMethods { @@ -29,20 +32,38 @@ trait WithDatastoreHandlerMethods * @var class-string */ protected string $model; + + /** + * @var ModelAdapter + */ protected ModelAdapter $modelAdapter; + protected ?RowCacheContextAdapter $rowCacheContextAdapter = null; /** * @inheritDoc */ public function getEstimatedCount(): int { - return $this->serviceProvider->cacheableService - ->getWithCache('estimatedCount', ['type' => $this->model], function () { - return $this->serviceProvider->queryStrategy->estimatedCount($this->table); - }); + $count = $this->readTableValueThrough(function () { + return $this->serviceProvider->queryStrategy->estimatedCount($this->table); + }); + + if (is_numeric($count)) { + return (int) $count; + } + + // Poisoned set-level slot (row and alias slots evict-and-repair; + // this one is bypassed until the next invalidation) — serve the + // database's answer rather than a garbage cast. + return $this->serviceProvider->queryStrategy->estimatedCount($this->table); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function where(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { try { @@ -55,7 +76,12 @@ public function where(array $conditions, ?int $limit = null, ?int $offset = null } } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function andWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { return $this->where([ @@ -66,7 +92,12 @@ public function andWhere(array $conditions, ?int $limit = null, ?int $offset = n ], $limit, $offset, $orderBy, $order); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function orWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { return $this->where([ @@ -101,7 +132,11 @@ public function countWhere(array $conditions): int return Arr::get($result, 'count', 0); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + */ public function countAndWhere(array $conditions): int { return $this->countWhere([ @@ -112,7 +147,11 @@ public function countAndWhere(array $conditions): int ]); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + */ public function countOrWhere(array $conditions): int { return $this->countWhere([ @@ -123,19 +162,28 @@ public function countOrWhere(array $conditions): int ]); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param mixed $value + */ public function findBy(string $field, $value): DataModel { $result = $this->andWhere([['column' => $field, 'operator' => '=', 'value' => $value]], 1); + $model = $result[0] ?? null; - if(empty($result)){ - throw new RecordNotFoundException("Could not find a record where $field equals $value"); + if (!$model instanceof DataModel) { + throw new RecordNotFoundException(sprintf('Could not find a record in table "%s" using lookup key %s.', $this->table->getName(), $this->encodeExceptionContext([$field => $value]))); } - return Arr::get($result, 0); + return $model; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array $attributes + */ public function create(array $attributes): DataModel { $fields = $this->table->getFieldsForIdentity(); @@ -146,7 +194,7 @@ public function create(array $attributes): DataModel $this->maybeThrowForDuplicateIdentity($attributes, $fields); } - $this->maybeThrowForDuplicateUniqueFields($attributes); + $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes); // Apply PHP-side defaults so the values that land in the DB also land // in the in-memory model we hand back. This eliminates the post-insert @@ -156,13 +204,23 @@ public function create(array $attributes): DataModel $ids = $this->serviceProvider->queryStrategy->insert($this->table, $attributes); - $result = $this->modelAdapter->toModel(Arr::merge($attributes, $ids)); - - // Pre-warm the cache so subsequent reads of this record don't have to - // round-trip the DB at all. Same key the framework's getModels() flow - // would have written, so existing read paths transparently pick it up. - $this->cacheItems([$result]); - + $row = Arr::merge($attributes, $ids); + $result = $this->modelAdapter->toModel($row); + + // The insert is committed: the post-write invalidation below (a + // generation bump, or a set-level delete for opted-out tables) is + // best-effort and must not fail the create or suppress RecordCreated. There is + // deliberately NO cache pre-warm: a model hydrated from write + // attributes carries request-typed scalars instead of column types + // (#29), and a pre-warm keyed under create's own post-insert bump + // can seed the newest generation with a row another writer already + // overwrote. The first read after create costs one DB round-trip + // and is always correct. + $this->invalidateAfterWrite(); + + // Single-record broadcasts intentionally PROPAGATE listener + // exceptions (the pre-PR contract): there are no sibling events to + // protect, unlike deleteWhere's buffered bulk emission. $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); return $result; @@ -193,22 +251,73 @@ protected function applyPhpDefaults(array $attributes): array } /** - * Delete all items that fit the specified condition. + * Deletes every row matching the conditions, by full table identity. + * Each deleted row broadcasts a RecordDeleted carrying its raw identity + * row; no matching rows is a silent no-op. * - * @param array $conditions * @return void * @throws DatastoreErrorException */ public function deleteWhere(array $conditions): void { - $items = $this->andWhere($conditions); + $generation = $this->snapshotGeneration(); - foreach ($items as $item) { - $identity = $item->getIdentity(); + try { + $identityRows = $this->findIds([['type' => 'AND', 'clauses' => $conditions]]); + } catch (RecordNotFoundException $e) { + return; + } + $deleted = false; + $broadcastQueue = []; + + // Alias entries pointing at deleted rows are left to self-heal: the + // row read they resolve to misses and falls through to the database. + // The finally block guarantees the post-write invalidation (bump, or + // set-level delete for opted-out tables) lands even when a + // later row's SQL delete throws — rows already deleted (and + // broadcast) must not leave set-level caches serving stale data. + try { + foreach ($identityRows as $identityRow) { + // Delete by the full table identity. The previous implementation + // deleted by the MODEL's identity, which can be a subset of the + // table's — on such tables that SQL delete could reach rows + // outside the matched set. + $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); + + $identity = $this->deriveRowIdentity($identityRow); + + if ($identity !== null) { + // deleteRowEntry() swallows-and-logs: cache trouble + // cannot abort the remaining SQL deletes. + $this->deleteRowEntry($identity, $generation); + } - $this->serviceProvider->queryStrategy->delete($this->table, $identity); - $this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($identity)); - $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($item::class, $identity)); + $broadcastQueue[] = $identityRow; + $deleted = true; + } + } finally { + if ($deleted) { + $this->invalidateAfterWrite(); + } + + // Broadcasts are buffered and emitted after the SQL work so a + // throwing listener cannot abort a bulk delete mid-set, and + // emitted per-row inside the finally so rows deleted before a + // mid-loop SQL failure still announce themselves. Each carries + // the raw identity row (DB-typed values): the deletion HAPPENED, + // listeners must hear about it even when no cache identity could + // be derived, and cache-key normalization must not leak into the + // event contract. + foreach ($broadcastQueue as $deletedIdentityRow) { + try { + $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $deletedIdentityRow)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'A RecordDeleted listener failed; remaining deletion events still fire.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + } + } } } @@ -217,7 +326,7 @@ public function deleteWhere(array $conditions): void */ protected function initiateQuery(?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC', array $select = null) { - $this->serviceProvider->clauseBuilder->useTable($this->table); + $this->serviceProvider->clauseBuilder->reset()->useTable($this->table); $select = $select === null ? $this->table->getFieldsForIdentity() : $select; @@ -297,173 +406,763 @@ protected function buildConditions(array $groups) } /** - * Gets the cache context for the given ID. + * Lazily builds the adapter that converts rows, identities, and lookup + * keys into the cache-context vocabulary. Lazy `??=` because a trait + * cannot extend its consumer's constructor, and the adapter derives + * entirely from $table/$model/$modelAdapter, which consumers set after + * construction. + */ + protected function getCacheContextAdapter(): RowCacheContextAdapter + { + return $this->rowCacheContextAdapter ??= new RowCacheContextAdapter( + $this->table, + $this->model, + $this->modelAdapter, + $this->shouldUseTableGenerations() + ); + } + + /** + * Whether this table's cache contexts are keyed under a per-table + * generation token. On by default; override to opt a write-hot table out + * in exchange for a higher hit rate. * - * Scalar identity values are normalized to strings so that an int identity - * coming from a hydrated model and a string identity coming back from the - * query strategy (MySQL returns identity columns as strings) hash to the - * same cache key. Without this, the same record produces two cache entries - * — one keyed by int, one by string — and updateCompound() only invalidates - * one of them, leaving the other to serve stale reads. + * What opting out costs: the cache-aside read-back race is only + * TTL-bounded (a slow reader can write a just-invalidated row back), and + * set-level caches plus rotated aliases fall to precise handling (the + * set-level delete in invalidateAfterWrite() and the read-time lookup + * verification in findFromCompound()) instead of being orphaned + * wholesale by the bump. + */ + protected function shouldUseTableGenerations(): bool + { + return true; + } + + /** + * Derives the canonical identity from row data, logging when the row + * cannot produce one (the adapter conversion itself is pure). * - * @param array $identities list of identities keyed by the field name for the identity. - * @return array + * @param array $row + * @return array|null */ - protected function getCacheContextForItem(array $identities): array + protected function deriveRowIdentity(array $row): ?array { - $normalized = []; - foreach ($identities as $key => $value) { - $normalized[$key] = is_scalar($value) ? (string) $value : $value; + $identity = $this->getCacheContextAdapter()->toRowIdentity($row); + + if ($identity === null) { + $this->serviceProvider->loggerStrategy->warning( + 'Cannot derive a canonical cache identity — row is missing an identity field.', + ['table' => $this->table->getName(), 'rowFields' => array_keys($row)] + ); } - return ['identities' => Arr::merge($normalized), 'type' => $this->model]; + return $identity; + } + + /** + * Mints an opaque, unique generation token — creation lives with the + * orchestration; the adapter only knows token FORMATS. + */ + protected function mintGenerationToken(): string + { + return bin2hex(random_bytes(8)); + } + + /** + * Takes the generation snapshot an operation keys its contexts under — + * once, before any database query. One snapshot per operation is both + * the race fence (a stale write-back keyed pre-write can never collide + * with post-bump reader keys) and the round-trip bound. Null when + * generations are disabled. + */ + protected function snapshotGeneration(): ?string + { + return $this->getCacheContextAdapter()->usesGenerations() ? $this->currentGeneration() : null; } /** - * Caches items in-batch + * Reads the current generation token, minting one when absent. * - * @param DataModel[] $models + * Deliberately NOT getWithCache(): a policy whose shouldCache() declines + * this context would re-mint a token on every read, silently defeating + * generation stability. The get-then-set is last-write-wins and safe: + * every minter SETs before its DB read and tokens are random, so + * concurrent re-mints can only orphan each other's fresh entries, never + * revive a stale one. + */ + protected function currentGeneration(): string + { + $context = $this->getCacheContextAdapter()->toGenerationContext(); + + try { + $token = $this->serviceProvider->cacheableService->get($context); + } catch (CachedItemNotFoundException $e) { + $token = null; + } catch (Throwable $e) { + // A read FAILURE (not a miss) mints an ephemeral token for this + // operation only, WITHOUT persisting: if reads blip while writes + // still work, persisting would let every reader clobber a + // healthy token and wholesale-invalidate the table cache. + $this->serviceProvider->loggerStrategy->warning( + 'Generation token read failed — using an ephemeral token for this operation.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return $this->getCacheContextAdapter()->toEphemeralGeneration($this->mintGenerationToken()); + } + + if (is_string($token) && $this->getCacheContextAdapter()->isValidGeneration($token)) { + return $token; + } + + $token = $this->mintGenerationToken(); + + try { + $this->serviceProvider->cacheableService->set($context, $token); + } catch (Throwable $e) { + // Cache down: every operation mints its own token, so keys + // never match and reads fall through to the database — + // caching degrades to disabled instead of breaking reads. + $this->serviceProvider->loggerStrategy->warning( + 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + } + + return $token; + } + + /** + * The one post-write invalidation step: bumps the generation when + * generations are on (orphaning every prior context for the table at + * once), precisely deletes the set-level context when they are off. + * Never throws — a cache failure after a committed database write must + * not fail the write, mask an in-flight exception, or suppress event + * broadcasts. * - * @return void + * @return string|null The fresh generation token, or null when + * generations are disabled or the cache failed. */ - protected function cacheItems(array $models): void + protected function invalidateAfterWrite(): ?string { - Arr::map($models, fn(DataModel $model) => $this->serviceProvider->cacheableService->set( - $this->getCacheContextForItem($model->getIdentity()), - $model - )); + try { + if (!$this->getCacheContextAdapter()->usesGenerations()) { + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toTableContext(null)); + + return null; + } + + $token = $this->mintGenerationToken(); + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toGenerationContext(), $token); + + return $token; + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return null; + } } /** - * Converts the given dataset into model objects. + * Read-through for a canonical row entry. Under an ephemeral generation + * snapshot the cache is skipped entirely — no key under such a token can + * ever be read back. * - * @param array $data + * @param array $identity Canonical identity (from deriveRowIdentity()). + * @param string|null $generation Pre-query generation snapshot. + * @param callable $fallback Loads the model on miss; its result is cached. + * @return mixed + */ + protected function readRowThrough(array $identity, ?string $generation, callable $fallback) + { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { + return $fallback(); + } + + return $this->guardedReadThrough($this->getCacheContextAdapter()->toIdentityContext($identity, $generation), $fallback); + } + + /** + * Read-through for the table's single set-level value (estimatedCount). * - * @return DataModel[] + * @param callable $fallback + * @return mixed */ - protected function hydrateItems(array $data): array + protected function readTableValueThrough(callable $fallback) { - return Arr::map($data, [$this->modelAdapter, 'toModel']); + $generation = $this->snapshotGeneration(); + + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { + return $fallback(); + } + + return $this->guardedReadThrough($this->getCacheContextAdapter()->toTableContext($generation), $fallback); } /** - * @param array $conditions - * @param int|null $limit - * @param int|null $offset - * @return array - * @throws DatastoreErrorException + * Read-through that survives a failing cache backend without masking + * domain exceptions. Three outcomes, distinguished by where the failure + * happened relative to the fallback: fallback loaded then the store + * threw → serve the loaded value; the fallback itself threw → propagate + * untouched (domain errors like RecordNotFoundException); the cache + * probe threw before the fallback ran → load directly. + * + * @param array $context + * @param callable $fallback + * @return mixed */ - public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array + protected function guardedReadThrough(array $context, callable $fallback) { - $this->serviceProvider->queryBuilder - ->from($this->table) - ->select(...$this->table->getFieldsForIdentity()); + $started = false; + $resolved = false; + $value = null; + $capturing = function () use ($fallback, &$started, &$resolved, &$value) { + $started = true; + $value = $fallback(); + $resolved = true; - if ($limit) { - $this->serviceProvider->queryBuilder->limit($limit); + return $value; + }; + + try { + return $this->serviceProvider->cacheableService->getWithCache(Operation::Read, $context, $capturing); + } catch (Throwable $e) { + if ($resolved) { + $this->serviceProvider->loggerStrategy->warning( + 'Cache store failed after a successful load — serving the loaded value uncached.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return $value; + } + + if ($started) { + throw $e; + } + + // An entry evicted between the service's exists() and get() is a + // normal miss under LRU pressure, not a failing backend — no log. + if ($e instanceof CachedItemNotFoundException) { + return $fallback(); + } + + $this->serviceProvider->loggerStrategy->warning( + 'Cache read failed — loading directly from the fallback.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return $fallback(); } + } - if ($offset) { - $this->serviceProvider->queryBuilder->offset($offset); + /** + * Whether a row entry exists for the given identity row. A probe failure + * reads as uncached — the caller falls through to the database. + * + * @param array $identityRow + * @param string|null $generation Pre-query generation snapshot. + */ + protected function hasRowEntry(array $identityRow, ?string $generation): bool + { + $context = $this->getCacheContextAdapter()->toRowContext($identityRow, $generation); + + if ($context === null) { + return false; + } + + try { + return $this->serviceProvider->cacheableService->exists($context); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Row cache probe failed — treating the row as uncached.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return false; + } + } + + /** + * Caches a row's model under its canonical context. No-ops under an + * ephemeral snapshot; swallows-and-logs cache failures — read paths must + * pass the snapshot they queried under (a token fetched at store time + * can postdate a concurrent write's bump and reopen the race). + * + * @param array $row + * @param DataModel $model + * @param string|null $generation Pre-query generation snapshot. + */ + protected function storeRowEntry(array $row, DataModel $model, ?string $generation): void + { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { + return; + } + + $identity = $this->deriveRowIdentity($row); + + if ($identity === null) { + return; + } + + try { + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toIdentityContext($identity, $generation), $model); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Could not cache a row — the next read will hit the database.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + } + } + + /** + * Stores an alias entry pointing a business key at a canonical identity. + * No-ops under an ephemeral snapshot; swallows-and-logs failures. + * + * @param array $ids + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + */ + protected function storeAliasEntry(array $ids, array $identity, ?string $generation): void + { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { + return; + } + + try { + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toAliasContext($ids, $generation), $identity); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Could not cache an alias — the next lookup will re-resolve from the database.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + } + } + + /** + * Deletes the row entry for a canonical identity; swallows-and-logs. + * + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + */ + protected function deleteRowEntry(array $identity, ?string $generation): void + { + try { + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toIdentityContext($identity, $generation)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); } + } + + /** + * Deletes an alias entry; swallows-and-logs. + * + * @param array $ids + * @param string|null $generation Pre-query generation snapshot. + */ + protected function deleteAliasEntry(array $ids, ?string $generation): void + { + try { + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toAliasContext($ids, $generation)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + } + } - $this->buildConditions($conditions); + /** + * Reads the identity an alias entry points at, validated against the + * table's identity shape. Null on miss, malformed value, or a + * cache-layer read failure (logged). + * + * @param array $ids + * @param string|null $generation Pre-query generation snapshot. + * @return array|null + */ + protected function resolveAliasedIdentity(array $ids, ?string $generation): ?array + { + try { + $aliased = $this->serviceProvider->cacheableService->get($this->getCacheContextAdapter()->toAliasContext($ids, $generation)); + } catch (CachedItemNotFoundException $e) { + return null; + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Alias cache read failed — treating the alias as missing.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] + ); + + return null; + } + + return (is_array($aliased) && $this->getCacheContextAdapter()->isTableIdentity($aliased)) ? $aliased : null; + } + + + /** + * @param array> $conditions + * @param int|null $limit + * @param int|null $offset + * @return array> + * @throws DatastoreErrorException + */ + public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array + { + // Same bootstrap as where(): initiateQuery()'s select defaults to + // the identity fields, which is exactly this method's projection. + $this->initiateQuery($limit, $offset)->buildConditions($conditions); return $this->serviceProvider->queryStrategy->query($this->serviceProvider->queryBuilder); } /** - * Gets the models from the specified list of IDs. + * Gets the models for the given identity rows, read-through cached. * - * @param array[] $ids - * @return array + * @param array> $ids Identity rows from findIds() (values arrive DB-typed). + * @return DataModel[] */ protected function getModels(array $ids): array { + // One generation snapshot for the whole operation, taken BEFORE any + // database read — see storeRowEntry() for why this must not be + // re-fetched at write-back time. + $generation = $this->snapshotGeneration(); + // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, - fn(array $ids) => !$this->serviceProvider->cacheableService->exists($this->getCacheContextForItem($ids)) + fn (array $identityRow) => !$this->hasRowEntry($identityRow, $generation) ); + $hydrated = []; + if (!empty($idsToQuery)) { $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); // Get the things that aren't in the cache. $data = $this->serviceProvider->queryStrategy->query( $this->serviceProvider->queryBuilder + ->reset() ->from($this->table) ->select('*') ->where($clauseBuilder->andWhere($this->table->getFieldsForIdentity(), 'IN', ...$idsToQuery)) ); - // Cache those items. - $this->cacheItems($this->hydrateItems($data)); + // Cache those items under their canonical row contexts, keyed + // from the ROW's identity values — never the model's getIdentity(), + // which is not necessarily the table's identity. The hydrated + // models are ALSO held locally: the batch result must not depend + // on the cache write landing, or a cache outage silently turns + // one list read into 1+N database queries. + foreach ($data as $row) { + $model = $this->modelAdapter->toModel($row); + $key = $this->getCacheContextAdapter()->toIdentityKey($row); + + if ($key !== null) { + $hydrated[$key] = $model; + $this->storeRowEntry($row, $model, $generation); + } + } + } + + // Return the rows in the requested order: just-hydrated models are + // served directly; only ids skipped as already-cached consult the + // cache (falling through to the database on a miss). + $models = []; + + foreach ($ids as $id) { + $key = $this->getCacheContextAdapter()->toIdentityKey($id); + + if ($key !== null && array_key_exists($key, $hydrated)) { + $models[] = $hydrated[$key]; + + continue; + } + + try { + $models[] = $this->findFromCompound($id, $generation); + } catch (RecordNotFoundException $e) { + // The row vanished between the id query and hydration (a + // concurrent delete). Skip it — letting this escape would + // collapse the WHOLE result to [] in where()'s catch, + // discarding rows that still exist. + continue; + } } - // Now, use the cache to get all the posts in the proper order. - return Arr::map($ids, fn(array $id) => $this->findFromCompound($id)); + return $models; } /** - * @param non-empty-array $ids - * @return mixed + * Finds a single record by compound key. A key that IS the table + * identity addresses the canonical row entry directly; any other key (a + * business key) resolves through an alias entry first. + * + * @param array $ids Must be non-empty; an empty key throws RecordNotFoundException. + * @param string|null $generation Pre-query generation snapshot; taken here when the caller has none. + * @return DataModel * @throws DatastoreErrorException * @throws RecordNotFoundException */ - protected function findFromCompound(array $ids) + protected function findFromCompound(array $ids, ?string $generation = null) { if (empty($ids)) { throw new RecordNotFoundException('Record cannot be found, no IDs provided.'); } - return $this->serviceProvider->cacheableService->getWithCache( - Operation::Read, - $this->getCacheContextForItem($ids), - function () use ($ids) { - $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); + $generation = $generation ?? $this->snapshotGeneration(); + + // Canonical lookup: the caller's key IS the table identity, so the + // row entry can be addressed directly (after normalizing order/types). + if ($this->getCacheContextAdapter()->isTableIdentity($ids)) { + $identity = $this->deriveRowIdentity($ids); - foreach($ids as $key => $id){ - $clauseBuilder->andWhere($key, '=', $id); + if ($identity !== null) { + $model = $this->readRowThrough($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); + + // A cached value that is not a model (a poisoned or + // old-format entry) must never be served — evict it and + // repair the slot from the database, mirroring how the + // alias path handles malformed entries. + if ($model instanceof DataModel) { + return $model; } - $items = $this->serviceProvider->queryStrategy->query( - $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->where($clauseBuilder) - ->limit(1) - ); + $this->deleteRowEntry($identity, $generation); + + [$freshRow, $freshModel] = $this->queryRowAndModel($ids); + $this->storeRowEntry($freshRow, $freshModel, $generation); + + return $freshModel; + } - $item = Arr::get($items, 0); + [, $freshModel] = $this->queryRowAndModel($ids); - if (!$item) { - throw new RecordNotFoundException(sprintf( - 'Record not found in table "%s" using identity %s.', - $this->table->getName(), - $this->encodeExceptionContext($ids) - )); + return $freshModel; + } + + // Business-key lookup: resolve through an alias entry so the row is + // still cached exactly once, under its canonical identity. + $aliasedIdentity = $this->resolveAliasedIdentity($ids, $generation); + + if ($aliasedIdentity !== null) { + try { + $model = $this->findFromCompound($aliasedIdentity, $generation); + + // Verify the resolved row still matches the caller's lookup + // values. An identity-keyed update can rotate a business key + // out from under its alias, and on generation-disabled + // tables nothing else would ever notice — the alias would + // keep serving fresh-looking rows for a key they no longer + // carry. + $matches = $this->getCacheContextAdapter()->matchesLookup($model, $ids); + + if ($matches === null) { + // Unverifiable: the adapter exposes none of the lookup + // fields. With generations on, the bump covers rotation + // and the alias can be trusted; without them this check + // is the ONLY rotation defense, so the alias is stale. + if ($this->getCacheContextAdapter()->usesGenerations()) { + return $model; + } + + $this->serviceProvider->loggerStrategy->warning( + 'Alias lookup could not be verified — the model adapter exposes none of the lookup fields; treating the alias as stale.', + ['table' => $this->table->getName(), 'lookupFields' => array_keys($ids)] + ); + } elseif ($matches === true) { + return $model; } - return $this->modelAdapter->toModel($item); + $this->deleteAliasEntry($ids, $generation); + } catch (RecordNotFoundException $e) { + // Stale alias — the row it points at moved or died. Drop it + // and re-resolve from the database. + $this->deleteAliasEntry($ids, $generation); } + } + + [$row, $model] = $this->queryRowAndModel($ids); + + $identity = $this->deriveRowIdentity($row); + + if ($identity !== null) { + $this->storeAliasEntry($ids, $identity, $generation); + $this->storeRowEntry($row, $model, $generation); + } + + return $model; + } + + /** + * Queries a single row by the given compound key and hydrates it. + * + * @param array $ids + * @return array{0: array, 1: DataModel} The raw row and its model. + * @throws RecordNotFoundException When no row matches. + * @throws DatastoreErrorException + */ + protected function queryRowAndModel(array $ids): array + { + $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); + + foreach ($ids as $key => $id) { + $clauseBuilder->andWhere($key, '=', $id); + } + + $items = $this->serviceProvider->queryStrategy->query( + $this->serviceProvider->queryBuilder + ->reset() + ->select('*') + ->from($this->table) + ->where($clauseBuilder) + ->limit(1) ); + + $item = $items[0] ?? null; + + if (!is_array($item) || $item === []) { + throw new RecordNotFoundException(sprintf( + 'Record not found in table "%s" using lookup key %s.', + $this->table->getName(), + $this->encodeExceptionContext($ids) + )); + } + + return [$item, $this->modelAdapter->toModel($item)]; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array $ids + * @param array $attributes + */ public function updateCompound($ids, array $attributes): void { - $record = $this->findFromCompound($ids); - $this->maybeThrowForDuplicateUniqueFields($attributes, $ids); + $generation = $this->snapshotGeneration(); + + // The pre-read warms the alias (business-key callers) or the row + // entry (canonical callers) — which is what makes the canonical + // identity resolvable below without an extra query. It also throws + // RecordNotFoundException before any write when the record is gone. + $record = $this->findFromCompound($ids, $generation); + + $identity = $this->resolveTableIdentity($ids, $generation); + + if ($identity === null) { + // The pre-read saw the record, but it cannot be re-resolved to a + // table identity now — it vanished mid-operation. Falling back + // to the caller's raw key would fan a non-unique business key + // out to rows the invalidation below never saw, so this fails + // the same way the pre-read would have. + throw new RecordNotFoundException(sprintf( + 'Record could not be re-resolved for update in table "%s" using lookup key %s.', + $this->table->getName(), + $this->encodeExceptionContext($ids) + )); + } - $this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes); - $this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($ids)); - $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes)); + // Self-matches in the duplicate scan are filtered by TABLE identity + // (falling back to model identity only when an adapter cannot expose + // one): model identities can be shared by distinct rows, and a true + // duplicate must not hide behind one. + $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes, $this->deriveRowIdentity($identity), $record->getIdentity()); + + // The SQL update targets the RESOLVED table identity AND the + // caller's own lookup fields: the identity pins exactly one row (no + // non-unique-business-key fan-out), and keeping the caller's fields + // in the WHERE re-conditions the write on the key they asked for — + // a concurrent rotation between resolution and UPDATE makes this a + // no-op instead of updating a row that no longer carries the key. + // (QueryStrategy::update() returns void, so a no-op write still + // broadcasts; documented as a known limitation.) + $conditions = $this->getCacheContextAdapter()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); + $this->serviceProvider->queryStrategy->update($this->table, $conditions, $attributes); + + // The DB write is committed: the invalidation below is best-effort + // (the entry mutations swallow-and-log cache failures) and runs under + // the pre-write generation — the one readers wrote their entries + // with. The bump closes the cache-aside race; precise deletes carry + // tables that opt out of generations. + $canonicalIdentity = $this->deriveRowIdentity($identity); + + if ($canonicalIdentity !== null) { + $this->deleteRowEntry($canonicalIdentity, $generation); + } + + if (!$this->getCacheContextAdapter()->isTableIdentity($ids)) { + // Drop the alias too: the update may have moved the row's + // business key or identity out from under it. + $this->deleteAliasEntry($ids, $generation); + } + + $this->invalidateAfterWrite(); + + // The event intentionally carries the caller's key — the lookup + // contract they wrote against — not the cache-normalized identity + // the SQL targeted. Like create(), a single-record broadcast + // propagates listener exceptions; only bulk emission isolates them. + $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($this->model, $ids, $attributes)); } /** - * @param array $attributes - * @param array $fields + * Resolves the caller's compound key to the row's canonical table + * identity: directly when the key IS the table identity, via the alias + * entry (warmed by the pre-read) otherwise. + * + * An alias miss (evicted between the pre-read and here, or a cache + * outage) resolves from the database: the caller targets its SQL write + * at this identity, so resolution must not silently degrade. + * + * @param array $ids + * @param string|null $generation Generation snapshot for the alias lookup. + * @return array|null + */ + protected function resolveTableIdentity(array $ids, ?string $generation = null): ?array + { + // Raw caller/row values are preferred: this identity feeds the SQL + // WHERE, and cache-key stringification is cache vocabulary that + // should not leak into driver-typed comparisons. (The cache delete + // canonicalizes separately.) + if ($this->getCacheContextAdapter()->isTableIdentity($ids)) { + return $this->getCacheContextAdapter()->toRawIdentity($ids); + } + + $aliased = $this->resolveAliasedIdentity($ids, $generation); + + if ($aliased !== null) { + // Alias entries store the canonical (stringified) identity — the + // only form available without a query. The merged WHERE keeps + // the caller's raw business key alongside it, and SQL drivers + // coerce numeric strings. + return $aliased; + } + + // The alias should have been warmed by the pre-read; reaching here + // means it was evicted or the cache is down. Resolve from the + // database regardless of generation mode — the SQL update targets + // this identity, so skipping resolution would reopen the + // multi-row fan-out for non-unique business keys. + try { + [$row] = $this->queryRowAndModel($ids); + + return $this->getCacheContextAdapter()->toRawIdentity($row); + } catch (RecordNotFoundException $e) { + return null; + } + } + + /** + * @param array $attributes + * @param array $fields * @return void * @throws DatastoreErrorException * @throws DuplicateEntryException @@ -496,9 +1195,9 @@ protected function maybeThrowForDuplicateIdentity(array $attributes, array $fiel } /** - * @param array $attributes - * @param array $fields - * @return array + * @param array $attributes + * @param array $fields + * @return array */ protected function removeIdentifiableFields(array $attributes, array $fields): array { @@ -508,7 +1207,7 @@ protected function removeIdentifiableFields(array $attributes, array $fields): a /** * Looks up records to check if a record with the specified unique columns already exists. * - * @param array $data + * @param array $data * @return DataModel[] List of existing items that match the unique constraints. * @throws DatastoreErrorException * @throws RecordNotFoundException @@ -553,23 +1252,43 @@ protected function getDuplicates(array $data): array /** - * @param array $data - * @param array|null $updateIdentity + * Guards unique-column groups. (Renamed from + * maybeThrowForDuplicateUniqueFields when its second parameter changed + * meaning, so stale call sites fail loudly instead of silently filtering + * self-matches by the wrong identity.) When updating, the record being + * updated is filtered out of the duplicate scan by TABLE identity — model + * identities can be a subset of the table's and therefore shared across + * distinct rows, so a model-identity self-match could hide a true + * duplicate. The model-identity comparison is only the fallback for + * adapters that cannot expose the table identity (that fallback CAN + * shadow a duplicate sharing the model identity; such adapters trade + * that for not tripping spurious self-duplicates). + * + * @param array $data + * @param array|null $updateTableIdentity Canonical identity of the record being updated. + * @param array|null $updateModelIdentity Model identity of the record being updated. * @return void * @throws DuplicateEntryException * @throws DatastoreErrorException */ - protected function maybeThrowForDuplicateUniqueFields(array $data, ?array $updateIdentity = null): void + protected function maybeThrowForDuplicateUniqueFieldsExcluding(array $data, ?array $updateTableIdentity = null, ?array $updateModelIdentity = null): void { try { $duplicates = $this->getDuplicates($data); - // If an identity is provided, filter out items that have the provided identity. - if (!is_null($updateIdentity)) { - $duplicates = Arr::filter( - $duplicates, - fn(CanIdentify $existingItem) => !Arr::containsSameData($existingItem->getIdentity(), $updateIdentity) - ); + if ($updateTableIdentity !== null || $updateModelIdentity !== null) { + $duplicates = Arr::filter($duplicates, function (CanIdentify $existingItem) use ($updateTableIdentity, $updateModelIdentity) { + $existingTableIdentity = $existingItem instanceof DataModel + ? $this->deriveRowIdentity($this->modelAdapter->toArray($existingItem)) + : null; + + if ($existingTableIdentity !== null && $updateTableIdentity !== null) { + return !Arr::containsSameData($existingTableIdentity, $updateTableIdentity); + } + + return $updateModelIdentity === null + || !Arr::containsSameData($existingItem->getIdentity(), $updateModelIdentity); + }); } } catch (RecordNotFoundException $e) { // Bail if no records were found. diff --git a/tests/Doubles/ArrayCacheStrategy.php b/tests/Doubles/ArrayCacheStrategy.php new file mode 100644 index 0000000..97f2eaf --- /dev/null +++ b/tests/Doubles/ArrayCacheStrategy.php @@ -0,0 +1,45 @@ + */ + public array $store = []; + + public function get(string $key) + { + if (!array_key_exists($key, $this->store)) { + throw new CachedItemNotFoundException('No cached item found for key ' . $key); + } + + return $this->store[$key]; + } + + public function set(string $key, $value, ?int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function exists(string $key): bool + { + return array_key_exists($key, $this->store); + } + + public function clear(): void + { + $this->store = []; + } +} diff --git a/tests/Doubles/FieldHidingModelAdapter.php b/tests/Doubles/FieldHidingModelAdapter.php new file mode 100644 index 0000000..6646470 --- /dev/null +++ b/tests/Doubles/FieldHidingModelAdapter.php @@ -0,0 +1,40 @@ + + */ +class FieldHidingModelAdapter implements ModelAdapter +{ + /** + * @param array $hiddenFields + */ + public function __construct(private array $hiddenFields = []) + { + } + + /** + * @param array $array + */ + public function toModel(array $array): DataModel + { + return new IdentityRowModel($array); + } + + /** + * @return array + */ + public function toArray(DataModel $model): array + { + $row = $model instanceof IdentityRowModel ? $model->toRow() : []; + + return array_diff_key($row, array_flip($this->hiddenFields)); + } +} diff --git a/tests/Doubles/FlakyCacheStrategy.php b/tests/Doubles/FlakyCacheStrategy.php new file mode 100644 index 0000000..9061ae3 --- /dev/null +++ b/tests/Doubles/FlakyCacheStrategy.php @@ -0,0 +1,53 @@ +failReads) { + throw new RuntimeException('Simulated cache read failure.'); + } + + return parent::get($key); + } + + public function exists(string $key): bool + { + if ($this->failReads) { + throw new RuntimeException('Simulated cache read failure.'); + } + + return parent::exists($key); + } + + public function set(string $key, $value, ?int $ttl): void + { + if ($this->failWrites) { + throw new RuntimeException('Simulated cache write failure.'); + } + + parent::set($key, $value, $ttl); + } + + public function delete(string $key): void + { + if ($this->failWrites) { + throw new RuntimeException('Simulated cache write failure.'); + } + + parent::delete($key); + } +} diff --git a/tests/Doubles/IdentityRowModel.php b/tests/Doubles/IdentityRowModel.php new file mode 100644 index 0000000..249c003 --- /dev/null +++ b/tests/Doubles/IdentityRowModel.php @@ -0,0 +1,44 @@ + $row + */ + public function __construct(private array $row = []) + { + } + + /** + * @return mixed + */ + public function get(string $field) + { + return $this->row[$field] ?? null; + } + + /** + * @return array + */ + public function toRow(): array + { + return $this->row; + } + + /** + * @return array + */ + public function getIdentity(): array + { + return ['id' => $this->row['id'] ?? null]; + } +} diff --git a/tests/Doubles/IdentityRowModelAdapter.php b/tests/Doubles/IdentityRowModelAdapter.php new file mode 100644 index 0000000..1ffc947 --- /dev/null +++ b/tests/Doubles/IdentityRowModelAdapter.php @@ -0,0 +1,31 @@ + + */ +class IdentityRowModelAdapter implements ModelAdapter +{ + /** + * @param array $array + */ + public function toModel(array $array): DataModel + { + return new IdentityRowModel($array); + } + + /** + * @return array + */ + public function toArray(DataModel $model): array + { + return $model instanceof IdentityRowModel ? $model->toRow() : []; + } +} diff --git a/tests/Doubles/NoopClauseBuilder.php b/tests/Doubles/NoopClauseBuilder.php new file mode 100644 index 0000000..2bf3e06 --- /dev/null +++ b/tests/Doubles/NoopClauseBuilder.php @@ -0,0 +1,57 @@ + + */ +class OpaqueModelAdapter implements ModelAdapter +{ + /** + * @param array $array + */ + public function toModel(array $array): DataModel + { + return new IdentityRowModel($array); + } + + /** + * @return array + */ + public function toArray(DataModel $model): array + { + return []; + } +} diff --git a/tests/Doubles/RecordingEventStrategy.php b/tests/Doubles/RecordingEventStrategy.php new file mode 100644 index 0000000..89c5ebe --- /dev/null +++ b/tests/Doubles/RecordingEventStrategy.php @@ -0,0 +1,41 @@ +broadcasts[] = $event; + } + + /** + * All recorded broadcasts of one event class, in broadcast order. + * + * @template T of Event + * @param class-string $eventClass + * @return array + */ + public function ofType(string $eventClass): array + { + return array_values(array_filter($this->broadcasts, fn (Event $event) => $event instanceof $eventClass)); + } + + public function attach(string $event, callable $action, ?int $priority = null): void + { + } + + public function detach(string $event, callable $action, ?int $priority = null): void + { + } +} diff --git a/tests/Doubles/ScriptedQueryStrategy.php b/tests/Doubles/ScriptedQueryStrategy.php new file mode 100644 index 0000000..c0a28b4 --- /dev/null +++ b/tests/Doubles/ScriptedQueryStrategy.php @@ -0,0 +1,87 @@ +>> */ + private array $queryResults = []; + public int $queryCount = 0; + public int $estimatedCountValue = 0; + /** @var array>> */ + public array $updates = []; + /** @var array> */ + public array $deletes = []; + /** @var int|null 1-indexed delete call that should throw. */ + public ?int $throwOnDeleteCall = null; + private int $deleteCalls = 0; + + /** + * @param array> $result + */ + public function queueQueryResult(array $result): void + { + $this->queryResults[] = $result; + } + + /** + * @return array> + */ + public function query(QueryBuilder $builder): array + { + $this->queryCount++; + + if (empty($this->queryResults)) { + throw new LogicException('ScriptedQueryStrategy ran out of queued query results — unexpected query #' . $this->queryCount); + } + + return array_shift($this->queryResults); + } + + /** + * @param array $data + * @return array + */ + public function insert(Table $table, array $data): array + { + return ['id' => 1]; + } + + /** + * @param array $ids + */ + public function delete(Table $table, array $ids): void + { + $this->deleteCalls++; + + if ($this->throwOnDeleteCall !== null && $this->deleteCalls === $this->throwOnDeleteCall) { + throw new LogicException('Simulated SQL delete failure on call ' . $this->deleteCalls . '.'); + } + + $this->deletes[] = $ids; + } + + /** + * @param array $ids + * @param array $data + */ + public function update(Table $table, array $ids, array $data): void + { + $this->updates[] = [$ids, $data]; + } + + public function estimatedCount(Table $table): int + { + return $this->estimatedCountValue; + } +} diff --git a/tests/Doubles/SerializingCachePolicy.php b/tests/Doubles/SerializingCachePolicy.php new file mode 100644 index 0000000..7ceb0a9 --- /dev/null +++ b/tests/Doubles/SerializingCachePolicy.php @@ -0,0 +1,45 @@ + $context + */ + public function shouldCache(string $operation, array $context = []): bool + { + return true; + } + + /** + * @param array $context + */ + public function getCacheKey(array $context): string + { + return md5(serialize($context)); + } + + /** + * @param array $context + */ + public function getTtl(array $context = []): ?int + { + return 60; + } + + /** + * @param array $context + */ + public function shouldInvalidate(string $operation, array $context = []): bool + { + return true; + } +} diff --git a/tests/Doubles/ThrowingOnceEventStrategy.php b/tests/Doubles/ThrowingOnceEventStrategy.php new file mode 100644 index 0000000..a65a431 --- /dev/null +++ b/tests/Doubles/ThrowingOnceEventStrategy.php @@ -0,0 +1,26 @@ +thrown) { + $this->thrown = true; + + throw new RuntimeException('Simulated listener failure.'); + } + } +} diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php new file mode 100644 index 0000000..5330f05 --- /dev/null +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -0,0 +1,253 @@ + $identityFields + * @param ModelAdapter|null $modelAdapter + */ + private function makeAdapter(array $identityFields, bool $useGenerations = false, ?ModelAdapter $modelAdapter = null): RowCacheContextAdapter + { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn($identityFields); + + return new RowCacheContextAdapter( + $table, + IdentityRowModel::class, + $modelAdapter ?? new IdentityRowModelAdapter(), + $useGenerations + ); + } + + public function testRowContextIsTypeStableAcrossIntAndStringIdentities(): void + { + // Regression: MySQL returns identity columns as strings, but hydrated + // rows can hold them as ints. Without normalization the same record + // produces two distinct cache contexts and invalidation misses one. + $adapter = $this->makeAdapter(['id']); + + $this->assertNotNull($adapter->toRowContext(['id' => 123], null)); + $this->assertSame( + $adapter->toRowContext(['id' => 123], null), + $adapter->toRowContext(['id' => '123'], null) + ); + } + + public function testRowIdentityReordersToTableOrderAndStringifies(): void + { + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame( + ['orgId' => '1', 'id' => '42'], + $adapter->toRowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) + ); + } + + public function testRowIdentityIsNullWhenAFieldIsMissing(): void + { + // Pure conversion: null, no side effects — the datastore handler + // owns the logging. + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertNull($adapter->toRowIdentity(['id' => 42])); + } + + public function testRawIdentityKeepsOriginalValueTypes(): void + { + // Raw projections feed SQL conditions, where cache stringification + // must not leak into driver-typed comparisons. + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame( + ['orgId' => 1, 'id' => 42], + $adapter->toRawIdentity(['id' => 42, 'orgId' => 1, 'name' => 'extra']) + ); + } + + /** + * @return array, 1: bool}> + */ + public static function identityShapes(): array + { + return [ + 'exact identity' => [['orgId' => '1', 'id' => '42'], true], + 'reordered identity' => [['id' => '42', 'orgId' => '1'], true], + 'subset' => [['id' => '42'], false], + 'superset' => [['orgId' => '1', 'id' => '42', 'name' => 'x'], false], + 'different fields' => [['keyHash' => 'abc', 'status' => 'active'], false], + 'empty' => [[], false], + ]; + } + + /** + * @dataProvider identityShapes + * + * @param array $ids + */ + public function testIsTableIdentityMatchesFieldSetsNotOrder(array $ids, bool $expected): void + { + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame($expected, $adapter->isTableIdentity($ids)); + } + + /** + * @return array, 1: array, 2: bool|null}> + */ + public static function lookupComparisons(): array + { + return [ + 'same-type match' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'abc'], true], + 'same-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'xyz'], false], + 'int model vs string lookup' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '7'], true], + 'string model vs int lookup' => [['id' => '7', 'keyHash' => 'abc'], ['id' => 7], true], + 'cross-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '8'], false], + 'bool model vs stored zero' => [['id' => 7, 'active' => false], ['active' => '0'], true], + ]; + } + + /** + * @dataProvider lookupComparisons + * + * @param array $modelRow + * @param array $lookup + */ + public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRow, array $lookup, ?bool $expected): void + { + $adapter = $this->makeAdapter(['id']); + + $this->assertSame($expected, $adapter->matchesLookup(new IdentityRowModel($modelRow), $lookup)); + } + + public function testMatchesLookupIsNullWhenAnyFieldIsHidden(): void + { + // A field that cannot be checked is exactly the field a rotation may + // have changed: matching exposed fields must not upgrade a partially + // verifiable lookup to a verified match. + $adapter = $this->makeAdapter(['id'], false, new FieldHidingModelAdapter(['tenantId'])); + + $this->assertNull($adapter->matchesLookup( + new IdentityRowModel(['id' => 7, 'keyHash' => 'abc', 'tenantId' => 9]), + ['keyHash' => 'abc', 'tenantId' => 999999] + )); + } + + public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void + { + // Tri-state: the adapter reports "unverifiable"; the datastore + // handler owns the policy for what that means per generation mode. + $adapter = $this->makeAdapter(['id'], false, new OpaqueModelAdapter()); + + $this->assertNull($adapter->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc'])); + } + + public function testGenerationKeyedContextsCarryTheSnapshot(): void + { + $this->assertSame( + ['type' => IdentityRowModel::class, 'table' => 'test_records', 'gen' => 'token-1'], + $this->makeAdapter(['id'], true)->toTableContext('token-1') + ); + } + + public function testContextsNeverCollideAcrossTablesSharingAModel(): void + { + // Nothing enforces a 1:1 model-to-table mapping: two tables reusing + // one model class must never cross-serve rows whose identities + // coincide, so every context carries the table name too. + $other = $this->createMock(Table::class); + $other->method('getName')->willReturn('other_records'); + $other->method('getFieldsForIdentity')->willReturn(['id']); + + $otherAdapter = new RowCacheContextAdapter($other, IdentityRowModel::class, new IdentityRowModelAdapter(), true); + + $this->assertNotSame( + $this->makeAdapter(['id'], true)->toRowContext(['id' => '7'], 'token-1'), + $otherAdapter->toRowContext(['id' => '7'], 'token-1') + ); + } + + public function testGenerationKeyedTablesRefuseUnkeyedContexts(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('generation snapshot is required'); + + $this->makeAdapter(['id'], true)->toTableContext(null); + } + + public function testGenerationDisabledTablesNeverCarryTokens(): void + { + $this->assertSame( + ['type' => IdentityRowModel::class, 'table' => 'test_records'], + $this->makeAdapter(['id'], false)->toTableContext('token-1') + ); + } + + /** + * The adapter owns token FORMATS; the datastore handler owns creation. + * + * @return array + */ + public static function generationTokenShapes(): array + { + return [ + 'ephemeral-prefixed token' => ['ephemeral-abc123', true], + 'plain token' => ['abc123', false], + 'no token' => [null, false], + ]; + } + + /** + * @dataProvider generationTokenShapes + */ + public function testEphemeralTokensAreRecognizedByPrefix(?string $token, bool $expected): void + { + $this->assertSame($expected, $this->makeAdapter(['id'], true)->isEphemeralGeneration($token)); + } + + public function testEphemeralMarkingRoundTrips(): void + { + $adapter = $this->makeAdapter(['id'], true); + + $this->assertTrue($adapter->isEphemeralGeneration($adapter->toEphemeralGeneration('abc123'))); + } + + /** + * @return array + */ + public static function tokenValidityShapes(): array + { + return [ + 'non-empty string' => ['abc123', true], + 'empty string' => ['', false], + 'null' => [null, false], + ]; + } + + /** + * @dataProvider tokenValidityShapes + * + * @param mixed $token + */ + public function testTokenValidityRequiresANonEmptyString($token, bool $expected): void + { + $this->assertSame($expected, $this->makeAdapter(['id'], true)->isValidGeneration($token)); + } +} diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php new file mode 100644 index 0000000..c67fc6f --- /dev/null +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -0,0 +1,1146 @@ +cacheStrategy = new ArrayCacheStrategy(); + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $this->cacheStrategy, + new SerializingCachePolicy() + ); + $this->queryStrategy = new ScriptedQueryStrategy(); + } + + /** + * @param array $identityFields + * @param ModelAdapter|null $adapter + * @param array> $uniqueColumns + */ + private function makeHandler( + array $identityFields, + string $tableName = 'test_records', + bool $useGenerations = false, + ?LoggerStrategy $logger = null, + ?EventStrategy $events = null, + ?ModelAdapter $adapter = null, + array $uniqueColumns = [] + ): CanonicalHandler { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn($tableName); + $table->method('getFieldsForIdentity')->willReturn($identityFields); + $table->method('getColumns')->willReturn([]); + + $tableSchemaService = $this->createMock(TableSchemaService::class); + $tableSchemaService->method('getUniqueColumns')->willReturn($uniqueColumns); + + $logger = $logger ?? $this->createMock(LoggerStrategy::class); + + $serviceProvider = new DatabaseServiceProvider( + $logger, + $this->queryStrategy, + new NoopQueryBuilder(), + new NoopClauseBuilder(), + $this->cacheableService, + $events ?? new NullEventStrategy() + ); + + $adapter = $adapter ?? new IdentityRowModelAdapter(); + + $this->contextAdapter = new RowCacheContextAdapter( + $table, + IdentityRowModel::class, + $adapter, + $useGenerations + ); + + return new CanonicalHandler( + $serviceProvider, + $table, + $tableSchemaService, + IdentityRowModel::class, + $adapter, + $useGenerations + ); + } + + /** + * Reads the live generation token straight from the cache, the way the + * datastore handler would snapshot it. + */ + private function currentToken(): ?string + { + try { + $token = $this->cacheableService->get($this->contextAdapter->toGenerationContext()); + } catch (\Throwable $e) { + return null; + } + + return is_string($token) ? $token : null; + } + + /** + * The cache slot a row currently lives under (live token included). + * + * @param array $row + * @return array + */ + private function probeRowContext(array $row): array + { + $context = $this->contextAdapter->toRowContext($row, $this->currentToken()); + \assert($context !== null); + + return $context; + } + + /** + * The cache slot an alias currently lives under (live token included). + * + * @param array $ids + * @return array + */ + private function probeAliasContext(array $ids): array + { + return $this->contextAdapter->toAliasContext($ids, $this->currentToken()); + } + + /** + * Both generation modes must exhibit identical canonical-keying behavior; + * generations only change WHICH key a context hashes to, never the + * invariants. (Production default is generations ON.) + * + * @return array + */ + public static function generationModes(): array + { + return [ + 'generations on (production default)' => [true], + 'generations off (opt-out)' => [false], + ]; + } + + /** + * One cached row plus the generation-token entry the ON mode keeps. + * + * @return array + */ + public static function generationModesWithRowAndTokenEntryCounts(): array + { + return [ + 'generations on (production default)' => [true, 2], + 'generations off (opt-out)' => [false, 1], + ]; + } + + /** + * @dataProvider generationModesWithRowAndTokenEntryCounts + */ + public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useGenerations, int $expectedEntries): void + { + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations); + + // findIds → identity rows; then SELECT * for the uncached row. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => 1, 'id' => 42, 'name' => 'first']]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'first']]]]); + + $this->assertCount(1, $models); + $this->assertInstanceOf(IdentityRowModel::class, $models[0]); + $this->assertSame('first', $models[0]->get('name')); + + // Cached under the TABLE identity (orgId + id, stringified, table order)… + $this->assertTrue( + $this->cacheableService->exists($this->probeRowContext(['orgId' => 1, 'id' => 42])), + 'Row is not cached under its canonical table-identity context.' + ); + // …and NOT under the model's own (subset) identity. The probed shape + // mirrors the pre-PR key vocabulary as regression documentation; the + // exact entry count below is the general guard — one row entry plus + // the generation token when generations are on. + $this->assertFalse( + $this->cacheableService->exists(['type' => IdentityRowModel::class, 'table' => 'test_records', 'identities' => ['id' => '42']]), + 'Row leaked a cache entry keyed by the MODEL identity.' + ); + $this->assertCount($expectedEntries, $this->cacheStrategy->store, 'Unexpected cache entries beyond the row (and generation token).'); + + // A second identical read: findIds queries again (list SQL is not + // cached), but the row itself is served from cache — no SELECT *. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'first']]]]); + + $this->assertSame(3, $this->queryStrategy->queryCount, 'Cached row was re-queried.'); + } + + /** + * @dataProvider generationModes + */ + public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_api_keys', $useGenerations); + + // Prime the row cache the way production reads do (list hydration). + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'keyHash', 'operator' => '=', 'value' => 'abc']]]]); + + // Update by BUSINESS KEY (not the table identity). The pre-read + // resolves the row (one query), then the write must invalidate the + // id-keyed entry the read above created. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); + + // The SQL update targets the RESOLVED identity pinned together with + // the caller's business key: the identity prevents non-unique-key + // fan-out, and keeping the key in the WHERE turns a concurrent + // rotation into a no-op instead of updating a row that no longer + // carries the key. + $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); + $this->assertFalse( + $this->cacheableService->exists($this->probeRowContext(['id' => '7'])), + 'Business-key update left the canonical row entry to serve stale reads.' + ); + + // The next read round-trips the DB and sees the new value. + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'revoked']]); + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'keyHash', 'operator' => '=', 'value' => 'abc']]]]); + + $revoked = $models[0]; + \assert($revoked instanceof IdentityRowModel); + $this->assertSame('revoked', $revoked->get('status')); + } + + /** + * @dataProvider generationModes + */ + public function testBusinessKeyLookupIsServedByAliasWithoutRequery(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + + $first = $handler->findByCompound(['keyHash' => 'abc']); + $second = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $first->get('id')); + $this->assertSame('7', $second->get('id')); + $this->assertSame(1, $this->queryStrategy->queryCount, 'Alias-resolved lookup hit the database again.'); + } + + /** + * Seeds keyHash abc → id 7, kills row 7 out from under the alias, and + * scripts the re-resolution to id 9 — the shared healing sequence. + */ + private function healRotatedAlias(CanonicalHandler $handler): IdentityRowModel + { + // Seed: keyHash abc → id 7. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // The row dies out from under the alias (deleted / re-keyed outside + // this process). Drop the row entry to simulate; the alias remains. + $this->cacheableService->delete($this->probeRowContext(['id' => '7'])); + + // Alias → id 7 → miss → DB says id 7 is gone… + $this->queryStrategy->queueQueryResult([]); + // …so the alias is dropped and the business key re-resolves to id 9. + $this->queryStrategy->queueQueryResult([['id' => '9', 'keyHash' => 'abc', 'status' => 'active']]); + + return $handler->findByCompound(['keyHash' => 'abc']); + } + + /** + * @dataProvider generationModes + */ + public function testStaleAliasSelfHeals(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $model = $this->healRotatedAlias($handler); + + $this->assertSame('9', $model->get('id'), 'Stale alias did not self-heal.'); + } + + /** + * @dataProvider generationModes + */ + public function testHealedAliasServesNextLookupWithoutQuery(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $this->healRotatedAlias($handler); + + // The healed alias must serve the follow-up lookup query-free. + $before = $this->queryStrategy->queryCount; + $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame($before, $this->queryStrategy->queryCount); + } + + public function testRotatedBusinessKeyAliasDoesNotServeTheOldKey(): void + { + // Generations OFF: without the bump, ONLY read-time verification + // stands between a rotated business key and its orphaned alias + // serving fresh-looking rows for a key they no longer carry. + $handler = $this->makeHandler(['id'], 'test_api_keys', false); + + // Seed alias: keyHash abc → id 7. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // Rotate the key via an identity-keyed update — the alias for 'abc' + // is not directly addressable from ['id' => '7']. + $handler->updateCompound(['id' => '7'], ['keyHash' => 'xyz']); + + // Lookup by the OLD key: alias → id 7 → row entry was invalidated → + // DB re-read returns the rotated row (keyHash xyz) → verification + // rejects it → alias dropped → re-resolve by keyHash finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'xyz', 'status' => 'active']]); + $this->queryStrategy->queueQueryResult([]); + + $this->expectException(RecordNotFoundException::class); + + $handler->findByCompound(['keyHash' => 'abc']); + } + + public function testGenerationBumpMakesLateStaleWriteUnreachable(): void + { + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Reader caches the row under the current generation. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $stale = $handler->findByCompound(['id' => '7']); + + // A slow reader computed its context BEFORE the write… + $staleContext = $this->probeRowContext(['id' => '7']); + + // …the writer updates and bumps the generation (its pre-read is + // served from the cache — no query)… + $handler->updateCompound(['id' => '7'], ['status' => 'revoked']); + + // …and the slow reader SETs its stale row back using the OLD context. + $this->cacheableService->set($staleContext, $stale); + + // The stale entry physically exists, but no new reader can compute + // its key: the next read misses and round-trips the DB. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'revoked']]); + $fresh = $handler->findByCompound(['id' => '7']); + + $this->assertSame('revoked', $fresh->get('status'), 'Late stale write-back was served after the generation bump.'); + } + + /** + * Generations orphan the cached count via the bump; opt-out tables + * delete the set-level context precisely. Same observable behavior. + * + * @dataProvider generationModes + */ + public function testEstimatedCountInvalidatesAfterWrite(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $this->queryStrategy->estimatedCountValue = 5; + $this->assertSame(5, $handler->getEstimatedCount()); + + // Cached: a changed underlying count is not visible yet. + $this->queryStrategy->estimatedCountValue = 6; + $this->assertSame(5, $handler->getEstimatedCount()); + + // Any write invalidates the cached count. + $handler->create(['name' => 'new row']); + + $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write.'); + } + + public function testFirstReadAfterCreateHitsTheDatabase(): void + { + // create() deliberately does NOT pre-warm: a model hydrated from + // write attributes carries request-typed scalars, and a pre-warm can + // seed the newest generation with a row another writer already + // overwrote. The first read costs one round-trip and is DB-true. + $handler = $this->makeHandler(['id'], 'test_records', true); + + $handler->create(['name' => 'as-written']); + + $this->queryStrategy->queueQueryResult([['id' => '1', 'name' => 'db-truth']]); + $read = $handler->findByCompound(['id' => 1]); + + $this->assertSame('db-truth', $read->get('name')); + $this->assertSame(1, $this->queryStrategy->queryCount); + } + + /** + * Cache-entry expectations differ only by the generation-token entry the + * ON mode keeps in the store. + * + * @return array + */ + public static function generationModesWithTokenOnlyEntryCounts(): array + { + return [ + 'generations on (production default)' => [true, 1], + 'generations off (opt-out)' => [false, 0], + ]; + } + + /** + * @dataProvider generationModesWithTokenOnlyEntryCounts + */ + public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(bool $useGenerations, int $expectedEntries): void + { + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('rowFields')); + + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations, $logger); + + // The SELECT * row lacks orgId — a partial identity must never + // become a cache key. + $this->queryStrategy->queueQueryResult([['id' => '42']]); + $this->queryStrategy->queueQueryResult([['id' => 42, 'name' => 'incomplete']]); + // The read-back cannot be served from cache, so it queries again. + $this->queryStrategy->queueQueryResult([['id' => 42, 'name' => 'incomplete']]); + + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'incomplete']]]]); + + // Only the generation token (when enabled) may exist — no row entry, + // no alias entry. + $this->assertCount($expectedEntries, $this->cacheStrategy->store, 'A partial-identity row produced a cache entry.'); + } + + /** + * @dataProvider generationModes + */ + public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGenerations): void + { + $events = new RecordingEventStrategy(); + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations, null, $events); + + // Prime the cache. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'name' => 'doomed']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'doomed']]]]); + + // deleteWhere resolves identity rows and deletes by the FULL table + // identity — not the model's subset identity. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); + $this->assertFalse( + $this->cacheableService->exists($this->probeRowContext(['orgId' => '1', 'id' => '42'])), + 'deleteWhere left the canonical row entry behind.' + ); + + // The deletion is broadcast with the raw identity row — DB-typed + // values, no cache normalization in the event contract. + $deletions = $events->ofType(RecordDeleted::class); + $this->assertCount(1, $deletions); + $this->assertSame(IdentityRowModel::class, $deletions[0]->getType()); + $this->assertSame(['orgId' => '1', 'id' => '42'], $deletions[0]->getIdentity()); + } + + public function testDeleteWhereBroadcastsWhenIdentityCannotBeDerived(): void + { + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('missing an identity field')); + + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', false, $logger, $events); + + // The identity row is missing orgId, so no cache entry can be named + // — but the SQL delete still runs and listeners MUST hear about it. + $this->queryStrategy->queueQueryResult([['id' => '42']]); + $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'orphan']]); + + $this->assertSame([['id' => '42']], $this->queryStrategy->deletes); + + $deletions = $events->ofType(RecordDeleted::class); + $this->assertCount(1, $deletions); + $this->assertSame(['id' => '42'], $deletions[0]->getIdentity()); + } + + public function testDeleteWhereWithNoMatchesLeavesCacheUntouched(): void + { + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Prime a generation token + row entry via a read. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + // No rows match: nothing may be deleted, bumped, or re-minted. + $this->queryStrategy->queueQueryResult([]); + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'missing']]); + + $this->assertSame($storeBefore, $this->cacheStrategy->store); + $this->assertSame([], $this->queryStrategy->deletes); + } + + private function useFlakyCache(): FlakyCacheStrategy + { + $flaky = new FlakyCacheStrategy(); + $this->cacheStrategy = $flaky; + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $flaky, + new SerializingCachePolicy() + ); + + return $flaky; + } + + /** + * @dataProvider generationModes + */ + public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); + + $flaky->failWrites = true; + + $created = $handler->create(['name' => 'survivor']); + \assert($created instanceof IdentityRowModel); + + $this->assertSame('survivor', $created->get('name')); + $this->assertCount(1, $events->ofType(RecordCreated::class), 'A cache outage suppressed RecordCreated for a committed insert.'); + } + + /** + * @dataProvider generationModes + */ + public function testUpdateCompoundSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); + + // Prime the row while the cache is healthy so the pre-read hits. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + + $flaky->failWrites = true; + + $handler->updateCompound(['id' => '7'], ['status' => 'revoked']); + + $this->assertCount(1, $this->queryStrategy->updates, 'The committed update was rolled back by a cache failure.'); + $this->assertCount(1, $events->ofType(RecordUpdated::class), 'A cache outage suppressed RecordUpdated for a committed update.'); + } + + /** + * @dataProvider generationModes + */ + public function testDeleteWhereSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); + + // Prime a generation token while the cache is healthy. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'doomed']]); + $handler->findByCompound(['id' => '7']); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertSame([['id' => '7']], $this->queryStrategy->deletes, 'A cache failure aborted the SQL delete.'); + $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A cache outage suppressed RecordDeleted for a committed delete.'); + } + + public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerations(): void + { + // The adapter exposes none of the lookup fields, so alias-resolved + // rows can't be verified. On a generation-disabled table the alias + // must not be trusted: every lookup re-resolves from the database. + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('could not be verified')); + + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new OpaqueModelAdapter()); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // Second lookup: alias hit, row hit, unverifiable, treated as + // stale, re-resolved from the database. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'An unverifiable alias was trusted on a generation-disabled table.'); + } + + public function testColdBusinessKeyReadSurvivesCacheWriteFailure(): void + { + // The DB answered; a cache that cannot store the result must not + // turn a successful read into an error. + $flaky = $this->useFlakyCache(); + $logger = $this->createMock(LoggerStrategy::class); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + } + + public function testRotatedAliasLookupSurfacesNotFoundNotCacheErrorsUnderWriteFailure(): void + { + // The self-heal path performs cache mutations (row store, alias + // delete); with a write-failing cache the lookup must still resolve + // to its true outcome — RecordNotFoundException — not a cache error. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_api_keys', false); + + // Prime alias keyHash abc → id 7 while healthy, then drop the row + // entry so the next lookup takes the re-read path. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + $this->cacheableService->delete($this->probeRowContext(['id' => '7'])); + + $flaky->failWrites = true; + + // Alias → id 7 → row re-read shows the key rotated away → alias + // dropped (swallowed failure) → re-resolve by key finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'xyz', 'status' => 'active']]); + $this->queryStrategy->queueQueryResult([]); + + $this->expectException(RecordNotFoundException::class); + + $handler->findByCompound(['keyHash' => 'abc']); + } + + public function testUpdateCompoundThrowsWhenTheRecordCannotBeReResolved(): void + { + // With a write-dead cache the alias never persists, and the DB + // fallback resolution comes back empty (the row vanished + // mid-operation). Falling back to the caller's business key could + // fan the write out — the update must fail instead. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_api_keys', true); + + $flaky->failWrites = true; + + // Pre-read resolves via the DB (alias store fails silently)… + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + // …then identity resolution's DB fallback finds the row gone. + $this->queryStrategy->queueQueryResult([]); + + try { + $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); + $this->fail('Expected RecordNotFoundException when the record cannot be re-resolved.'); + } catch (RecordNotFoundException $e) { + $this->assertSame([], $this->queryStrategy->updates, 'An unresolvable record was still updated by raw business key.'); + } + } + + public function testListReadStaysBatchedWhenCacheWritesFail(): void + { + // The batch result must not depend on the cache write landing: with + // a write-dead cache, one where() is still findIds + one SELECT — + // never 1+N per-row re-queries. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->queueQueryResult([ + ['id' => '1', 'name' => 'first'], + ['id' => '2', 'name' => 'second'], + ]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); + + $this->assertCount(2, $models); + [$first, $second] = $models; + \assert($first instanceof IdentityRowModel && $second instanceof IdentityRowModel); + $this->assertSame('first', $first->get('name')); + $this->assertSame('second', $second->get('name')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'A cache outage degraded a batched list read into per-row queries.'); + } + + public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): void + { + // Self-matches are filtered by the pre-read record's TABLE identity + // (model identity only as a fallback): a business-key caller + // re-sending the record's own unique values must not trip + // DuplicateEntryException just because their lookup key never + // equals an identity. + $handler = $this->makeHandler(['id'], 'test_api_keys', true, null, null, null, [['keyHash']]); + + // Pre-read resolves the record by business key… + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + // …and the duplicate scan finds the same record (identity row, then + // its hydration is served from the cache warmed by the pre-read). + $this->queryStrategy->queueQueryResult([['id' => '7']]); + + $handler->updateCompound(['keyHash' => 'abc'], ['keyHash' => 'abc', 'status' => 'revoked']); + + $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); + } + + /** + * @return array, 1: string, 2: string}> + */ + public static function readOutageLookups(): array + { + return [ + 'business-key lookup (alias + token reads)' => [['keyHash' => 'abc'], 'id', '7'], + 'canonical lookup (read-through probe)' => [['id' => '7'], 'status', 'active'], + ]; + } + + /** + * Probe/read failures must degrade to database loads — never break the + * lookup, whatever its shape. + * + * @dataProvider readOutageLookups + * + * @param array $lookup + */ + public function testReadsSurviveACacheThatFailsOnReads(array $lookup, string $field, string $expected): void + { + $flaky = $this->useFlakyCache(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->logicalOr( + $this->stringContains('read failed'), + $this->stringContains('store failed'), + $this->stringContains('Could not cache'), + $this->stringContains('Could not persist') + )); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger); + + $flaky->failReads = true; + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound($lookup); + + $this->assertSame($expected, $model->get($field)); + } + + public function testEstimatedCountSurvivesACacheThatFailsOnReads(): void + { + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + $flaky->failReads = true; + $this->queryStrategy->estimatedCountValue = 9; + + $this->assertSame(9, $handler->getEstimatedCount()); + } + + public function testPoisonedEstimatedCountSlotIsBypassedNotServed(): void + { + // The set-level slot is not evict-and-repaired like row/alias slots, + // but a poisoned value must never be garbage-cast and served — the + // database answers instead. + $handler = $this->makeHandler(['id'], 'test_records', true); + + $this->queryStrategy->estimatedCountValue = 9; + $this->assertSame(9, $handler->getEstimatedCount()); + + $this->cacheableService->set($this->contextAdapter->toTableContext($this->currentToken()), 'garbage'); + + $this->assertSame(9, $handler->getEstimatedCount()); + } + + public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void + { + // A read FAILURE mints an ephemeral token without persisting: when + // reads recover, the original token (and the entries keyed under it) + // must still be live. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Healthy read caches the row under the current token. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + // During the blip, the read degrades to the database… + $flaky->failReads = true; + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + + // …and after recovery the original entries are untouched and served. + $flaky->failReads = false; + + // Byte-for-byte: nothing clobbered, nothing NEW written — stores + // no-op under ephemeral tokens, so a blip can neither corrupt healthy + // entries nor fill the cache with unreachable ones. + $this->assertSame($storeBefore, $this->cacheStrategy->store, 'A read blip changed the cache.'); + + $served = $handler->findByCompound(['id' => '7']); + $this->assertSame('active', $served->get('status')); + } + + public function testAReadOutageNeverFillsTheCacheThroughDirectStorePaths(): void + { + // Ephemeral tokens must gate the DIRECT store paths (alias writes and + // the list-read batch write-back), not just read-throughs: during a + // read outage WRITES still work, so without the guard every lookup + // would land entries under a token that is never persisted — + // unbounded, unreachable cache garbage. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Healthy read establishes a token and a canonical row entry. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + $flaky->failReads = true; + + // Business-key lookup — would store an alias and a row directly. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $this->assertSame('active', $handler->findByCompound(['keyHash' => 'abc'])->get('status')); + + // List read — getModels() would batch-write rows back. + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'status', 'operator' => '=', 'value' => 'active']]]]); + + $this->assertCount(1, $models); + $this->assertSame($storeBefore, $this->cacheStrategy->store, 'A read outage wrote cache entries under an ephemeral token.'); + } + + public function testDuplicateOnADifferentRowSharingTheModelIdentityIsStillCaught(): void + { + // Model identity (id only) is a SUBSET of the table identity + // (orgId + id): a duplicate on org 2's row must not hide behind + // sharing org 1's model identity. + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', true, null, null, null, [['keyHash']]); + + // Pre-read: org 1's row, resolved canonically. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'keyHash' => 'abc']]); + // Duplicate scan finds org 2's row carrying the same unique value — + // and the same MODEL identity (id 42). + $this->queryStrategy->queueQueryResult([['orgId' => '2', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => '2', 'id' => '42', 'keyHash' => 'abc']]); + + $this->expectException(DuplicateEntryException::class); + + $handler->updateCompound(['orgId' => '1', 'id' => '42'], ['keyHash' => 'abc']); + } + + public function testConcurrentlyDeletedRowDoesNotCollapseTheListResult(): void + { + // A row deleted between the id query and hydration must be skipped — + // not allowed to throw RecordNotFoundException into where()'s catch, + // which would discard rows that still exist. + $handler = $this->makeHandler(['id'], 'test_records', true); + + // findIds sees two rows; the batch SELECT only finds one (row 2 + // vanished); the per-id fallback for row 2 also finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->queueQueryResult([['id' => '1', 'name' => 'survivor']]); + $this->queryStrategy->queueQueryResult([]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); + + $this->assertCount(1, $models, 'A concurrently deleted row collapsed the whole result.'); + $survivor = $models[0]; + \assert($survivor instanceof IdentityRowModel); + $this->assertSame('survivor', $survivor->get('name')); + } + + public function testMidLoopSqlFailureStillInvalidatesAndAnnouncesCompletedDeletes(): void + { + // Row 1 deletes and must still bump the generation and broadcast, + // even though row 2's SQL delete throws. + $events = new RecordingEventStrategy(); + $handler = $this->makeHandler(['id'], 'test_records', true, null, $events); + + // Prime a token + a cached row so invalidation is observable. + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'doomed']]); + $handler->findByCompound(['id' => '1']); + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->throwOnDeleteCall = 2; + + try { + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + $this->fail('Expected the mid-loop SQL failure to propagate.'); + } catch (\Throwable $e) { + $this->assertInstanceOf(\LogicException::class, $e); + $this->assertSame([['id' => '1']], $this->queryStrategy->deletes, 'Row 1 was not deleted before the failure.'); + $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A completed delete was not announced after a mid-loop failure.'); + $this->assertFalse( + $this->cacheableService->exists($this->probeRowContext(['id' => '1'])), + 'The completed delete\'s row entry survived the failure.' + ); + } + } + + public function testThrowingListenerDoesNotSuppressRemainingDeleteBroadcasts(): void + { + // The FIRST RecordDeleted listener throws; the second row's event + // must still fire (and the failure is logged, not propagated). + $events = new ThrowingOnceEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('listener failed')); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertCount(2, $events->ofType(RecordDeleted::class), 'A throwing listener suppressed a sibling RecordDeleted.'); + } + + public function testPartiallyVerifiableAliasLookupIsNotTrustedWithoutGenerations(): void + { + // The adapter hides tenantId; keyHash matches. One hidden lookup + // field must be enough to distrust the alias on a + // generation-disabled table — the hidden field is exactly the one a + // rotation may have changed. + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('could not be verified')); + + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new FieldHidingModelAdapter(['tenantId'])); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'tenantId' => '9', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc', 'tenantId' => '9']); + + // Second lookup re-resolves from the database instead of trusting + // the partially verifiable alias. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'tenantId' => '9', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc', 'tenantId' => '9']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'A partially verifiable alias was trusted on a generation-disabled table.'); + } + + public function testUnverifiableAliasLookupIsTrustedWithGenerations(): void + { + // With generations on, the bump covers rotation — an unverifiable + // alias (adapter exposes nothing) is trusted and served query-free. + $handler = $this->makeHandler(['id'], 'test_records', true, null, null, new OpaqueModelAdapter()); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + $before = $this->queryStrategy->queryCount; + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame($before, $this->queryStrategy->queryCount, 'An unverifiable alias was re-resolved despite generation coverage.'); + } + + /** + * An alias slot can hold garbage from a corrupted write or an older key + * vocabulary: string garbage, arrays with the wrong fields, or identity + * SUBSETS (old formats). None may ever be dereferenced as an identity. + * + * @return array, 1: mixed, 2: array, 3: array}> + */ + public static function malformedAliasPoisons(): array + { + return [ + 'string garbage' => [['id'], 'not-an-identity', ['id' => '1', 'status' => 'seed'], ['id' => '7', 'keyHash' => 'abc', 'status' => 'active']], + 'wrong-field array' => [['id'], ['status' => 'active'], ['id' => '1', 'status' => 'seed'], ['id' => '7', 'keyHash' => 'abc', 'status' => 'active']], + 'identity subset (old format)' => [['id', 'tenantId'], ['id' => '7'], ['id' => '1', 'tenantId' => '1', 'status' => 'seed'], ['id' => '7', 'tenantId' => '2', 'keyHash' => 'abc', 'status' => 'active']], + ]; + } + + /** + * @dataProvider malformedAliasPoisons + * @param array $identityFields + * @param mixed $poison + * @param array $seedRow + * @param array $freshRow + */ + public function testMalformedAliasValueIsNeverDereferencedAsAnIdentity(array $identityFields, $poison, array $seedRow, array $freshRow): void + { + $handler = $this->makeHandler($identityFields, 'test_records', true); + + // Establish a token so the poisoned slot matches what reads probe. + $this->queryStrategy->queueQueryResult([$seedRow]); + $handler->findByCompound(array_intersect_key($seedRow, array_flip($identityFields))); + + $this->cacheableService->set($this->probeAliasContext(['keyHash' => 'abc']), $poison); + + $this->queryStrategy->queueQueryResult([$freshRow]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + + // And the slot is REPAIRED, not just bypassed: with nothing queued, + // a second lookup can only be served by the healed alias + row. + $repaired = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $repaired->get('id')); + } + + /** + * @dataProvider generationModes + */ + public function testPoisonedCanonicalEntryIsEvictedAndRepaired(bool $useGenerations): void + { + // A canonical slot holding something that is not a model must never + // be served — and it must not stay poisoned: the read repairs the + // slot so subsequent reads are cache hits again. + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'seed']]); + $handler->findByCompound(['id' => '1']); + + $this->cacheableService->set($this->probeRowContext(['id' => '1']), 'not-a-model'); + + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'active']]); + $model = $handler->findByCompound(['id' => '1']); + + $this->assertSame('active', $model->get('status')); + + // No queued result remains, so a second read can only succeed if the + // slot was re-warmed — a query here throws by construction. + $repaired = $handler->findByCompound(['id' => '1']); + + $this->assertSame('active', $repaired->get('status')); + } + +} + +class CanonicalHandler +{ + use WithDatastoreHandlerMethods; + + private bool $useGenerations; + + /** + * @param class-string $model + * @param ModelAdapter $modelAdapter + */ + public function __construct( + DatabaseServiceProvider $serviceProvider, + Table $table, + TableSchemaService $tableSchemaService, + string $model, + ModelAdapter $modelAdapter, + bool $useGenerations = false + ) { + $this->serviceProvider = $serviceProvider; + $this->table = $table; + $this->tableSchemaService = $tableSchemaService; + $this->model = $model; + $this->modelAdapter = $modelAdapter; + $this->useGenerations = $useGenerations; + } + + protected function shouldUseTableGenerations(): bool + { + return $this->useGenerations; + } + + /** + * @param array $ids + * @return IdentityRowModel + */ + public function findByCompound(array $ids) + { + $model = $this->findFromCompound($ids); + \assert($model instanceof IdentityRowModel); + + return $model; + } +} diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 8e3ce0c..798ca6e 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -1,34 +1,19 @@ createMock(CacheableService::class); $cacheableService->expects($this->never())->method('exists'); + // No pre-warm: create() never caches the attribute-hydrated model. + // Its only cache write path here (generations disabled) is the + // set-level context delete. + $cacheableService->expects($this->never())->method('set'); $cacheableService->expects($this->once()) - ->method('set') - ->with(['identities' => ['id' => '123'], 'type' => TestModel::class], $createdModel); + ->method('delete') + ->with(['type' => TestModel::class, 'table' => 'test_records']); $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); $table->method('getFieldsForIdentity')->willReturn(['id']); $table->method('getColumns')->willReturn([]); @@ -80,8 +70,8 @@ public function testCreateHydratesFromAttributesWithoutPostInsertRead(): void $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $cacheableService, $eventStrategy ); @@ -117,7 +107,7 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void $createdModel = new TestModel(123); $cacheableService = $this->createMock(CacheableService::class); - $cacheableService->expects($this->once())->method('set'); + $cacheableService->expects($this->never())->method('set'); $nameColumn = new Column('name', 'VARCHAR', [255]); $createdAtColumn = (new Column('createdAt', 'TIMESTAMP')) @@ -143,8 +133,8 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $cacheableService, $eventStrategy ); @@ -157,7 +147,7 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void $modelAdapter ); - $handler->create(['name' => 'Example']); + $this->assertSame($createdModel, $handler->create(['name' => 'Example'])); } public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void @@ -184,14 +174,15 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void $tableSchemaService = $this->createMock(TableSchemaService::class); $tableSchemaService->method('getUniqueColumns')->willReturn([]); + $createdModel = new TestModel(7); $modelAdapter = $this->createMock(ModelAdapter::class); - $modelAdapter->method('toModel')->willReturn(new TestModel(7)); + $modelAdapter->method('toModel')->willReturn($createdModel); $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $cacheableService, $eventStrategy ); @@ -204,50 +195,13 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void $modelAdapter ); - $handler->create(['createdAt' => 'caller-provided']); - } - - public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void - { - // Regression: MySQL returns identity columns as strings, but hydrated - // models hold them as ints. Without normalization, the same record - // produces two distinct cache entries and updateCompound() only - // invalidates one of them — leaving the other to serve stale reads. - $loggerStrategy = $this->createMock(LoggerStrategy::class); - $eventStrategy = $this->createMock(EventStrategy::class); - $queryStrategy = $this->createMock(QueryStrategy::class); - $cacheableService = $this->createMock(CacheableService::class); - $table = $this->createMock(Table::class); - $tableSchemaService = $this->createMock(TableSchemaService::class); - $modelAdapter = $this->createMock(ModelAdapter::class); - - $serviceProvider = new DatabaseServiceProvider( - $loggerStrategy, - $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), - $cacheableService, - $eventStrategy - ); - - $handler = new DummyDatastoreHandler( - $serviceProvider, - $table, - $tableSchemaService, - TestModel::class, - $modelAdapter - ); - - $intContext = $handler->exposeCacheContext(['id' => 123]); - $stringContext = $handler->exposeCacheContext(['id' => '123']); - - $this->assertSame($intContext, $stringContext); + $this->assertSame($createdModel, $handler->create(['createdAt' => 'caller-provided'])); } public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): void { - // Drives the actual stale-read scenario end-to-end: cacheItems writes - // with int identity (from the hydrated model), updateCompound is called + // Drives the actual stale-read scenario end-to-end: the read path + // caches rows with int identity values, updateCompound is called // with the string identity that came back from queryStrategy->query() // — both must hit the same cache key for the invalidation to land. $loggerStrategy = $this->createMock(LoggerStrategy::class); @@ -258,7 +212,10 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $cacheableService = $this->createMock(CacheableService::class); $cacheableService->method('getWithCache') ->willReturnCallback(fn(string $operation, array $context, callable $callback) => $callback()); - $cacheableService->expects($this->once()) + // Two deletes: the canonical row entry, then the set-level context + // (this handler opts out of generations, so estimatedCount has no + // bump to orphan it and is deleted precisely). + $cacheableService->expects($this->exactly(2)) ->method('delete') ->willReturnCallback(function (array $context) use (&$deletedKeys) { $deletedKeys[] = $context; @@ -270,6 +227,7 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $table = $this->createMock(Table::class); $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn(['id']); $tableSchemaService = $this->createMock(TableSchemaService::class); $tableSchemaService->method('getUniqueColumns')->willReturn([]); $modelAdapter = $this->createMock(ModelAdapter::class); @@ -278,8 +236,8 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $cacheableService, $eventStrategy ); @@ -295,11 +253,13 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // String identity (the shape MySQL returns). $handler->updateCompound(['id' => '42'], ['name' => 'new']); - // The deleted cache context must match what cacheItems wrote earlier, - // which used the int identity from the hydrated model. - $expected = $handler->exposeCacheContext(['id' => 42]); - $this->assertCount(1, $deletedKeys); + // The deleted cache context must match what the read path wrote, + // which used the int identity from the hydrated row. + $contextAdapter = new RowCacheContextAdapter($table, TestModel::class, $modelAdapter, false); + $expected = $contextAdapter->toRowContext(['id' => 42], null); + $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); + $this->assertSame(['type' => TestModel::class, 'table' => 'test_records'], $deletedKeys[1]); } public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing(): void @@ -319,6 +279,7 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( $table = $this->createMock(Table::class); $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn(['id']); $tableSchemaService = $this->createMock(TableSchemaService::class); $modelAdapter = $this->createMock(ModelAdapter::class); @@ -326,8 +287,8 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, $queryStrategy, - new DummyQueryBuilder(), - new DummyClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $cacheableService, $eventStrategy ); @@ -340,11 +301,15 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( $modelAdapter ); - $this->expectException(RecordNotFoundException::class); - $this->expectExceptionMessage('Record not found in table "test_records"'); - $this->expectExceptionMessage('"id":123'); - - $handler->findByIdentity(['id' => 123]); + try { + $handler->findByIdentity(['id' => 123]); + $this->fail('Expected RecordNotFoundException was not thrown.'); + } catch (RecordNotFoundException $e) { + // expectExceptionMessage() keeps only its LAST invocation, so + // both fragments are pinned explicitly. + $this->assertStringContainsString('Record not found in table "test_records"', $e->getMessage()); + $this->assertStringContainsString('"id":123', $e->getMessage()); + } } } @@ -371,9 +336,14 @@ public function findByIdentity(array $ids) return $this->findFromCompound($ids); } - public function exposeCacheContext(array $ids): array + /** + * Legacy tests assert precise per-key cache interactions against a mocked + * CacheableService; generations would add token get/set chatter that + * belongs to the dedicated generation tests. + */ + protected function shouldUseTableGenerations(): bool { - return $this->getCacheContextForItem($ids); + return false; } } @@ -393,130 +363,3 @@ public function getIdentity(): array return ['id' => $this->id]; } } - -class DummyQueryBuilder implements QueryBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function select(string $field, string ...$fields) - { - return $this; - } - - public function from(Table $table) - { - return $this; - } - - public function where(?ClauseBuilder $clauseBuilder) - { - return $this; - } - - public function leftJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function rightJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function groupBy(string $column, string ...$columns) - { - return $this; - } - - public function sum(string $fieldToSum, ?string $alias = null) - { - return $this; - } - - public function count(string $fieldToCount, ?string $alias = null) - { - return $this; - } - - public function limit(int $limit) - { - return $this; - } - - public function offset(int $offset) - { - return $this; - } - - public function orderBy(string $field, string $order) - { - return $this; - } - - public function build(): string - { - return 'SELECT * FROM test_table'; - } - - public function reset() - { - return $this; - } - - public function resetClauses(string $clause, string ...$clauses) - { - return $this; - } -} - -class DummyClauseBuilder implements ClauseBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function where($field, string $operator, ...$values) - { - return $this; - } - - public function andWhere($field, string $operator, ...$values) - { - return $this; - } - - public function orWhere($field, string $operator, ...$values) - { - return $this; - } - - public function group(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function andGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function orGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function build(): string - { - return 'id = 123'; - } - - public function reset() - { - return $this; - } -} -}