diff --git a/.editorconfig b/.editorconfig index f2f85fd..900307d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -559,7 +559,7 @@ ij_php_keep_control_statement_in_one_line = true ij_php_keep_first_column_comment = true ij_php_keep_indents_on_empty_lines = false ij_php_keep_line_breaks = true -ij_php_keep_rparen_and_lbrace_on_one_line = false +ij_php_keep_rparen_and_lbrace_on_one_line = true ij_php_keep_simple_classes_in_one_line = false ij_php_keep_simple_methods_in_one_line = false ij_php_lambda_brace_style = end_of_line diff --git a/.gitignore b/.gitignore index 112391f..c0d8d85 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ /wordpress # AI +.claude/worktrees/ +graphify-out/ +docs/superposers graphify-out diff --git a/composer.json b/composer.json index 8fbce0a..fea347f 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,10 @@ "SpaghettiDojo\\Konomi\\Tests\\Unit\\": "tests/unit/php", "SpaghettiDojo\\Konomi\\Tests\\Integration\\": "tests/integration/php", "SpaghettiDojo\\Konomi\\Tests\\Functional\\": "tests/functional/php" - } + }, + "files": [ + "tests/Helpers/functions.php" + ] }, "config": { "platform": { diff --git a/composer.lock b/composer.lock index 55c4354..223303a 100644 --- a/composer.lock +++ b/composer.lock @@ -328,27 +328,27 @@ }, { "name": "brain/monkey", - "version": "2.6.2", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Brain-WP/BrainMonkey.git", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373" + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/d95a9d895352c30f47604ad1b825ab8fa9d1a373", - "reference": "d95a9d895352c30f47604ad1b825ab8fa9d1a373", + "url": "https://api.github.com/repos/Brain-WP/BrainMonkey/zipball/ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", + "reference": "ea3aeb3d559ba3c0930b3f4d210b665a4c044d83", "shasum": "" }, "require": { "antecedent/patchwork": "^2.1.17", - "mockery/mockery": "^1.3.5 || ^1.4.4", + "mockery/mockery": "~1.3.6 || ~1.4.4 || ~1.5.1 || ^1.6.10", "php": ">=5.6.0" }, "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^0.7.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0", "phpcompatibility/php-compatibility": "^9.3.0", - "phpunit/phpunit": "^5.7.26 || ^6.0 || ^7.0 || >=8.0 <8.5.12 || ^8.5.14 || ^9.0" + "phpunit/phpunit": "^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.49 || ^9.6.30" }, "type": "library", "extra": { @@ -394,20 +394,20 @@ "issues": "https://github.com/Brain-WP/BrainMonkey/issues", "source": "https://github.com/Brain-WP/BrainMonkey" }, - "time": "2024-08-29T20:15:04+00:00" + "time": "2026-02-05T09:22:14+00:00" }, { "name": "brianium/paratest", - "version": "v7.8.4", + "version": "v7.8.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4" + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/130a9bf0e269ee5f5b320108f794ad03e275cad4", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", "shasum": "" }, "require": { @@ -415,27 +415,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", + "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.10", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.46", "sebastian/environment": "^7.2.1", - "symfony/console": "^6.4.22 || ^7.3.0", - "symfony/process": "^6.4.20 || ^7.3.0" + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "squizlabs/php_codesniffer": "^3.13.2", - "symfony/filesystem": "^6.4.13 || ^7.3.0" + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -475,7 +475,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.8.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" }, "funding": [ { @@ -487,7 +487,7 @@ "type": "paypal" } ], - "time": "2025-06-23T06:07:21+00:00" + "time": "2026-01-08T08:02:38+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", @@ -569,29 +569,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -611,9 +611,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "fidry/cpu-core-counter", @@ -1201,39 +1201,36 @@ }, { "name": "nunomaduro/collision", - "version": "v8.8.3", + "version": "v8.9.4", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4" + "reference": "716af8f95a470e9094cfca09ed897b023be191a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/1dc9e88d105699d0fee8bb18890f41b274f6b4c4", - "reference": "1dc9e88d105699d0fee8bb18890f41b274f6b4c4", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5", + "reference": "716af8f95a470e9094cfca09ed897b023be191a5", "shasum": "" }, "require": { - "filp/whoops": "^2.18.1", - "nunomaduro/termwind": "^2.3.1", + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.3.0" + "symfony/console": "^7.4.8 || ^8.0.8" }, "conflict": { - "laravel/framework": "<11.44.2 || >=13.0.0", - "phpunit/phpunit": "<11.5.15 || >=13.0.0" + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" }, "require-dev": { - "brianium/paratest": "^7.8.3", - "larastan/larastan": "^3.4.2", - "laravel/framework": "^11.44.2 || ^12.18", - "laravel/pint": "^1.22.1", - "laravel/sail": "^1.43.1", - "laravel/sanctum": "^4.1.1", - "laravel/tinker": "^2.10.1", - "orchestra/testbench-core": "^9.12.0 || ^10.4", - "pestphp/pest": "^3.8.2 || ^4.0.0", - "sebastian/environment": "^7.2.1 || ^8.0" + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.9.6", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0", + "laravel/pint": "^1.29.1", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0" }, "type": "library", "extra": { @@ -1296,35 +1293,35 @@ "type": "patreon" } ], - "time": "2025-11-20T02:55:25+00:00" + "time": "2026-04-21T14:04:20+00:00" }, { "name": "nunomaduro/termwind", - "version": "v2.3.3", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/nunomaduro/termwind.git", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017" + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/6fb2a640ff502caace8e05fd7be3b503a7e1c017", - "reference": "6fb2a640ff502caace8e05fd7be3b503a7e1c017", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", "shasum": "" }, "require": { "ext-mbstring": "*", "php": "^8.2", - "symfony/console": "^7.3.6" + "symfony/console": "^7.4.4 || ^8.0.4" }, "require-dev": { - "illuminate/console": "^11.46.1", - "laravel/pint": "^1.25.1", + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", "mockery/mockery": "^1.6.12", - "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.1.3", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", "phpstan/phpstan": "^1.12.32", "phpstan/phpstan-strict-rules": "^1.6.2", - "symfony/var-dumper": "^7.3.5", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", "thecodingmachine/phpstan-strict-rules": "^1.0.0" }, "type": "library", @@ -1356,7 +1353,7 @@ "email": "enunomaduro@gmail.com" } ], - "description": "Its like Tailwind CSS, but for the console.", + "description": "It's like Tailwind CSS, but for the console.", "keywords": [ "cli", "console", @@ -1367,7 +1364,7 @@ ], "support": { "issues": "https://github.com/nunomaduro/termwind/issues", - "source": "https://github.com/nunomaduro/termwind/tree/v2.3.3" + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" }, "funding": [ { @@ -1383,20 +1380,20 @@ "type": "github" } ], - "time": "2025-11-20T02:34:59+00:00" + "time": "2026-02-16T23:10:27+00:00" }, { "name": "openmetrics-php/exposition-text", - "version": "v0.4.2", + "version": "v0.4.3", "source": { "type": "git", "url": "https://github.com/openmetrics-php/exposition-text.git", - "reference": "4f65004f93e53eac4b9668879b92d3c200a851ba" + "reference": "754e0003543f7b0f31c4bd46f88e5f2413f32c40" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/openmetrics-php/exposition-text/zipball/4f65004f93e53eac4b9668879b92d3c200a851ba", - "reference": "4f65004f93e53eac4b9668879b92d3c200a851ba", + "url": "https://api.github.com/repos/openmetrics-php/exposition-text/zipball/754e0003543f7b0f31c4bd46f88e5f2413f32c40", + "reference": "754e0003543f7b0f31c4bd46f88e5f2413f32c40", "shasum": "" }, "require": { @@ -1424,44 +1421,44 @@ "description": "Implementation of the text exposition format of OpenMetrics", "support": { "issues": "https://github.com/openmetrics-php/exposition-text/issues", - "source": "https://github.com/openmetrics-php/exposition-text/tree/v0.4.2" + "source": "https://github.com/openmetrics-php/exposition-text/tree/v0.4.3" }, - "time": "2025-06-04T14:59:47+00:00" + "time": "2026-02-20T11:21:20+00:00" }, { "name": "pestphp/pest", - "version": "v3.8.4", + "version": "v3.8.6", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "72cf695554420e21858cda831d5db193db102574" + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/72cf695554420e21858cda831d5db193db102574", - "reference": "72cf695554420e21858cda831d5db193db102574", + "url": "https://api.github.com/repos/pestphp/pest/zipball/8871a6f5ef1de8e7c8dee2a270991449a7b6af73", + "reference": "8871a6f5ef1de8e7c8dee2a270991449a7b6af73", "shasum": "" }, "require": { - "brianium/paratest": "^7.8.4", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.9.1", + "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.1", "pestphp/pest-plugin-mutate": "^3.0.5", "php": "^8.2.0", - "phpunit/phpunit": "^11.5.33" + "phpunit/phpunit": "^11.5.50" }, "conflict": { "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.33", + "phpunit/phpunit": ">11.5.50", "sebastian/exporter": "<6.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^3.4.0", "pestphp/pest-plugin-type-coverage": "^3.6.1", - "symfony/process": "^7.3.0" + "symfony/process": "^7.4.5" }, "bin": [ "bin/pest" @@ -1526,7 +1523,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.4" + "source": "https://github.com/pestphp/pest/tree/v3.8.6" }, "funding": [ { @@ -1538,7 +1535,7 @@ "type": "github" } ], - "time": "2025-08-20T19:12:42+00:00" + "time": "2026-03-10T21:04:33+00:00" }, { "name": "pestphp/pest-plugin", @@ -2162,16 +2159,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.5", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", "shasum": "" }, "require": { @@ -2179,9 +2176,9 @@ "ext-filter": "*", "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -2190,7 +2187,8 @@ "phpstan/phpstan-mockery": "^1.1", "phpstan/phpstan-webmozart-assert": "^1.2", "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" }, "type": "library", "extra": { @@ -2220,44 +2218,44 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" }, - "time": "2025-11-27T19:50:05+00:00" + "time": "2026-03-18T20:49:53+00:00" }, { "name": "phpdocumentor/type-resolver", - "version": "1.12.0", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", "shasum": "" }, "require": { "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", + "php": "^7.4 || ^8.0", "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" + "phpstan/phpdoc-parser": "^2.0" }, "require-dev": { "ext-tokenizer": "*", "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" + "psalm/phar": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-1.x": "1.x-dev" + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" } }, "autoload": { @@ -2278,9 +2276,9 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" }, - "time": "2025-11-21T15:09:14+00:00" + "time": "2026-01-06T21:53:42+00:00" }, { "name": "phpmetrics/phpmetrics", @@ -2361,16 +2359,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "2.3.0", + "version": "2.3.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495" + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/1e0cd5370df5dd2e556a36b9c62f62e555870495", - "reference": "1e0cd5370df5dd2e556a36b9c62f62e555870495", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", "shasum": "" }, "require": { @@ -2402,17 +2400,17 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" }, - "time": "2025-08-30T15:50:23+00:00" + "time": "2026-01-25T14:56:51+00:00" }, { "name": "phpstan/phpstan", - "version": "2.1.33", + "version": "2.1.54", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9e800e6bee7d5bd02784d4c6069b48032d16224f", - "reference": "9e800e6bee7d5bd02784d4c6069b48032d16224f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", "shasum": "" }, "require": { @@ -2457,39 +2455,39 @@ "type": "github" } ], - "time": "2025-12-05T10:24:31+00:00" + "time": "2026-04-29T13:31:09+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "11.0.11", + "version": "11.0.12", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4" + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", - "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.4.0", + "nikic/php-parser": "^5.7.0", "php": ">=8.2", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-text-template": "^4.0.1", "sebastian/code-unit-reverse-lookup": "^4.0.1", "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", + "sebastian/environment": "^7.2.1", "sebastian/lines-of-code": "^3.0.1", "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "theseer/tokenizer": "^1.3.1" }, "require-dev": { - "phpunit/phpunit": "^11.5.2" + "phpunit/phpunit": "^11.5.46" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -2527,7 +2525,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" }, "funding": [ { @@ -2547,32 +2545,32 @@ "type": "tidelift" } ], - "time": "2025-08-27T14:37:49+00:00" + "time": "2025-12-24T07:01:01+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -2600,15 +2598,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "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/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -2796,16 +2806,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.33", + "version": "11.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6" + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", "shasum": "" }, "require": { @@ -2819,17 +2829,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -2877,7 +2887,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" }, "funding": [ { @@ -2901,7 +2911,7 @@ "type": "tidelift" } ], - "time": "2025-08-16T05:19:02+00:00" + "time": "2026-01-27T05:59:18+00:00" }, { "name": "psr/log", @@ -3361,16 +3371,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -3429,7 +3439,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -3449,7 +3459,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -4429,47 +4439,39 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v8.0.9", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", + "php": ">=8.4", + "symfony/polyfill-mbstring": "^1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/string": "^7.4|^8.0" }, "provide": { "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "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" + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4503,7 +4505,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v8.0.9" }, "funding": [ { @@ -4523,20 +4525,20 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2026-04-29T15:02:55+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4549,7 +4551,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -4574,7 +4576,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.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4585,32 +4587,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-04-13T15:52:40+00:00" }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "8da41214757b87d97f181e3d14a4179286151007" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/8da41214757b87d97f181e3d14a4179286151007", + "reference": "8da41214757b87d97f181e3d14a4179286151007", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0|^8.0" + "symfony/filesystem": "^7.4|^8.0" }, "type": "library", "autoload": { @@ -4638,7 +4644,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v8.0.8" }, "funding": [ { @@ -4658,20 +4664,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.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": { @@ -4721,7 +4727,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4741,20 +4747,20 @@ "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.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -4803,7 +4809,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -4823,11 +4829,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -4888,7 +4894,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -4912,16 +4918,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -4973,7 +4979,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -4993,24 +4999,24 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -5038,7 +5044,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v8.0.8" }, "funding": [ { @@ -5058,20 +5064,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -5089,7 +5095,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -5125,7 +5131,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -5145,20 +5151,20 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", - "version": "v8.0.1", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", - "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -5215,7 +5221,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.1" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -5235,28 +5241,28 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", - "version": "0.8.5", + "version": "0.8.7", "source": { "type": "git", "url": "https://github.com/ta-tikoma/phpunit-architecture-test.git", - "reference": "cf6fb197b676ba716837c886baca842e4db29005" + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/cf6fb197b676ba716837c886baca842e4db29005", - "reference": "cf6fb197b676ba716837c886baca842e4db29005", + "url": "https://api.github.com/repos/ta-tikoma/phpunit-architecture-test/zipball/1248f3f506ca9641d4f68cebcd538fa489754db8", + "reference": "1248f3f506ca9641d4f68cebcd538fa489754db8", "shasum": "" }, "require": { "nikic/php-parser": "^4.18.0 || ^5.0.0", "php": "^8.1.0", - "phpdocumentor/reflection-docblock": "^5.3.0", - "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0", - "symfony/finder": "^6.4.0 || ^7.0.0" + "phpdocumentor/reflection-docblock": "^5.3.0 || ^6.0.0", + "phpunit/phpunit": "^10.5.5 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "symfony/finder": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "require-dev": { "laravel/pint": "^1.13.7", @@ -5292,9 +5298,9 @@ ], "support": { "issues": "https://github.com/ta-tikoma/phpunit-architecture-test/issues", - "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.5" + "source": "https://github.com/ta-tikoma/phpunit-architecture-test/tree/0.8.7" }, - "time": "2025-04-20T20:23:40+00:00" + "time": "2026-02-17T17:25:14+00:00" }, { "name": "theseer/tokenizer", @@ -5348,23 +5354,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -5374,7 +5380,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -5390,6 +5396,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -5400,9 +5410,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-04-11T10:33:05+00:00" }, { "name": "wp-coding-standards/wpcs", @@ -5486,5 +5496,5 @@ "platform-overrides": { "php": "8.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docs/storage-drivers.md b/docs/storage-drivers.md new file mode 100644 index 0000000..52c6c2b --- /dev/null +++ b/docs/storage-drivers.md @@ -0,0 +1,171 @@ +# Storage Drivers + +Konomi persists user/entity interactions through the `SpaghettiDojo\Konomi\Storage\Storage` interface. Core ships a single implementation, `Storage\TableStorage`, which writes to the `{prefix}konomi_interactions` custom table. The driver is registered once as a shared container service (`Storage\Storage::class`) and consumed by both the Post and User repositories. This document describes the contract and shows how to swap in an alternative driver (e.g. WordPress meta) the Modularity way, via an [Extending Module](https://inpsyde.github.io/modularity/Modules/#extendingmodule). + +## The `Storage` interface + +```php +namespace SpaghettiDojo\Konomi\Storage; + +interface Storage +{ + /** @return list */ + public function read(Axis $axis, int $id, string $groupKey): array; + + /** @param list $records */ + public function write(Axis $axis, int $id, string $groupKey, array $records): bool; +} +``` + +The same storage instance serves both the Post and User domains. There is a single `konomi_interactions` table; the `Axis` supplied per call tells the driver which column the bare `$id` addresses: + +```php +enum Axis +{ + case Entity; // $id is an entity (post) id -> column entity_id + case User; // $id is a user id -> column user_id +} +``` + +`Post\Repository` always calls with `Axis::Entity`, `User\Repository` always with `Axis::User`. + +`Record` is a readonly value object: + +```php +final class Record +{ + public function __construct( + public readonly int $entityId, + public readonly int $userId, + public readonly string $entityType, + ) {} +} +``` + +`$id` is the axis identifier (a post id under `Axis::Entity`, a user id under `Axis::User`). `$groupKey` is a sanitized `User\ItemGroup` value (e.g. `"reaction"`, `"bookmark"`). + +`read` returns every record scoped to `($axis, $id, $groupKey)`. `write` replaces the entire scope: existing rows for that `(axis column = $id, groupKey)` are deleted and the supplied `$records` are inserted. An empty `$records` list clears the scope. Implementations should be transactional — partial writes must roll back and return `false`. + +## Reference: meta-backed driver + +The example below stores each scope as a single serialized array on `wp_postmeta` (for `Axis::Entity`) or `wp_usermeta` (for `Axis::User`). A single implementation branches on the `Axis` it receives. Copy and adapt as needed. + +```php +namespace MyPlugin\Storage; + +use SpaghettiDojo\Konomi\Storage\Axis; +use SpaghettiDojo\Konomi\Storage\Record; +use SpaghettiDojo\Konomi\Storage\Storage; + +final class MetaStorage implements Storage +{ + private const BASE = '_konomi_items'; + + public function read(Axis $axis, int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + + $raw = $this->getMeta($axis, $id, self::BASE . '.' . $groupKey); + if (!is_array($raw)) { + return []; + } + + $records = []; + foreach ($raw as $row) { + if (!is_array($row)) { + continue; + } + $entityId = (int) ($row['entity_id'] ?? 0); + $userId = (int) ($row['user_id'] ?? 0); + $entityType = (string) ($row['entity_type'] ?? ''); + if ($entityId <= 0 || $userId <= 0 || $entityType === '') { + continue; + } + $records[] = new Record($entityId, $userId, $entityType); + } + return $records; + } + + public function write(Axis $axis, int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + + $payload = array_map( + static fn (Record $r) => [ + // Preserve the axis invariant: force the axis column to $id. + 'entity_id' => $axis === Axis::Entity ? $id : $r->entityId, + 'user_id' => $axis === Axis::User ? $id : $r->userId, + 'entity_type' => $r->entityType, + ], + $records + ); + + return $this->updateMeta($axis, $id, self::BASE . '.' . $groupKey, $payload); + } + + private function getMeta(Axis $axis, int $id, string $key): mixed + { + return $axis === Axis::Entity + ? get_post_meta($id, $key, true) + : get_user_meta($id, $key, true); + } + + /** @param list> $payload */ + private function updateMeta(Axis $axis, int $id, string $key, array $payload): bool + { + return (bool) ($axis === Axis::Entity + ? update_post_meta($id, $key, $payload) + : update_user_meta($id, $key, $payload)); + } +} +``` + +## Extending the storage service + +Konomi registers the driver once, under the `Storage\Storage::class` id (see `Storage\Module`). To swap it from a consumer plugin or site, extend that service with an [`ExtendingModule`](https://inpsyde.github.io/modularity/Modules/#extendingmodule) — you do **not** re-register the repositories: + +```php +use Inpsyde\Modularity\Module\ExtendingModule; +use Inpsyde\Modularity\Module\ModuleClassNameIdTrait; +use MyPlugin\Storage\MetaStorage; +use Psr\Container\ContainerInterface; +use SpaghettiDojo\Konomi\Storage\Storage; + +final class StorageOverrideModule implements ExtendingModule +{ + use ModuleClassNameIdTrait; + + public static function new(): self + { + return new self(); + } + + private function __construct() {} + + public function extensions(): array + { + return [ + Storage::class => static fn (Storage $original, ContainerInterface $c): Storage + => new MetaStorage(), + ]; + } +} +``` + +Add the extending module after Konomi's bundled modules so its extension is applied: + +```php +\SpaghettiDojo\Konomi\package()->addModule(StorageOverrideModule::new()); +``` + +Because there is a single shared service, one extension swaps the driver for **both** the Post and User repositories. The custom driver receives the `Axis` on every call and can branch on it when the backend differs per axis (as the `MetaStorage` reference does). + +## Notes + +- `groupKey` is operation scope, not row data — `write` always replaces the entire `($axis, $id, $groupKey)` slice. +- `TableStorage` enforces an axis invariant: the column corresponding to the call's `Axis` is forced to `$id` regardless of the value carried on a `Record`. Custom drivers should preserve that invariant if they rely on the same shape. +- Validation is the driver's responsibility at the read boundary: `read` must return only well-formed `Record`s. Repositories assume the type is the contract. diff --git a/konomi.php b/konomi.php index 79a8f8d..62b46fe 100644 --- a/konomi.php +++ b/konomi.php @@ -18,33 +18,54 @@ namespace SpaghettiDojo\Konomi; +use Inpsyde\Modularity\Properties\PluginProperties; +use SpaghettiDojo\Konomi\Activation\ActivationExecute; + +function autoload(string $projectRoot): void +{ + $autoloadFile = "{$projectRoot}/vendor/autoload.php"; + if (!\is_readable($autoloadFile)) { + return; + } + require_once $autoloadFile; +} + +autoload(__DIR__); + +$package = package(); +/** @var PluginProperties $properties */ +$properties = $package->properties(); + +$modules = [ + Configuration\Module::new($properties, '/sources/Icons/icons'), + Database\Module::new(), + Storage\Module::new(), + ApiFetch\Module::new($properties), + Icons\Module::new($properties), + User\Module::new(), + Post\Module::new(), + Rest\Module::new(), + Blocks\Module::new($properties), + Activation\Module::new(), +]; + +foreach ($modules as $module) { + $package->addModule($module); +} + +$package->build(); + +// Register the plugin lifecycle hooks at top level scope, after the container is +// built but before the deferred `boot()` on `plugins_loaded`. This is early +// enough for WordPress to catch the activation hook, which fires before +// `plugins_loaded` for the plugin being activated. +$activation = $package->container()->get(ActivationExecute::class); +$activation->prepare($modules); +$activation->registerActivationLogic(); +$activation->registerDeactivationLogic(); +$activation->registerUninstallLogic(); + add_action( 'plugins_loaded', - static function () { - // phpcs:disable Squiz.PHP.InnerFunctions.NotAllowed - function autoload(string $projectRoot): void - { - // phpcs:enable Squiz.PHP.InnerFunctions.NotAllowed - - $autoloadFile = "{$projectRoot}/vendor/autoload.php"; - if (!\is_readable($autoloadFile)) { - return; - } - require_once $autoloadFile; - } - - autoload(__DIR__); - $package = package(); - $properties = $package->properties(); - - $package - ->addModule(Configuration\Module::new($properties, '/sources/Icons/icons')) - ->addModule(ApiFetch\Module::new($properties)) - ->addModule(Icons\Module::new($properties)) - ->addModule(User\Module::new()) - ->addModule(Post\Module::new()) - ->addModule(Rest\Module::new()) - ->addModule(Blocks\Module::new($properties)) - ->boot(); - } + static fn () => $package->boot() ); diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml b/openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/design.md b/openspec/changes/archive/2026-05-08-custom-table-storage/design.md new file mode 100644 index 0000000..a3d484d --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/design.md @@ -0,0 +1,77 @@ +## Context + +Konomi stores user interactions (reactions, bookmarks) as serialized arrays in WordPress meta tables (`wp_usermeta` and `wp_postmeta`). The previous change extracted `Storage` interfaces from concrete implementations, creating `Post\Storage` and `User\Storage` interfaces with `MetaStorage` implementations. This change introduces a single custom database table and `$wpdb`-backed storage implementations behind those interfaces. + +Current data model: +- **Post storage**: `wp_postmeta` key `_konomi_items.{group}` → serialized array `[userId => [[entityId, entityType]]]` +- **User storage**: `wp_usermeta` key `_konomi_items.{group}` → serialized array `[[entityId, entityType], ...]` + +The `Storage` interface contract is: `read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool`. + +## Goals / Non-Goals + +**Goals:** +- Single custom DB table for structured, queryable storage of user interactions +- `$wpdb`-based storage implementations for both Post and User domains +- Schema management with full lifecycle (activation + uninstall) + +**Non-Goals:** +- Changing the `Storage` interface contract — `TableStorage` must conform to existing `read`/`write` signatures +- Removing `MetaStorage` — kept but no longer wired by default +- Adding query capabilities beyond what `Storage` interface exposes + +## Decisions + +### 1. `$wpdb` for database operations + +Use WordPress `$wpdb` directly for all custom table operations. + +**Why**: No external dependency needed. `$wpdb` provides prepared statements via `$wpdb->prepare()`, handles table prefix, and is always available. `dbDelta()` handles schema creation/updates. + +### 2. Single shared table + +`{prefix}konomi_interactions` — stores all user-post interactions. + +**Schema:** +| Column | Type | Description | +|--------|------|-------------| +| id | BIGINT UNSIGNED AUTO_INCREMENT | PK | +| entity_id | BIGINT UNSIGNED NOT NULL | Entity being interacted with (currently always a post ID) | +| user_id | BIGINT UNSIGNED NOT NULL | WordPress user ID — who interacted | +| entity_type | VARCHAR(50) NOT NULL | Entity type string (`Item::type()`) | +| group_key | VARCHAR(50) NOT NULL | ItemGroup value (reaction, bookmark) | +| UNIQUE | (entity_id, user_id, group_key) | One interaction per user per group per entity | + +**Why one table**: Post and User storage represent the same relationship queried from different sides. `Post\TableStorage` queries `WHERE entity_id = ?` (where the entity ID is the post ID passed in), `User\TableStorage` queries `WHERE user_id = ?`. Same data, different access patterns. One table is simpler to manage, fewer moving parts. + +**Naming — `entity_id` over `post_id`**: In the current code `Item::id()` always equals the post ID, but the domain concept is "entity being interacted with". Using `entity_id` keeps the schema neutral if non-post entities are added later. `Post\TableStorage` passes the post ID as `entity_id` since today they are the same value. + +**Index strategy**: The UNIQUE constraint on `(post_id, user_id, group_key)` covers post-side queries. An additional index on `(user_id, group_key)` covers user-side queries efficiently. + +### 3. TableStorage implements existing Storage interface + +Two `TableStorage` classes still needed — `Post\TableStorage` and `User\TableStorage` — because the return formats differ: +- Post: `[userId => [[entityId, entityType]]]` +- User: `[[entityId, entityType], ...]` + +Both query the same table but filter and serialize differently. `write()` replaces all rows for the given (id, key) combination in a transaction using `$wpdb->query('START TRANSACTION')`. + +The `key` parameter encodes `{base}.{group}` — `TableStorage` parses the group from the key to filter by `group_key` column. + +### 4. Schema management via `dbDelta` with full lifecycle + +A `Database\SchemaManager` class owns both table creation and destruction. Creation uses `dbDelta()` (idempotent). Destruction uses `DROP TABLE IF EXISTS`. Both triggered from the `Database` package's modularity `Module`: +- `register_activation_hook` → `SchemaManager::create()` +- `register_uninstall_hook` → `SchemaManager::drop()` + +Using `register_uninstall_hook` over `uninstall.php` because `SchemaManager` needs `$wpdb` and table prefix knowledge already encapsulated in the package. WordPress loads the plugin for `register_uninstall_hook`, giving access to the container. + +The `Database` package lives at `sources/Database/` and may contain one or more `Module.php` files following the established pattern (see `sources/Post/`, `sources/User/`). + +## Risks / Trade-offs + +- **`$wpdb` typing** → Weak return types, stringly-typed queries. Mitigate with thorough tests and `$wpdb->prepare()` for all parameterized queries +- **`dbDelta` quirks** → Specific SQL formatting requirements (e.g., two spaces after PRIMARY KEY). Mitigate by following WordPress codex guidelines exactly +- **Rollback complexity** → Switching back to `MetaStorage` in Module wiring is sufficient +- **Key parsing in TableStorage** → `write(id, "base.group", data)` requires parsing the key to extract group. Fragile if key format changes. Mitigate with `StorageKey` being the single source of key formatting +- **User-side query performance** → UNIQUE index is `(post_id, user_id, group_key)`, not optimal for `WHERE user_id = ?`. Mitigate with additional index on `(user_id, group_key)` diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md b/openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md new file mode 100644 index 0000000..0c690b8 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/proposal.md @@ -0,0 +1,25 @@ +## Why + +Konomi stores user reactions and bookmarks as serialized arrays in `wp_usermeta`/`wp_postmeta`. This blocks structured queries, search, aggregation, and integration with WP Data Views. The `Storage` interface extracted in the previous change provides the seam — now we need the custom table backend to use it. + +## What Changes + +- Create a single custom DB table for all interactions on plugin activation +- Implement `TableStorage` classes for both `Post\Storage` and `User\Storage` interfaces, reading/writing to the same table via `$wpdb` (queried from different sides) +- Wire `TableStorage` as the default storage backend in both modules +- **BREAKING**: Storage uses custom table instead of meta; MetaStorage kept but no longer wired by default + +## Capabilities + +### New Capabilities +- `table-schema`: Database schema management — single table creation, lifecycle (activate/uninstall), and `Database` package +- `table-storage`: TableStorage implementations for Post and User that read/write to the shared custom table + +### Modified Capabilities + +## Impact + +- `Post\Module`, `User\Module`: Rewired to use `TableStorage` instead of `MetaStorage` +- Plugin activation hook: Must create custom table +- Plugin uninstall hook: Drops custom table +- Test suite: New integration tests for TableStorage diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-schema/spec.md b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-schema/spec.md new file mode 100644 index 0000000..a7ed989 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-schema/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Interactions table schema +The system SHALL create a `{prefix}konomi_interactions` table with columns: `id` (BIGINT UNSIGNED AUTO_INCREMENT PK), `entity_id` (BIGINT UNSIGNED NOT NULL), `user_id` (BIGINT UNSIGNED NOT NULL), `entity_type` (VARCHAR(50) NOT NULL), `group_key` (VARCHAR(50) NOT NULL), with a UNIQUE constraint on `(entity_id, user_id, group_key)` and an additional index on `(user_id, group_key)`. + +#### Scenario: Table created on activation +- **WHEN** the plugin is activated +- **THEN** the `{prefix}konomi_interactions` table SHALL exist with the defined schema and indexes + +#### Scenario: Table creation is idempotent +- **WHEN** the plugin is activated and the table already exists +- **THEN** no error SHALL occur and the existing table SHALL remain unchanged + +### Requirement: Schema manager orchestration +The `Database` package SHALL provide a `Database\SchemaManager` class that creates the table using `dbDelta()`. It SHALL be invoked via `register_activation_hook`. + +#### Scenario: Table created on activation +- **WHEN** `SchemaManager::create()` runs +- **THEN** the `konomi_interactions` table SHALL be created with all columns and indexes + +### Requirement: Table cleanup on uninstall +The `Database\SchemaManager` SHALL provide a `drop()` method to drop the custom table. It SHALL be invoked via `register_uninstall_hook`. + +#### Scenario: Table dropped on uninstall +- **WHEN** the plugin is uninstalled +- **THEN** the `konomi_interactions` table SHALL be dropped + +#### Scenario: Uninstall with missing table +- **WHEN** the plugin is uninstalled and the table does not exist +- **THEN** no error SHALL occur (DROP TABLE IF EXISTS) + +### Requirement: Database package +The `Database` package SHALL provide a modularity `Module` class that registers `SchemaManager`, hooks activation and uninstall lifecycle events, and exposes the `$wpdb` table name (with prefix) as a service for consumption by storage implementations. + +#### Scenario: Services registered +- **WHEN** the `Database` package modularity `Module` is loaded +- **THEN** `SchemaManager` and table name service SHALL be available in the container + +#### Scenario: Lifecycle events hooked +- **WHEN** the `Database` package modularity `Module` runs +- **THEN** it SHALL register `SchemaManager::create` on `register_activation_hook` and `SchemaManager::drop` on `register_uninstall_hook` diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-storage/spec.md new file mode 100644 index 0000000..f510d3e --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/specs/table-storage/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and key +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and key +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` matches the parsed group + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: Key parsing extracts group +Both `TableStorage` implementations SHALL parse the `$key` parameter (format `base.group`) to extract the `group_key` value for database queries. + +#### Scenario: Key with valid format +- **WHEN** key is `_konomi_items.reaction` +- **THEN** the group_key used in queries SHALL be `reaction` + +#### Scenario: Key with invalid format +- **WHEN** key contains no dot separator +- **THEN** `read` SHALL return empty array and `write` SHALL return `false` + +### Requirement: Module wiring uses TableStorage +`Post\Module` and `User\Module` SHALL wire `TableStorage` as the implementation for `Storage` interface in their service definitions. + +#### Scenario: Post module wires TableStorage +- **WHEN** the Post module registers services +- **THEN** `Post\Storage::class` SHALL resolve to a `Post\TableStorage` instance + +#### Scenario: User module wires TableStorage +- **WHEN** the User module registers services +- **THEN** `User\Storage::class` SHALL resolve to a `User\TableStorage` instance diff --git a/openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md b/openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md new file mode 100644 index 0000000..f87f2d4 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-custom-table-storage/tasks.md @@ -0,0 +1,29 @@ +## 1. Database Package & Schema + +- [x] 1.1 Create `sources/Database/` package with a modularity `Module` class that registers `SchemaManager` and table name service (prefixed `konomi_interactions`) +- [x] 1.2 Create `Database\SchemaManager` with `create()` method using `dbDelta()` to define `konomi_interactions` table schema (UNIQUE on `(entity_id, user_id, group_key)`, index on `(user_id, group_key)`) +- [x] 1.3 Add `drop()` method to `SchemaManager` using `DROP TABLE IF EXISTS` +- [x] 1.4 Hook `SchemaManager::create` into `register_activation_hook` and `SchemaManager::drop` into `register_uninstall_hook` from the `Database` package's modularity `Module` +- [x] 1.5 Register the `Database` package modularity `Module` in the plugin bootstrap module list +- [x] 1.6 Write integration tests for schema creation and drop (table exists, correct columns, indexes, clean drop) + +## 2. TableStorage Implementations + +- [x] 2.1 Create `Post\TableStorage` implementing `Post\Storage` — `read()` queries `konomi_interactions` WHERE `entity_id` via `$wpdb` and returns `[userId => [[entityId, entityType]]]` format +- [x] 2.2 Implement `Post\TableStorage::write()` — transactional delete+insert for given post and group +- [x] 2.3 Implement key parsing logic to extract `group_key` from `base.group` format key string +- [x] 2.4 Create `User\TableStorage` implementing `User\Storage` — `read()` queries `konomi_interactions` WHERE `user_id` via `$wpdb` and returns `[[entityId, entityType], ...]` format +- [x] 2.5 Implement `User\TableStorage::write()` — transactional delete+insert for given user and group +- [x] 2.6 Add input validation (id <= 0, empty key, invalid key format) returning empty array / false +- [x] 2.7 Write unit tests for both TableStorage classes (read, write, empty data, invalid input, transactional behavior) + +## 3. Module Wiring + +- [x] 3.1 Update `Post\Module` to wire `Post\TableStorage` as `Post\Storage::class` implementation (injecting table name from `Database` package) +- [x] 3.2 Update `User\Module` to wire `User\TableStorage` as `User\Storage::class` implementation (injecting table name from `Database` package) +- [x] 3.3 Verify existing repository tests still pass with new wiring + +## 4. Verification + +- [x] 4.1 Run full test suite — all existing tests pass +- [x] 4.2 Run PHPStan static analysis — no new errors diff --git a/openspec/changes/archive/2026-05-08-extract-storage-interface/design.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/design.md new file mode 100644 index 0000000..a235266 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-extract-storage-interface/design.md @@ -0,0 +1,80 @@ +# Design: Extract Storage Interface + +## Overview + +Convert `Post\Storage` and `User\Storage` from concrete classes to interfaces. Current implementations become `MetaStorage`. No behavior change — pure structural refactor. + +## Current State + +``` +Post\Storage (concrete) +├── read(int $id, string $key): array → get_post_meta() +└── write(int $id, string $key, array $data): bool → update_post_meta() + +User\Storage (concrete) +├── read(int $id, string $key): array → get_user_meta() +└── write(int $id, string $key, array $data): bool → update_user_meta() +``` + +Both registered in their Module under `Storage::class` as service ID. + +## Target State + +``` +Post\Storage (interface) +├── read(int $id, string $key): array +└── write(int $id, string $key, array $data): bool + +Post\MetaStorage implements Post\Storage +├── read() → get_post_meta() +└── write() → update_post_meta() + +User\Storage (interface) +├── read(int $id, string $key): array +└── write(int $id, string $key, array $data): bool + +User\MetaStorage implements User\Storage +├── read() → get_user_meta() +└── write() → update_user_meta() +``` + +## Decisions + +### Interface contract matches current signatures exactly + +`read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool`. No changes to what flows through. Repository code untouched. + +### Service ID stays `Storage::class` + +Modules register under `Storage::class` (the interface). Factory returns `MetaStorage::new()`. Consumers resolve `Storage::class` — decoupled from implementation. + +```php +// Post\Module — before +Storage::class => static fn () => Storage::new(), + +// Post\Module — after +Storage::class => static fn () => MetaStorage::new(), +``` + +### Static factory `new()` moves to MetaStorage + +Interface has no constructor. `MetaStorage::new()` replaces `Storage::new()`. + +### `@internal` stays on MetaStorage + +Both current Storage classes are `@internal`. MetaStorage keeps that. Interface itself is not `@internal` — it's the public contract. + +## Files Changed + +| File | Change | +|------|--------| +| `sources/Post/Storage.php` | Concrete class → interface | +| `sources/Post/MetaStorage.php` | New file, current impl moved here | +| `sources/User/Storage.php` | Concrete class → interface | +| `sources/User/MetaStorage.php` | New file, current impl moved here | +| `sources/Post/Module.php` | `Storage::new()` → `MetaStorage::new()` | +| `sources/User/Module.php` | `Storage::new()` → `MetaStorage::new()` | +| `tests/unit/php/Post/StorageTest.php` | Rename to MetaStorageTest, update references | +| `tests/unit/php/User/StorageTest.php` | Rename to MetaStorageTest, update references | +| `tests/integration/php/PostRepositoryTest.php` | Update if references Storage directly | +| `tests/integration/php/UserRepositoryTest.php` | Update if references Storage directly | diff --git a/openspec/changes/archive/2026-05-08-extract-storage-interface/proposal.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/proposal.md new file mode 100644 index 0000000..130cf31 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-extract-storage-interface/proposal.md @@ -0,0 +1,36 @@ +# Extract Storage Interface + +## Summary + +Extract `Post\Storage` and `User\Storage` concrete classes into interfaces, renaming current implementations to `MetaStorage`. This creates the seam needed to swap storage backends (e.g., Dbal custom tables) without changing Repository code. + +## Motivation + +Konomi stores user reactions and bookmarks as serialized arrays in `wp_usermeta`/`wp_postmeta`. This blocks structured queries, search, and integration with WP Data Views. Moving to custom tables requires a storage abstraction — which doesn't exist yet since `Storage` classes are concrete with hardcoded `get_*_meta`/`update_*_meta` calls. + +## Scope + +- `Post\Storage` becomes an interface with `read(int, string): array` and `write(int, string, array): bool` +- `Post\MetaStorage` implements `Post\Storage` with current `get_post_meta`/`update_post_meta` logic +- `User\Storage` becomes an interface with same contract +- `User\MetaStorage` implements `User\Storage` with current `get_user_meta`/`update_user_meta` logic +- `Post\Module` and `User\Module` updated to wire `MetaStorage` where `Storage` was constructed +- All tests updated to reflect rename + +## Non-goals + +- No new storage backends (Dbal comes in a follow-up change) +- No signature changes — interface matches current `read`/`write` exactly +- No data migration + +## Risks + +- Low risk. Pure refactor, no behavior change. Existing tests validate. + +## Part of + +This is Step 1 of moving storage from meta to custom tables: +1. **Extract Storage interface** ← this change +2. Add Dbal dependency + table creation +3. Implement `DbalStorage` +4. (Later) Normalize to single table diff --git a/openspec/changes/archive/2026-05-08-extract-storage-interface/tasks.md b/openspec/changes/archive/2026-05-08-extract-storage-interface/tasks.md new file mode 100644 index 0000000..72ca3d3 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-extract-storage-interface/tasks.md @@ -0,0 +1,6 @@ +# Tasks: Extract Storage Interface + +- [x] Extract Post\Storage interface and create Post\MetaStorage: Convert `sources/Post/Storage.php` to interface, create `sources/Post/MetaStorage.php` with current impl, update `sources/Post/Module.php` +- [x] Extract User\Storage interface and create User\MetaStorage: Convert `sources/User/Storage.php` to interface, create `sources/User/MetaStorage.php` with current impl, update `sources/User/Module.php` +- [x] Update tests: Rename/update Storage tests to MetaStorage, check integration tests for direct Storage references +- [x] Verify: Run `composer cs`, `composer analysis`, `composer tests` diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md new file mode 100644 index 0000000..d263d4e --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/design.md @@ -0,0 +1,91 @@ +## Context + +`custom-table-storage` introduced two `Storage` backends backed by the shared `konomi_interactions` table. Both `TableStorage` classes only need the `group_key` portion of the key (e.g. `"reaction"`) to populate the `group_key` column. `StorageKey::for()` builds a composite `"_konomi_items.reaction"` string, then `Database\StorageKeyParser` re-extracts the trailing segment. The composite shape exists solely because the original `MetaStorage` backend uses the full string as a `meta_key`. + +This change relocates the base prefix to where it is actually used: inside `MetaStorage`. `StorageKey` becomes a group-only sanitizer/validator. + +## Goals + +- One source of truth for the `meta_key` shape: `MetaStorage` itself. +- Remove the parser roundtrip for `TableStorage`. +- Keep the `Storage::read/write(int $id, string $key, ...)` signature unchanged so downstream consumers and the interface contract stay stable. +- Keep `Repository` code unchanged in shape — still calls `$this->key->for($group)`. + +## Non-Goals + +- Merging `Post\TableStorage` and `User\TableStorage` into a single class. +- Collapsing `Post\StorageKey` and `User\StorageKey`. +- Changing the `konomi_interactions` schema or column names. +- Touching `MetaStorage` callers (still wired off, kept for future toggle). + +## Decisions + +### Decision 1: `StorageKey::for()` returns sanitized group only + +```php +final class StorageKey +{ + public static function new(): self { return new self(); } + final private function __construct() {} + + public function for(ItemGroup $group): string + { + $value = $group->value; + if ($value === '') { + throw new \InvalidArgumentException('Group value cannot be empty'); + } + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); + } + return $sanitized; + } +} +``` + +Rationale: `StorageKey` keeps its role as the trusted producer of the key string, but the string it produces is the bare group. Sanitization rules drop the `.` from the allowlist since it is no longer part of the output. + +### Decision 2: `MetaStorage` owns the base prefix + +```php +class MetaStorage implements Storage +{ + private const BASE = '_konomi_items'; + + public function read(int $id, string $key): array + { + $metaKey = self::BASE . '.' . $key; + // ...get_post_meta / get_user_meta with $metaKey + } + public function write(int $id, string $key, array $data): bool + { + $metaKey = self::BASE . '.' . $key; + // ...update_post_meta / update_user_meta with $metaKey + } +} +``` + +Rationale: the base is a `meta_key` namespacing concern. It does not belong on the `Storage` contract or on the domain key. + +### Decision 3: `TableStorage` uses `$key` directly as `group_key` + +```php +// Drop StorageKeyParser dependency. +// $key already equals the group, e.g. "reaction". +WHERE group_key = $key +``` + +### Decision 4: `StorageKeyParser` deleted + +No remaining callers once `TableStorage` stops parsing. Service registration in `Database\Module` removed. + +## Risks / Trade-offs + +- **Stored data shape unchanged**: `MetaStorage` still produces `_konomi_items.reaction` meta_keys; `TableStorage` still stores `reaction` in `group_key`. No data migration needed. +- **Backward compatibility**: `Storage::read/write` signature unchanged. Internal call sites update. +- **Future merge of Post/User logic**: explicitly out of scope; no design lock-in either way. +- **Test updates**: TableStorage tests currently pass `'_konomi_items.reaction'` as the key; they update to `'reaction'`. Functional MetaStorage tests (if any) keep asserting the composite meta_key written to the DB. + +## Migration + +None. Brand-new project, no production data, no archived state to convert. diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md new file mode 100644 index 0000000..d9b7390 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/proposal.md @@ -0,0 +1,27 @@ +## Why + +After introducing `TableStorage`, the `StorageKey::for()` output (`"_konomi_items.reaction"`) is immediately re-parsed by `StorageKeyParser` to recover the trailing group segment, which is the only piece TableStorage actually needs. The base prefix is a `MetaStorage` concern — the meta_key column shape — not a domain concern. Encoding it into the `Storage` contract forces a roundtrip and an extra class (`StorageKeyParser`) whose only job is to undo what `StorageKey` did. + +## What Changes + +- `StorageKey::for(ItemGroup): string` returns the sanitized group segment only (e.g. `"reaction"`), no base prefix. +- `StorageKey::new()` takes no arguments. Drop the `$base` constructor parameter from both `Post\StorageKey` and `User\StorageKey`. +- `Post\MetaStorage` and `User\MetaStorage` own a private `BASE` constant (`'_konomi_items'`) and prepend it on write / strip it on read when composing the `meta_key`. +- `Post\TableStorage` and `User\TableStorage` use the incoming `string $key` directly as `group_key`. Drop the `StorageKeyParser` dependency. +- Delete `Database\StorageKeyParser` and unregister it from `Database\Module`. +- Drop the `'_konomi_items'` argument from `StorageKey` service factories in `Post\Module` and `User\Module`. +- Update unit and functional tests: parser tests removed; TableStorage tests pass `'reaction'` as the key instead of `'_konomi_items.reaction'`. + +## Impact + +- Affected capabilities: `table-storage` (key shape change) +- Affected code: + - `sources/Post/StorageKey.php`, `sources/User/StorageKey.php` + - `sources/Post/MetaStorage.php`, `sources/User/MetaStorage.php` + - `sources/Post/TableStorage.php`, `sources/User/TableStorage.php` + - `sources/Post/Module.php`, `sources/User/Module.php` + - `sources/Database/Module.php` + - `sources/Database/StorageKeyParser.php` (deleted) + - Tests: parser tests deleted, TableStorage tests adjusted +- No DB schema change. No migration. Brand-new project, no production data. +- No public surface change observable to plugin consumers — the `Storage::read/write` signatures keep `string $key`; only the value passed in changes shape. diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md new file mode 100644 index 0000000..260fe4e --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/specs/table-storage/spec.md @@ -0,0 +1,101 @@ +## MODIFIED Requirements + +### Requirement: Post TableStorage implements Post\Storage +The system SHALL provide a `Post\TableStorage` class that implements `Post\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, treating the post ID as `entity_id`. + +#### Scenario: Read existing post interactions +- **WHEN** `read($postId, $key)` is called with a valid post ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[userId => [[entityId, entityType]]]` matching rows in the table WHERE `entity_id` matches `$postId` and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($postId, $key)` is called and no rows exist for that post and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($postId, $key)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write post interactions +- **WHEN** `write($postId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that post and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($postId, $key, [])` is called +- **THEN** it SHALL delete all rows for that post and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($postId, $key, $data)` is called with `$postId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +### Requirement: User TableStorage implements User\Storage +The system SHALL provide a `User\TableStorage` class that implements `User\Storage` interface, reading from and writing to `{prefix}konomi_interactions` via `$wpdb`, filtering by `user_id` and returning `entity_id` values as item IDs. + +#### Scenario: Read existing user interactions +- **WHEN** `read($userId, $key)` is called with a valid user ID and a non-empty `$key` +- **THEN** it SHALL return an array in the format `[[entityId, entityType], ...]` matching rows in the table WHERE `user_id` matches and `group_key` equals `$key` + +#### Scenario: Read with no data +- **WHEN** `read($userId, $key)` is called and no rows exist for that user and group +- **THEN** it SHALL return an empty array + +#### Scenario: Read with invalid ID +- **WHEN** `read($userId, $key)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return an empty array + +#### Scenario: Write user interactions +- **WHEN** `write($userId, $key, $data)` is called with valid data +- **THEN** it SHALL replace all rows for that user and group in the table and return `true` + +#### Scenario: Write empty data clears rows +- **WHEN** `write($userId, $key, [])` is called +- **THEN** it SHALL delete all rows for that user and group and return `true` + +#### Scenario: Write with invalid ID +- **WHEN** `write($userId, $key, $data)` is called with `$userId <= 0` or empty `$key` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write is transactional +- **WHEN** `write()` deletes existing rows and inserts new ones +- **THEN** both operations SHALL execute within a single database transaction + +## REMOVED Requirements + +### Requirement: Key parsing extracts group +**Reason**: `StorageKey::for()` now returns the bare sanitized group, so `TableStorage` consumes `$key` directly as the `group_key` value. The `Database\StorageKeyParser` class is deleted. +**Migration**: Callers that produced keys via `StorageKey::for()` continue to work without changes — the produced string is now the group itself. Any test passing a literal `"_konomi_items.reaction"` must be updated to `"reaction"`. + +## ADDED Requirements + +### Requirement: StorageKey produces sanitized group +`Post\StorageKey` and `User\StorageKey` SHALL accept an `ItemGroup` and return its `value` after validating that it contains only `[a-z0-9_]` characters and is non-empty. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no base +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage owns the meta_key base prefix +`Post\MetaStorage` and `User\MetaStorage` SHALL define a private `BASE` constant equal to `'_konomi_items'` and SHALL compose the WordPress `meta_key` as `{BASE}.{$key}` on every read and write. + +#### Scenario: Write composes the meta_key +- **WHEN** `MetaStorage::write($id, "reaction", $data)` is called +- **THEN** the underlying `update_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` + +#### Scenario: Read composes the meta_key +- **WHEN** `MetaStorage::read($id, "reaction")` is called +- **THEN** the underlying `get_*_meta` call SHALL use `meta_key` `"_konomi_items.reaction"` diff --git a/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md b/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md new file mode 100644 index 0000000..7ee461a --- /dev/null +++ b/openspec/changes/archive/2026-05-08-simplify-storage-key/tasks.md @@ -0,0 +1,33 @@ +## 1. StorageKey simplification + +- [x] 1.1 Update `Post\StorageKey`: drop `$base` ctor param, change `for()` to return sanitized `ItemGroup->value` (allowlist `[a-z0-9_]`, throw on invalid) +- [x] 1.2 Update `User\StorageKey`: same change as `Post\StorageKey` +- [x] 1.3 Update unit tests for both `StorageKey` classes (valid group, invalid chars, empty group) + +## 2. MetaStorage owns base prefix + +- [x] 2.1 Add `private const BASE = '_konomi_items'` to `Post\MetaStorage`; prepend on read/write when composing `meta_key` +- [x] 2.2 Add `private const BASE = '_konomi_items'` to `User\MetaStorage`; prepend on read/write when composing `meta_key` +- [x] 2.3 Update or add unit tests confirming `meta_key` shape + +## 3. TableStorage drops parser + +- [x] 3.1 Remove `StorageKeyParser` dependency from `Post\TableStorage`; use `$key` directly as `group_key` +- [x] 3.2 Remove `StorageKeyParser` dependency from `User\TableStorage`; use `$key` directly as `group_key` +- [x] 3.3 Update functional `Post\TableStorage` tests: pass `'reaction'` as key +- [x] 3.4 Update functional `User\TableStorage` tests: pass `'reaction'` as key +- [x] 3.5 Update unit `TableStorage` tests for both sides + +## 4. Module wiring & deletions + +- [x] 4.1 Update `Post\Module`: drop `'_konomi_items'` arg from `StorageKey` factory; drop `StorageKeyParser` from `TableStorage` factory +- [x] 4.2 Update `User\Module`: same as Post +- [x] 4.3 Update `Database\Module`: unregister `StorageKeyParser` service +- [x] 4.4 Delete `sources/Database/StorageKeyParser.php` +- [x] 4.5 Delete `tests/unit/php/Database/StorageKeyParserTest.php` + +## 5. Verification + +- [x] 5.1 Run full test suite — all existing tests pass +- [x] 5.2 Run PHPStan static analysis — no new errors +- [x] 5.3 Grep for any leftover `StorageKeyParser` references diff --git a/openspec/changes/archive/2026-05-08-split-table-storage-query-build/.openspec.yaml b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/archive/2026-05-08-split-table-storage-query-build/design.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/design.md new file mode 100644 index 0000000..642e653 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/design.md @@ -0,0 +1,50 @@ +## Context + +`Post\TableStorage` and `User\TableStorage` each implement two public methods (`read`, `write`) that braid together input validation, raw `$wpdb` SQL calls, and array coercion/payload shaping. The two classes are ~90% identical and total ~250 lines. Before any future cross-class refactor (shared storage module, bulk inserts), the internal structure of each class needs to clearly separate "talk to DB" from "shape data". + +## Goals / Non-Goals + +**Goals:** +- Inside each `TableStorage`, isolate `$wpdb` calls into private helpers and isolate array coercion/payload building into separate private helpers. +- Keep public method signatures, return shapes, transactional semantics, and validation behavior identical. +- Leave existing quirks intact (Post `write` only persists `$rawItems[0]`; Post `read` returns one row per user via the `[userId => [[…]]]` shape). + +**Non-Goals:** +- No shared base class, trait, or new module across Post/User. Tracked for a later iteration. +- No bulk multi-VALUES INSERT. Tracked for a later iteration. +- No change to SQL statements, table layout, or service wiring. +- No new tests added as part of this change (test suite untouched). + +## Decisions + +### Split each method into two private helpers + +`read()` becomes: +- `fetchRows(int $id, string $key): array` — only `$wpdb->prepare` + `$wpdb->get_results`. Returns the raw `ARRAY_A` rows (or `[]` on failure). +- `mapRows(array $rows): array` — pure coercion/shaping into the documented return type. + +`write()` becomes: +- `buildPayloads(array $data): array` — pure coercion. Returns a list of associative payloads ready for `$wpdb->insert` (Post payload includes `user_id`; User payload omits it because `$id` is the user ID and is injected by the persist helper). +- `persist(int $id, string $key, array $payloads): bool` — owns the transaction, the DELETE, and the per-row INSERT loop. + +Rationale: smallest change that lets the eye see "DB call" vs "data shape" at a glance, and that lets a future test target either side without `$wpdb` in scope. Alternative considered: extract a single private `query()` helper. Rejected — it would still leave coercion tangled inside `read`/`write`. + +### Keep `$wpdb` access inline (no abstraction) + +Helpers continue to call `global $wpdb` directly. No injected gateway, no `Database` wrapper. The proposal explicitly scopes shared abstractions to a later iteration. + +### Preserve all existing quirks + +- Post `buildPayloads` reads only `$rawItems[0]` per user, matching current semantics confirmed during exploration. +- Post `mapRows` writes `$result[$userId] = [[…]]`, the list-of-list shape kept for downstream consistency. +- All silent-skip branches (non-array row, non-scalar value, non-positive ID, empty type) remain. + +### Public surface frozen + +`read(int $id, string $key): array` and `write(int $id, string $key, array $data): bool` keep their exact signatures, return types, and error semantics. Guard checks (`$id <= 0 || $key === ''`) stay in the public methods so the helpers can assume valid inputs. + +## Risks / Trade-offs + +- [Behavioral drift while refactoring] → Reuse exact existing coercion logic in `buildPayloads`/`mapRows` line-for-line. No tests cover these paths today, so manual diff review is the safety net. +- [Helper proliferation] → Two extra private methods per class (4 per file). Acceptable given the clarity gain; no helpers shared across classes yet. +- [Post `[0]`-only quirk hidden behind helper] → Mitigation: short inline comment in `Post\TableStorage::buildPayloads` noting the behavior is intentional, so a future reader does not "fix" it. diff --git a/openspec/changes/archive/2026-05-08-split-table-storage-query-build/proposal.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/proposal.md new file mode 100644 index 0000000..37adae1 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/proposal.md @@ -0,0 +1,26 @@ +## Why + +`Post\TableStorage` and `User\TableStorage` mix three concerns inside `read()` and `write()`: input guards, raw SQL execution against `$wpdb`, and array coercion/payload building. The methods are hard to read and test, and any future bulk-insert or shared-storage refactor will compound the mess. Splitting query execution from data shaping now keeps the change minimal while unblocking later work. + +## What Changes + +- Refactor `Post\TableStorage::read()` into private helpers: one for the SQL fetch, one for mapping rows into the return shape. +- Refactor `Post\TableStorage::write()` into private helpers: one for building row payloads from input, one for executing the DELETE + INSERTs inside the transaction. +- Apply the same split to `User\TableStorage::read()` and `User\TableStorage::write()`. +- No public API change. No SQL change. No behavior change. Existing quirks preserved (Post `write` indexes `[0]` only; Post `read` collapses one row per user). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +None. Public behavior of `table-storage` is unchanged; this is an internal restructure of the existing implementation. + +## Impact + +- Affected files: `sources/Post/TableStorage.php`, `sources/User/TableStorage.php`. +- No DB schema, no service wiring, no consumer change. +- Unblocks future iterations: extracting a shared storage module across Post/User, and switching per-row inserts to a single multi-VALUES insert. diff --git a/openspec/changes/archive/2026-05-08-split-table-storage-query-build/specs/table-storage/spec.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/specs/table-storage/spec.md new file mode 100644 index 0000000..b472626 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/specs/table-storage/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Post TableStorage write persists only the first item per user +`Post\TableStorage::write()` SHALL, for each `userId => $rawItems` entry in the input array, persist only `$rawItems[0]` and ignore any additional indices. This codifies existing behavior and prevents regressions during the internal split between payload building and DB execution. + +#### Scenario: Multiple items under the same user +- **WHEN** `write($postId, $key, [$userId => [[$entityId1, $type1], [$entityId2, $type2]]])` is called with valid IDs +- **THEN** only one row SHALL be inserted for `$userId` using `$entityId1` and `$type1`, and the second tuple SHALL be ignored + +#### Scenario: Single item under a user +- **WHEN** `write($postId, $key, [$userId => [[$entityId, $type]]])` is called with valid IDs +- **THEN** exactly one row SHALL be inserted for `$userId` with `$entityId` and `$type` + +### Requirement: Post TableStorage read returns one row per user +`Post\TableStorage::read()` SHALL return at most one entry per `userId` in the result map, even when multiple rows exist in the table for the same `(entity_id, group_key, user_id)` combination. The list-of-list shape `[userId => [[entityId, entityType]]]` is preserved for downstream consumer consistency. + +#### Scenario: Multiple rows for the same user +- **WHEN** `read($postId, $key)` is called and the underlying table contains more than one row for the same `user_id` +- **THEN** the returned map SHALL contain a single entry per `userId`, each holding a one-element list with the last seen `[entityId, entityType]` tuple + +#### Scenario: Distinct users +- **WHEN** `read($postId, $key)` is called and rows exist for distinct user IDs +- **THEN** the returned map SHALL contain one entry per distinct `userId` diff --git a/openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md new file mode 100644 index 0000000..baf1930 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-split-table-storage-query-build/tasks.md @@ -0,0 +1,25 @@ +## 1. Refactor Post\TableStorage + +- [x] 1.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard, returning the raw `ARRAY_A` rows or `[]` +- [x] 1.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[userId => [[entityId, entityType]]]` +- [x] 1.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [x] 1.4 Extract `buildPayloads(array $data, string $key): array` private helper from `write()` that returns a list of associative payloads `['entity_id', 'user_id', 'entity_type', 'group_key']`, preserving the `$rawItems[0]`-only behavior and silent skips; add a one-line comment noting the `[0]`-only behavior is intentional +- [x] 1.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop, and `COMMIT`/`ROLLBACK` semantics +- [x] 1.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data, $key)`, `return $this->persist($id, $key, $payloads)` + +## 2. Refactor User\TableStorage + +- [x] 2.1 Extract `fetchRows(int $id, string $key): array` private helper from `read()` containing only the `$wpdb->prepare` + `$wpdb->get_results` call and the `is_array` guard +- [x] 2.2 Extract `mapRows(array $rows): array` private helper from `read()` containing the row coercion loop that produces `[entityId => [entityId, entityType]]` +- [x] 2.3 Reduce `read()` body to: input guard, `$rows = $this->fetchRows($id, $key)`, `return $this->mapRows($rows)` +- [x] 2.4 Extract `buildPayloads(array $data): array` private helper from `write()` that returns a list of partial payloads `['entity_id', 'entity_type']`, preserving silent skips for non-array entries and zero/empty values +- [x] 2.5 Extract `persist(int $id, string $key, array $payloads): bool` private helper from `write()` that owns `START TRANSACTION`, the prepared `DELETE`, the per-row `$wpdb->insert` loop (injecting `user_id => $id` and `group_key => $key` at insert time), and `COMMIT`/`ROLLBACK` semantics +- [x] 2.6 Reduce `write()` body to: input guard, `$payloads = $this->buildPayloads($data)`, `return $this->persist($id, $key, $payloads)` + +## 3. Verify + +- [x] 3.1 Run `composer run-script phpcs` (or equivalent) and resolve any new violations introduced by the refactor +- [x] 3.2 Run `composer run-script phpstan` (or equivalent) and resolve any new type errors +- [x] 3.3 Run the existing test suite and confirm no regressions +- [x] 3.4 Manually diff `read()` and `write()` behavior against the pre-refactor version: inputs in → identical SQL executed, identical return values, identical transactional behavior +- [x] 3.5 Run `openspec validate split-table-storage-query-build --strict` and confirm the change validates diff --git a/openspec/changes/archive/2026-07-05-extract-shared-storage-module/design.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/design.md new file mode 100644 index 0000000..27275d4 --- /dev/null +++ b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/design.md @@ -0,0 +1,174 @@ +# Design: Extract Shared Storage Module + +## Overview + +Collapse the duplicated `Post\*` and `User\*` persistence stack into a single `sources/Storage/` namespace. The shared layer exposes a DTO-typed interface; per-module behavior is reduced to a single configuration parameter (`Axis`) injected via the DI container. + +## Decisions + +### DTO over array shapes + +`Storage` interface returns `list` and accepts `list` for writes. `Record` is a readonly value object: + +```php +final class Record { + public function __construct( + public readonly int $entityId, + public readonly int $userId, + public readonly string $entityType, + ) {} +} +``` + +This eliminates the diverging `array` (User) vs `array>` (Post) shapes and the `RawDataAssert` validators that exist only because the shape was opaque. + +### Axis enum, constructor-injected + +`TableStorage` is parameterized by `Axis`, set once at construction. Repos do not pass `Axis` per call — they receive an already-bound `Storage` instance from the container. + +```php +enum Axis { + case Entity; + case User; + + public function column(): string { + return match ($this) { + self::Entity => 'entity_id', + self::User => 'user_id', + }; + } +} +``` + +`column()` is the single point of axis-driven dispatch. `match` exhaustiveness guarantees that any new Axis case forces a code update at every consumption site. + +### `groupKey` stays a method parameter + +Group is operation scope, not row data. The DB transaction in `write` is bounded by `(axis_id, group_key)` — DELETE + INSERTs in one TX. Putting `groupKey` on `Record` would either: +- require a uniformity check in `write` (all records share the same key), or +- allow heterogeneous keys, breaking the single-scope-per-call invariant. + +Method parameter is simpler and matches current semantics. + +### Boundary validation, no `RawDataAssert` + +`TableStorage::read` maps each raw row through a private `mapRow(array $row): ?Record`. Malformed rows return `null` and are skipped. The repo always receives a clean `list`. No separate validator class exists — the type is the contract. + +### Flatten Post in-memory shape + +The Post-side `[[id, type]]` envelope was introduced for symmetry with the User shape, never for actual multi-item-per-user storage. Confirmed by inspection: every site treats it as a single-tuple list (`$rawItems[0]`). On disk, each row is one tuple; the envelope existed only in PHP memory. + +Removing it: +- collapses `Post\RawDataAssert` and `User\RawDataAssert` into the same logic (both validate `array`) +- eliminates the "first-item-per-user persisted" quirk currently codified as a SHALL requirement — the registry, keyed by `userId`, already enforces uniqueness +- enables a single `Record` DTO contract for both modules + +No DB migration is required because the on-disk format was never nested. + +### `MetaStorage` becomes documentation + +`MetaStorage` is not wired in any production container; both modules bind `Storage::class` to `TableStorage`. It exists only as legacy fallback for environments where custom tables may not be viable. + +Container-based DI already supports per-environment swap of the bound implementation. We move the swap pattern into `docs/storage-drivers.md`: +- the `Storage` interface contract (DTO-based) +- a reference `MetaStorage` implementation (post + user variants) +- a container override snippet showing how to rebind `Storage::class` from a consumer plugin or site + +Users who need meta-backed storage copy the example and adapt. Core no longer carries unused code. + +### `StorageKey` has no `base` field + +Current `StorageKey` (both copies) returns the sanitized group string with no prefix. The `_konomi_items` prefix lives only inside `MetaStorage`. With `MetaStorage` removed, the prefix concern is driver-local: any consumer copying the documented `MetaStorage` example embeds their own base inside that class. + +The shared `StorageKey` therefore takes no constructor arguments. Behavior is identical to the current per-module copies, just deduplicated. + +### Per-module DI binding + +Each `Module::services()` binds `Storage::class` (the interface) to a `TableStorage` instance configured with the module's axis: + +```php +// Post\Module +Storage::class => static fn (ContainerInterface $c) => + TableStorage::new( + $c->get(Database\InteractionsTable::class), + Axis::Entity, + ), + +// User\Module +Storage::class => static fn (ContainerInterface $c) => + TableStorage::new( + $c->get(Database\InteractionsTable::class), + Axis::User, + ), +``` + +Repos resolve `Storage::class` from the per-module container scope and remain axis-blind. + +### Registry snapshot/rollback preserved + +The current `loadItems → snapshot → mutate → write → rollback-on-failure` flow stays in both repos: + +``` +$this->loadItems(...); +$snapshot = clone $this->registry; +$this->prepareDataToStore(...); // mutates registry +$stored = $this->storage->write(...); +$stored or $this->registry->replace($snapshot); +``` + +`TableStorage::write` keeps DB-side TX rollback. The two layers are complementary: DB rollback returns disk to pre-write state; repo rollback returns memory to pre-mutation state. Refactor leaves this structurally intact. + +## Files Changed + +| File | Change | +|------|--------| +| `sources/Storage/Storage.php` | New — DTO-typed interface | +| `sources/Storage/TableStorage.php` | New — axis-bound impl, boundary validation, transactional write | +| `sources/Storage/Record.php` | New — readonly DTO | +| `sources/Storage/Axis.php` | New — enum with `column()` | +| `sources/Storage/StorageKey.php` | New — moved from `Post\StorageKey` (identical) | +| `sources/Post/Storage.php` | Deleted | +| `sources/Post/TableStorage.php` | Deleted | +| `sources/Post/MetaStorage.php` | Deleted | +| `sources/Post/RawDataAssert.php` | Deleted | +| `sources/Post/StorageKey.php` | Deleted | +| `sources/Post/Repository.php` | Use shared `Storage`, `Record`, `StorageKey`; flatten serialize; drop `RawDataAssert` import | +| `sources/Post/Module.php` | Wire `Storage::class` to shared `TableStorage(Axis::Entity)` | +| `sources/User/Storage.php` | Deleted | +| `sources/User/TableStorage.php` | Deleted | +| `sources/User/MetaStorage.php` | Deleted | +| `sources/User/RawDataAssert.php` | Deleted | +| `sources/User/StorageKey.php` | Deleted | +| `sources/User/Repository.php` | Use shared `Storage`, `Record`, `StorageKey`; emit `list`; drop `RawDataAssert` import | +| `sources/User/Module.php` | Wire `Storage::class` to shared `TableStorage(Axis::User)` | +| `tests/unit/php/Post/TableStorageTest.php` | Move/rewrite under `tests/unit/php/Storage/`; assert `list` results | +| `tests/unit/php/User/TableStorageTest.php` | Merged into `tests/unit/php/Storage/TableStorageTest.php` (axis-parameterized) | +| `tests/unit/php/Post/StorageKeyTest.php` | Move to `tests/unit/php/Storage/StorageKeyTest.php` | +| `tests/unit/php/User/StorageKeyTest.php` | Deleted (covered by shared test) | +| `tests/unit/php/Post/StorageTest.php` | Deleted (`MetaStorage` no longer in code) | +| `tests/unit/php/User/StorageTest.php` | Deleted (`MetaStorage` no longer in code) | +| `tests/integration/php/PostRepositoryTest.php` | Update fixtures to flat shape; use shared types | +| `tests/integration/php/UserRepositoryTest.php` | Use shared types; signature is unchanged otherwise | +| `docs/storage-drivers.md` | New — Storage interface contract, `MetaStorage` reference impl, container override how-to | + +## Open Questions + +None at design time. Outstanding work is implementation-only. + +## Alternatives considered + +### Single shared abstract base + two thin subclasses + +`AbstractTableStorage` holds the TX skeleton; `Post\PostTableStorage` and `User\UserTableStorage` override the column. Rejected: still ships two classes for zero behavioral difference once `Axis` exists. Constructor-injected `Axis` collapses them into one impl with a `match` expression on a single line. + +### Axis as method parameter + +Pass `Axis` to `read`/`write` instead of injecting via constructor. Rejected: forces every caller (i.e. every repo) to know about `Axis`. With constructor injection, repos remain axis-blind and the per-module DI binding is the single point of axis configuration. + +### Keep `MetaStorage` in code + +Wire it as a fallback or expose a setting to swap. Rejected: it isn't wired anywhere today and has no callers. The container itself is the swap mechanism — a documented example serves the "I need meta backend on this host" use case without dragging unused production code through future refactors. + +### Keep nested `[[id, type]]` envelope + +Preserve it in case of future multi-item-per-user storage. Rejected: speculative, never implemented, requires keeping a divergent `RawDataAssert` and the "first-item-per-user persisted" quirk. If multi-item storage is ever needed, a new change can introduce it explicitly. diff --git a/openspec/changes/archive/2026-07-05-extract-shared-storage-module/proposal.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/proposal.md new file mode 100644 index 0000000..37e187c --- /dev/null +++ b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/proposal.md @@ -0,0 +1,309 @@ +# Extract Shared Storage Module + +## Summary + +Extract the persistence layer duplicated across `Post` and `User` modules into a single shared `sources/Storage/` namespace. Replace opaque array shapes with a `Record` DTO, parameterize the table-side filter column with an `Axis` enum injected at construction, drop `MetaStorage` from production code (preserved as documented reference impl), flatten the vestigial `[[id, type]]` envelope on the Post side, and delete `RawDataAssert` in favor of boundary validation in `TableStorage`. Repositories stay module-specific and consume the shared `Storage` interface. + +## Motivation + +The current code has two near-identical persistence stacks: + +- `Post\Storage` / `User\Storage` interfaces — identical except namespace +- `Post\TableStorage` / `User\TableStorage` — ~95% duplicated; differ only in filter column (`entity_id` vs `user_id`) and result-map keying +- `Post\MetaStorage` / `User\MetaStorage` — parallel; differ only in WP meta function (`get/update_post_meta` vs `get/update_user_meta`) +- `Post\StorageKey` / `User\StorageKey` — near-identical +- `Post\RawDataAssert` / `User\RawDataAssert` — diverge only because Post uses a nested `[[id, type]]` envelope that was never multi-element in practice + +Both `TableStorage` impls write to the same `{prefix}konomi_interactions` table. The duplication is a copy-paste artifact of two domains evolving in parallel, not a real shape divergence. The Post nested envelope was introduced for symmetry with the User shape, never for actual multi-item-per-user storage. Removing it eliminates the "first-item-per-user persisted" quirk codified in the existing spec. + +`MetaStorage` is no longer wired in any production container — both modules bind `Storage::class` to `TableStorage`. It exists only as legacy fallback kept "in case." A documented swap pattern via DI override serves users on hosts where custom tables are not viable, without dragging unused code through future refactors. + +## Scope + +### Added (`sources/Storage/`) + +- `Storage` interface, DTO-typed: `read(int $id, string $groupKey): list`, `write(int $id, string $groupKey, list $records): bool` +- `Record` readonly DTO: `entityId`, `userId`, `entityType` +- `Axis` enum: `Entity | User`; `column(): string` returns `"entity_id"` or `"user_id"` +- `TableStorage` implementing `Storage`; constructor takes `InteractionsTable` + `Axis`; validates rows at boundary (skips malformed); preserves transactional write +- `StorageKey` (moved from per-module copies), no `base` field, `for(ItemGroup): string` sanitizes and returns the group value + +### Changed + +- `Post\Repository` consumes `Storage`; `serializeData` emits flat `list` (one per `userId`); registry-based uniqueness replaces dedupe quirk +- `User\Repository` consumes `Storage`; `serializeData` emits flat `list` (one per `entityId`) +- `Post\Module` binds `Storage::class` → `TableStorage::new($table, Axis::Entity)` +- `User\Module` binds `Storage::class` → `TableStorage::new($table, Axis::User)` + +### Removed + +- `sources/Post/Storage.php`, `sources/Post/TableStorage.php`, `sources/Post/MetaStorage.php`, `sources/Post/RawDataAssert.php`, `sources/Post/StorageKey.php` +- `sources/User/Storage.php`, `sources/User/TableStorage.php`, `sources/User/MetaStorage.php`, `sources/User/RawDataAssert.php`, `sources/User/StorageKey.php` +- Post nested `[[id, type]]` in-memory envelope and the "first-item-per-user persisted" requirement (vestigial; never written nested to disk) + +### Documentation + +- `docs/storage-drivers.md`: documents `Storage` interface contract, provides `MetaStorage` reference impl (post + user variants) and a container-override snippet for swapping the bound `Storage::class` driver per environment + +## Non-goals + +- No DB schema migration. The on-disk row format is already flat per row; the nested envelope existed only in PHP memory. +- No change to repository semantics (find/save/all signatures, action hooks, registry snapshot/rollback flow). +- No new domains or new `Axis` cases; enum has exactly the two cases needed today. +- No change to `Database\InteractionsTable` or `Database\SchemaManager`. +- No new storage backends shipped in core. + +## Risks + +- Type-narrowing the `Storage` interface from `array` to `list` is a breaking change at the iface level. Mitigated by replacing all callers in this same change (only two: `Post\Repository`, `User\Repository`). +- Test fixtures using the nested `[[id, type]]` literal must be updated alongside. Mitigated by full test pass requirement (`composer cs`, `composer analysis`, `composer tests`) before merge. +- Registry snapshot/rollback invariant must be preserved through repo refactor. Mitigated by leaving `loadItems → snapshot → mutate → write → rollback-on-failure` flow structurally intact in both repos. + +## Part of + +Continuation of the Storage refactor sequence: +1. Extract Storage interface (archived 2026-05-08) +2. Custom table storage (archived 2026-05-08) +3. Simplify StorageKey (archived 2026-05-08) +4. Split TableStorage query/build (archived 2026-05-08) +5. **Extract shared Storage module** ← this change + +--- + +## Final Agreement (with artifacts) + +### Agreements + +1. **New shared module** `sources/Storage/` — persistence primitives only. +2. **DTO contract**: `Record { entityId, userId, entityType }` readonly. Replaces all `array` shapes. +3. **`groupKey`** = method param (scope), not on Record. +4. **`Axis` enum** (`Entity | User`) injected via `TableStorage` constructor. Repos axis-blind. `match` exhaustiveness enforces lockstep on new Axis cases. +5. **Repos stay module-specific.** `Post\Repository` + `User\Repository` keep intent, registry, save/find. Consume shared `Storage` iface. +6. **`StorageKey`** moves to shared as-is. No `base` field. Single class, ctor takes nothing, `for(ItemGroup)` returns sanitized group value. Prefix concerns are driver-local. +7. **`MetaStorage` dropped from code.** Documented in `docs/storage-drivers.md` as reference impl + container override how-to. +8. **Flatten Post in-memory shape** `[[id, type]]` → `[id, type]`. Vestigial nested envelope removed. +9. **Post quirk eliminated.** Map-keyed-by-userId guarantees uniqueness. No dedupe step. +10. **`RawDataAssert` deleted from both modules.** Validation collapses into `TableStorage` row mapping; malformed rows skipped at boundary. DTO list IS the validated contract. +11. **Registry snapshot/rollback preserved.** Repo-side `clone $registry` before mutation, `replace(snapshot)` on write failure. DB-side TX rollback in `TableStorage::write` is complementary. Refactor leaves this flow untouched — invariant to preserve. +12. **Per-module DI**: `Module::services()` binds `Storage::class` → `TableStorage::new($table, Axis::{Entity|User})`. +13. **Drop**: `Post\Storage`, `Post\TableStorage`, `Post\MetaStorage`, `Post\RawDataAssert`, `Post\StorageKey`, `User\Storage`, `User\TableStorage`, `User\MetaStorage`, `User\RawDataAssert`, `User\StorageKey`. + +### Structure + +``` +sources/ +├── Storage/ ← NEW (shared) +│ ├── Storage.php interface (DTO-typed) +│ ├── TableStorage.php impl, axis-bound; validates rows at boundary +│ ├── Record.php readonly DTO +│ ├── Axis.php enum: Entity | User; +column():string +│ └── StorageKey.php sanitize group → string; no base +├── Post/ +│ ├── Repository.php consumes Storage; flat serialization +│ ├── Module.php Storage::class → TableStorage(Axis::Entity) +│ ├── ItemRegistry.php +│ ├── ItemRegistryKey.php +│ └── Post.php +├── User/ +│ ├── Repository.php consumes Storage +│ ├── Module.php Storage::class → TableStorage(Axis::User) +│ ├── Item.php / ItemFactory.php / ItemGroup.php / ItemRegistry.php / User.php / ... +│ └── (no Storage / TableStorage / MetaStorage / RawDataAssert / StorageKey) +└── Database/ + └── InteractionsTable.php unchanged + +docs/ +└── storage-drivers.md ← NEW: MetaStorage example + DI override snippet +``` + +### Component diagram + +``` + ┌────────────────────────────────────────────────────┐ + │ sources/Storage/ │ + │ │ + │ ┌──────────────┐ ┌──────────────┐ │ + │ │ Storage │ │ Record │ │ + │ │ «interface» │ │ «DTO» │ │ + │ └──────▲───────┘ └──────────────┘ │ + │ │ implements │ + │ ┌──────┴────────┐ ┌──────────────┐ │ + │ │ TableStorage │──▶│ Axis «enum» │ │ + │ │ axis-bound │ │ Entity│User │ │ + │ │ validates │ └──────────────┘ │ + │ │ rows at │ ┌──────────────┐ │ + │ │ boundary │ │ StorageKey │ │ + │ └──────┬────────┘ └──────────────┘ │ + └─────────┼─────────────────────▲────────────────┬───┘ + │ │ │ + ┌──────────┴──────────┐ ┌──────┴───────────┐ │ + │ Post\Module │ │ User\Module │ │ + │ Storage::class → │ │ Storage::class → │ │ + │ TableStorage( │ │ TableStorage( │ │ + │ Axis::Entity) │ │ Axis::User) │ │ + └──────────┬──────────┘ └─────────┬────────┘ │ + │ │ │ + ┌──────────▼─────────┐ ┌──────────▼────────┐ │ + │ Post\Repository │ │ User\Repository │ │ + │ flat serialize │ │ flat serialize │ │ + │ snapshot+rollback │ │ snapshot+rollback │ │ + └────────────────────┘ └───────────────────┘ │ + │ + ┌─────────────────────────────────┘ + ▼ + ┌──────────────────┐ + │ InteractionsTable│ (wp_konomi_*) + └──────────────────┘ + + ┌──────────────────────────────────────────────┐ + │ docs/storage-drivers.md │ + │ - Storage interface contract │ + │ - MetaStorage reference impl (post + user) │ + │ - Container override snippet │ + └──────────────────────────────────────────────┘ +``` + +### UML class + +``` +┌────────────────────────────────────────────────┐ +│ «interface» Storage │ +├────────────────────────────────────────────────┤ +│ +read(id:int, groupKey:string): list │ +│ +write(id:int, groupKey:string, │ +│ records: list): bool │ +└──────────────────▲─────────────────────────────┘ + │ implements + ┌──────────┴────────────┐ + │ TableStorage │ + ├───────────────────────┤ + │ -table: InteractionsTable │ + │ -axis : Axis │ + ├───────────────────────┤ + │ +new(table, axis) │ + │ +read(id, groupKey) │ + │ +write(id, groupKey, │ + │ records) │ + │ -mapRow(row): ?Record │ ← boundary validation + └───────────────────────┘ + + ┌────────────────────┐ ┌──────────────────────┐ + │ «enum» Axis │ │ Record «readonly» │ + ├────────────────────┤ ├──────────────────────┤ + │ Entity │ │ +entityId : int │ + │ User │ │ +userId : int │ + ├────────────────────┤ │ +entityType: string │ + │ +column():string │ └──────────────────────┘ + └────────────────────┘ + + ┌────────────────────────┐ + │ StorageKey │ + ├────────────────────────┤ + │ +new(): self │ + │ +for(group): string │ sanitize + return + └────────────────────────┘ + + ┌──────────────────────────────┐ + │ Post\Repository │ + ├──────────────────────────────┤ + │ -storage : Storage │ TableStorage(Axis::Entity) + │ -storageKey: StorageKey │ + │ -registry : ItemRegistry │ + │ -itemFactory: User\ItemFactory│ + ├──────────────────────────────┤ + │ +find(entityId, group) │ + │ +save(item, user) │ + │ -serialize(): list │ flat; one Record per userId + └──────────────────────────────┘ + + ┌──────────────────────────────┐ + │ User\Repository │ + ├──────────────────────────────┤ + │ -storage : Storage │ TableStorage(Axis::User) + │ -storageKey: StorageKey │ + │ -registry : ItemRegistry │ + │ -itemFactory: ItemFactory │ + ├──────────────────────────────┤ + │ +find(user, id, group) │ + │ +all(user, group) │ + │ +save(user, item) │ + │ -serialize(): list │ + └──────────────────────────────┘ +``` + +### Read flow + +``` +Post\Repository::find(postId, group) + │ + │ key = storageKey.for(group) + ▼ +Storage::read(postId, key) ◀── TableStorage(Axis::Entity) + │ + ▼ +TableStorage::read(id, key) + │ col = axis.column() // "entity_id" + │ SELECT entity_id, user_id, entity_type + │ FROM %i WHERE {col}=%d AND group_key=%s + │ + │ for each row: + │ mapRow(row) → ?Record // null = malformed, skip + ▼ + list + │ + ▼ +Repository::loadItems + for each Record r: + item = factory.create(r.entityId, r.entityType, true, group) + item.isValid() && registry.set(postId, r.userId, item) + │ + ▼ + registry.all(postId, group) + + +User flow: same shape; TableStorage(Axis::User); col="user_id"; +repo keys registry by r.entityId. +``` + +### Write flow + +``` +Post\Repository::save(item, user) + │ + │ loadItems() // ensure hydrated + │ snapshot = clone registry + │ item.isActive() + │ ? registry.set(postId, user.id, item) + │ : registry.unset(postId, user.id, group) + │ + │ records = serialize(postId, group): + │ ┌─ for each registry.all(postId, group) as $userId => $item: + │ │ records[] = new Record(item.id, $userId, item.type) + │ └─ return records // list + ▼ +Storage::write(postId, key, records) ◀── TableStorage(Axis::Entity) + │ + ▼ +TableStorage::write(id, key, records) + │ col = axis.column() + │ BEGIN TX + │ DELETE FROM %i WHERE {col}=id AND group_key=key + │ on fail → ROLLBACK; return false + │ for each Record r: + │ force r.{axisField} = id // axis invariant + │ INSERT (entity_id, user_id, entity_type, group_key) + │ on fail → ROLLBACK; return false + │ COMMIT + ▼ + bool + │ + ▼ +Repository + true → fire `konomi.post.collection.save` (Post) + or `konomi.user.repository.save-successfully` (User) + false → registry.replace(snapshot) ← memory rollback + + +User-side write: same shape; TableStorage(Axis::User); +serialize foreach registry.all(user, group) as $item → Record(item.id, user.id, item.type). +``` diff --git a/openspec/changes/archive/2026-07-05-extract-shared-storage-module/specs/table-storage/spec.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/specs/table-storage/spec.md new file mode 100644 index 0000000..7bde71a --- /dev/null +++ b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/specs/table-storage/spec.md @@ -0,0 +1,153 @@ +## ADDED Requirements + +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(int $id, string $groupKey): list` and `write(int $id, string $groupKey, array $records): bool`. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. + +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties + +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `(id, groupKey)` + +### Requirement: Record DTO shape +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Record` readonly value object with three public readonly properties: `entityId: int`, `userId: int`, `entityType: string`. The class SHALL be `final`. + +#### Scenario: Construction +- **WHEN** `new Record($entityId, $userId, $entityType)` is invoked +- **THEN** the resulting instance SHALL expose those values via readonly public properties of the declared types + +### Requirement: Axis enum drives table-side filter column +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Axis` enum with cases `Entity` and `User`, and a method `column(): string` returning `"entity_id"` for `Entity` and `"user_id"` for `User`. Adding any new case SHALL force `match` updates at every consumption site. + +#### Scenario: Entity axis maps to entity_id +- **WHEN** `Axis::Entity->column()` is called +- **THEN** it SHALL return `"entity_id"` + +#### Scenario: User axis maps to user_id +- **WHEN** `Axis::User->column()` is called +- **THEN** it SHALL return `"user_id"` + +### Requirement: Shared TableStorage is axis-bound at construction +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking `Database\InteractionsTable` and `Axis` via constructor. The configured `Axis` SHALL determine which column is used as the filter for both `read` and `write`. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. + +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($id, $groupKey)` is called on a `TableStorage` constructed with a given `Axis` +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` + +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database + +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid + +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction + +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($id, $groupKey, $records)` is called with a configured `Axis` +- **THEN** for each `Record`, the field corresponding to `Axis::column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) + +#### Scenario: Write empty list clears scope +- **WHEN** `write($id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `(axis.column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` + +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` + +### Requirement: Module wiring binds shared Storage with the module's Axis +`Post\Module` and `User\Module` SHALL bind `SpaghettiDojo\Konomi\Storage\Storage::class` in their `services()` definitions to a `TableStorage` instance constructed with the appropriate `Axis`. + +#### Scenario: Post module wires Axis::Entity +- **WHEN** the Post module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` + +#### Scenario: User module wires Axis::User +- **WHEN** the User module registers services +- **THEN** `Storage\Storage::class` SHALL resolve to `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` + +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. + +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group + +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group + +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation + +### Requirement: Shared StorageKey deduplication +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\StorageKey` class that produces sanitized group strings. The class SHALL replace per-module `Post\StorageKey` and `User\StorageKey`. The constructor SHALL take no arguments. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no arguments +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to rebind `Storage\Storage::class` from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. + +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a reference `MetaStorage` example for both post and user backends along with a DI override snippet + +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` + +## REMOVED Requirements + +### Requirement: Post TableStorage implements Post\Storage +**Reason**: `Post\TableStorage` is replaced by the single shared `Storage\TableStorage`, configured with `Axis::Entity` via constructor. The `Post\Storage` interface no longer exists; `Storage\Storage` is the shared interface. +**Migration**: Update `Post\Module::services()` to bind `Storage\Storage::class` to `Storage\TableStorage::new($table, Axis::Entity)`. Repository code that previously typed against `Post\Storage` SHALL type against `Storage\Storage`. + +### Requirement: Post TableStorage write persists only the first item per user +**Reason**: The vestigial `[[id, type]]` envelope is removed. The registry is keyed by `userId`, so uniqueness per user is already enforced before the records list is built. There is no longer a place where multiple items per user can be expressed in input. +**Migration**: No code change required; behavior of writing one row per user is preserved by virtue of the registry shape. + +### Requirement: Post TableStorage read returns one row per user +**Reason**: Reads return a `list` directly. Per-user collation is the repository's concern (it builds the registry keyed by `userId`); the storage layer no longer shapes results into a per-user map. +**Migration**: `Post\Repository::loadItems()` iterates the returned `list` and calls `registry.set($postId, $r->userId, $item)` once per record — last-write-wins for duplicates. + +### Requirement: User TableStorage implements User\Storage +**Reason**: `User\TableStorage` is replaced by the single shared `Storage\TableStorage`, configured with `Axis::User` via constructor. +**Migration**: Update `User\Module::services()` to bind `Storage\Storage::class` to `Storage\TableStorage::new($table, Axis::User)`. Repository code that previously typed against `User\Storage` SHALL type against `Storage\Storage`. + +### Requirement: Module wiring uses TableStorage +**Reason**: Replaced by the shared variant that wires `Storage\Storage::class` to `Storage\TableStorage` with the appropriate `Axis`. +**Migration**: See "Module wiring binds shared Storage with the module's Axis" above. + +### Requirement: StorageKey produces sanitized group +**Reason**: Replaced by the shared `Storage\StorageKey` (same behavior, deduplicated). Per-module `Post\StorageKey` and `User\StorageKey` are deleted. +**Migration**: Update consumers to import `SpaghettiDojo\Konomi\Storage\StorageKey`. + +### Requirement: MetaStorage owns the meta_key base prefix +**Reason**: `MetaStorage` is removed from production code and survives only as a documented reference implementation in `docs/storage-drivers.md`. The `_konomi_items` base is therefore an example concern, not a runtime requirement. +**Migration**: Sites that depend on the meta-backed driver SHALL register their own `MetaStorage`-style implementation via container override, copying the documented example. diff --git a/openspec/changes/archive/2026-07-05-extract-shared-storage-module/tasks.md b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/tasks.md new file mode 100644 index 0000000..074805b --- /dev/null +++ b/openspec/changes/archive/2026-07-05-extract-shared-storage-module/tasks.md @@ -0,0 +1,13 @@ +# Tasks: Extract Shared Storage Module + +- [x] Create `sources/Storage/` namespace with `Storage` interface (DTO-typed), `Record` DTO, `Axis` enum (with `column()`), `StorageKey` (no base), and `TableStorage` (axis-bound, boundary validation in `mapRow`, transactional `write`). +- [x] Update `Post\Repository` to consume shared `Storage` + `Record` + `StorageKey`; flatten `serializeData` to emit `list` (one per `userId`); drop `RawDataAssert` import and any nested `[[id, type]]` references; preserve `loadItems → snapshot → mutate → write → rollback-on-failure` flow. +- [x] Update `User\Repository` to consume shared `Storage` + `Record` + `StorageKey`; emit `list` (one per `entityId`); drop `RawDataAssert` import. +- [x] Update `Post\Module::services()` to bind `Storage::class` → `TableStorage::new($table, Axis::Entity)`. +- [x] Update `User\Module::services()` to bind `Storage::class` → `TableStorage::new($table, Axis::User)`. +- [x] Delete `sources/Post/Storage.php`, `sources/Post/TableStorage.php`, `sources/Post/MetaStorage.php`, `sources/Post/RawDataAssert.php`, `sources/Post/StorageKey.php`. +- [x] Delete `sources/User/Storage.php`, `sources/User/TableStorage.php`, `sources/User/MetaStorage.php`, `sources/User/RawDataAssert.php`, `sources/User/StorageKey.php`. +- [x] Move and parameterize tests: create `tests/unit/php/Storage/TableStorageTest.php` (axis-parameterized), `tests/unit/php/Storage/StorageKeyTest.php`, `tests/unit/php/Storage/RecordTest.php`. Delete old per-module `TableStorageTest`, `StorageKeyTest`, `StorageTest` (MetaStorage) under `tests/unit/php/Post/` and `tests/unit/php/User/`. +- [x] Update integration tests `tests/integration/php/PostRepositoryTest.php` + `tests/integration/php/UserRepositoryTest.php`: flatten Post fixtures, use shared `Record` and `Storage` types, confirm registry rollback path. +- [x] Write `docs/storage-drivers.md`: document `Storage` interface contract; include reference `MetaStorage` impl for post + user backends; show container override snippet for swapping `Storage::class` binding. +- [x] Verify: run `composer cs`, `composer analysis`, `composer tests`. Confirm all pass. diff --git a/openspec/changes/single-storage-service/.openspec.yaml b/openspec/changes/single-storage-service/.openspec.yaml new file mode 100644 index 0000000..8cceb8d --- /dev/null +++ b/openspec/changes/single-storage-service/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-08 diff --git a/openspec/changes/single-storage-service/design.md b/openspec/changes/single-storage-service/design.md new file mode 100644 index 0000000..589534c --- /dev/null +++ b/openspec/changes/single-storage-service/design.md @@ -0,0 +1,42 @@ +## Context + +`docs/storage-drivers.md` documents driver swapping via re-registering each module's `Repository::class` binding — which contradicts Inpsyde Modularity's `ExtendingModule` mechanism for modifying already-registered services. That mechanism needs the driver to be a real container service, but today each `Repository` factory builds `TableStorage` inline. + +Investigation established that there is a **single** `konomi_interactions` table (one `InteractionsTable` value object, one `SchemaManager`), and that the two storage instances differ **only** by `Storage\Axis`: the axis selects which column `$id` filters on (`entity_id` vs `user_id`) in the read `WHERE` / write `DELETE`, and which column a write forces to `$id`. Everything else is identical. The two instances exist purely because the `Storage` interface hides the axis at the call site, forcing it to be baked into the instance. + +## Goals / Non-Goals + +**Goals:** +- One shared `Storage` service in the container under `Storage::class`. +- Driver swap via a single `ExtendingModule` extension on `Storage::class`. +- Preserve all existing storage behaviour (validation, transactional write, axis invariant, `Record` mapping). + +**Non-Goals:** +- Changing the `konomi_interactions` schema or `SchemaManager`. +- Changing `Repository` public behaviour or the `Record` / `StorageKey` contracts. +- Supporting genuinely different backends per axis via separate services. + +## Decisions + +- **Lift `Axis` into the method signature** (`read(Axis, id, key)`, `write(Axis, id, key, records)`) rather than keeping it on the instance. This is the minimal change that lets a single instance serve both domains. Alternative — two marker-interface services (`EntityStorage`/`UserStorage`) — was rejected: two of everything for an axis-only difference. Alternative — a `Criteria` value object — was rejected as YAGNI given the shared schema. +- **Repositories pass their axis inline** at their two call sites (each repository is inherently axis-specific by namespace). No new `Repository` constructor parameter; the injected dependency stays typed `Storage\Storage`. +- **New `Storage\Module` (ServiceModule)** owns the single `Storage::class` binding (`TableStorage::new($interactionsTable)`), registered in `konomi.php` after `Database\Module`. A dedicated module keeps storage as its own domain and gives consumers a stable id to extend. Alternative — registering in `Database\Module` — was rejected as conflating schema management with the storage driver. +- **`Axis` becomes public contract** (drops `@internal`) since custom drivers now receive it. + +## Risks / Trade-offs + +- [A consumer wants different backends for post vs user] → One service cannot bind two backends; the custom driver branches on the `Axis` argument it receives per call. Acceptable given identical schema/logic. +- [Interface signature change breaks any existing `Storage` implementers] → In-tree there is only `TableStorage`; the reference `MetaStorage` lives in docs and is updated in the same change. Flagged BREAKING in the proposal. + +## Migration Plan + +1. Change the `Storage` interface and `TableStorage` (axis-less ctor, axis-per-call). +2. Add `Storage\Module`; register in `konomi.php`. +3. Update `Post\Module` / `User\Module` to consume `Storage::class`; update repositories to pass their axis. +4. Update tests and `docs/storage-drivers.md`. + +Rollback is a straight revert of the branch; no data migration is involved (schema unchanged). + +## Open Questions + +None. diff --git a/openspec/changes/single-storage-service/proposal.md b/openspec/changes/single-storage-service/proposal.md new file mode 100644 index 0000000..449434b --- /dev/null +++ b/openspec/changes/single-storage-service/proposal.md @@ -0,0 +1,27 @@ +## Why + +The documented way to swap the persistence driver — re-registering each module's `Repository::class` binding — contradicts Inpsyde Modularity's mechanism for modifying already-registered services ([Extending Module](https://inpsyde.github.io/modularity/Modules/#extendingmodule)), which requires the driver to be a real container service. Today it is not: each `Repository` factory constructs `TableStorage` inline, and two instances exist only because the `Storage` interface hides the `Axis` at the call site (there is a single shared `konomi_interactions` table; the two instances differ solely by axis). + +## What Changes + +- **BREAKING** `Storage::read()`/`write()` gain an `Axis` first parameter; the axis is lifted out of the instance and passed per call. +- `TableStorage` becomes a single axis-less concrete constructed with only `Database\InteractionsTable`; it is no longer axis-bound at construction. +- A new `Storage\Module` registers one shared `Storage::class` service in the container; `Post\Module`/`User\Module` stop constructing `TableStorage` inline and consume `Storage::class`. +- `Post\Repository`/`User\Repository` pass their axis (`Axis::Entity` / `Axis::User`) on every storage call. +- The driver is swapped via a single `ExtendingModule` extension on `Storage::class` instead of overriding `Repository::class`; `docs/storage-drivers.md` and its reference driver are reworked accordingly (single `Axis`-branching `MetaStorage`). +- `Axis` becomes part of the public storage contract (drops `@internal`). + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `table-storage`: `Storage` interface methods take `Axis` per call; `TableStorage` is a single axis-less service (not axis-bound at construction); module wiring registers one shared `Storage::class` service instead of inline per-`Repository` construction; repositories pass their axis; the driver is swapped via `ExtendingModule` on `Storage::class` rather than by overriding `Repository::class`; the reference `MetaStorage` doc becomes a single axis-branching implementation. + +## Impact + +- Code: `sources/Storage/Storage.php`, `sources/Storage/TableStorage.php`, `sources/Storage/Axis.php`, new `sources/Storage/Module.php`, `sources/Post/Module.php`, `sources/User/Module.php`, `sources/Post/Repository.php`, `sources/User/Repository.php`, `konomi.php`. +- Docs: `docs/storage-drivers.md`. +- Tests: `tests/unit/php/Storage/TableStorageTest.php` and new coverage for `Storage\Module` binding + `ExtendingModule` override. +- No schema change: `SchemaManager` and the `konomi_interactions` table are untouched. diff --git a/openspec/changes/single-storage-service/specs/table-storage/spec.md b/openspec/changes/single-storage-service/specs/table-storage/spec.md new file mode 100644 index 0000000..6539576 --- /dev/null +++ b/openspec/changes/single-storage-service/specs/table-storage/spec.md @@ -0,0 +1,110 @@ +## RENAMED Requirements + +- FROM: `### Requirement: Shared TableStorage is axis-bound at construction` +- TO: `### Requirement: Shared TableStorage takes Axis per call` + +- FROM: `### Requirement: Module wiring constructs an axis-bound TableStorage per Repository` +- TO: `### Requirement: Storage service registered once and consumed by repositories` + +## MODIFIED Requirements + +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(Axis $axis, int $id, string $groupKey): list` and `write(Axis $axis, int $id, string $groupKey, array $records): bool`. The `Axis` SHALL be supplied per call (not bound to the instance) and determine which column `$id` filters on. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. + +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($axis, $id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties + +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `($axis, $id, $groupKey)` + +### Requirement: Shared TableStorage takes Axis per call +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking only `Database\InteractionsTable` via constructor. It SHALL NOT be bound to an `Axis` at construction. The `Axis` supplied to each `read`/`write` call SHALL determine which column is used as the filter. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. + +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($axis, $id, $groupKey)` is called +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` + +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($axis, $id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database + +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid + +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction + +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($axis, $id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($axis, $id, $groupKey, $records)` is called +- **THEN** for each `Record`, the field corresponding to `$axis->column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) + +#### Scenario: Write empty list clears scope +- **WHEN** `write($axis, $id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `($axis->column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` + +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` + +### Requirement: Storage service registered once and consumed by repositories +A new `SpaghettiDojo\Konomi\Storage\Module` (ServiceModule) SHALL register a single `SpaghettiDojo\Konomi\Storage\Storage::class` container service bound to `Storage\TableStorage::new($interactionsTable)`. `Post\Module` and `User\Module` SHALL inject that `Storage::class` service into their `Repository::class` definition instead of constructing `TableStorage` inline. The storage driver SHALL be swapped by extending the `Storage::class` service via an `Inpsyde\Modularity\Module\ExtendingModule`, not by overriding any `Repository::class` binding. + +#### Scenario: Storage module binds the shared service +- **WHEN** the container is built +- **THEN** `Storage\Module::services()` SHALL bind `Storage\Storage::class` to `Storage\TableStorage::new($container->get(Database\InteractionsTable::class))` + +#### Scenario: Post module injects the shared Storage service +- **WHEN** the Post module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `Post\Repository` SHALL be `$container->get(Storage\Storage::class)` (no inline `TableStorage` construction) + +#### Scenario: User module injects the shared Storage service +- **WHEN** the User module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `User\Repository` SHALL be `$container->get(Storage\Storage::class)` (no inline `TableStorage` construction) + +#### Scenario: Driver swapped via ExtendingModule +- **WHEN** a consumer registers an `ExtendingModule` whose `extensions()` returns `[Storage\Storage::class => fn(Storage $original, $c) => new CustomStorage()]` +- **THEN** both `Post\Repository` and `User\Repository` SHALL consume `CustomStorage` without any `Repository::class` override + +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`, and SHALL pass their own `Axis` (`Axis::Entity` for `Post\Repository`, `Axis::User` for `User\Repository`) as the first argument on every storage call. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. + +#### Scenario: Post repository passes Axis::Entity +- **WHEN** `Post\Repository` calls `Storage::read()` or `Storage::write()` +- **THEN** it SHALL pass `Storage\Axis::Entity` as the first argument + +#### Scenario: User repository passes Axis::User +- **WHEN** `User\Repository` calls `Storage::read()` or `Storage::write()` +- **THEN** it SHALL pass `Storage\Axis::User` as the first argument + +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group + +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group + +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation + +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract (including the per-call `Axis` parameter), a single reference `MetaStorage` implementation that branches on `Axis` to back `Storage` by `wp_postmeta` (`Axis::Entity`) and `wp_usermeta` (`Axis::User`), and an `ExtendingModule` snippet showing how to swap the driver by extending the `Storage\Storage::class` service. The document SHALL link to the Modularity Extending Module documentation. No `MetaStorage` class SHALL exist in `sources/`. + +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a single `Axis`-branching `MetaStorage` reference example and an `ExtendingModule` override snippet targeting `Storage\Storage::class` + +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` diff --git a/openspec/changes/single-storage-service/tasks.md b/openspec/changes/single-storage-service/tasks.md new file mode 100644 index 0000000..bd5f7a1 --- /dev/null +++ b/openspec/changes/single-storage-service/tasks.md @@ -0,0 +1,34 @@ +## 1. Storage core (interface + concrete) + +- [x] 1.1 Change `Storage\Storage` methods to `read(Axis $axis, int $id, string $groupKey): array` and `write(Axis $axis, int $id, string $groupKey, array $records): bool` +- [x] 1.2 Update `Storage\TableStorage`: drop the `Axis` constructor arg (ctor takes only `Database\InteractionsTable`); accept `Axis $axis` per call and use `$axis->column()` for the read `WHERE`, write `DELETE`, and per-`Record` axis invariant +- [x] 1.3 Remove `@internal` from `Storage\Axis` (now part of the public storage contract); leave `column()` unchanged + +## 2. Service registration & wiring + +- [x] 2.1 Add `Storage\Module` (ServiceModule) binding `Storage\Storage::class => TableStorage::new($container->get(Database\InteractionsTable::class))` +- [x] 2.2 Register `Storage\Module::new()` in `konomi.php` (after `Database\Module::new()`) +- [x] 2.3 Update `Post\Module`: inject `$container->get(Storage\Storage::class)` into `Repository::class`; remove inline `TableStorage::new(...)` and the direct `Database\InteractionsTable` use +- [x] 2.4 Update `User\Module`: same as 2.3 for `User\Repository` + +## 3. Repositories pass their axis + +- [x] 3.1 `Post\Repository`: pass `Storage\Axis::Entity` on its `read` and `write` calls +- [x] 3.2 `User\Repository`: pass `Storage\Axis::User` on its `read` and `write` calls + +## 4. Tests + +- [x] 4.1 Update `tests/unit/php/Storage/TableStorageTest.php`: construct `TableStorage::new($table)` (no axis) and feed the `axes` dataset into `read`/`write` calls; keep `Axis::column()` cases +- [x] 4.2 Add coverage that `Storage\Module` binds `Storage::class` to a `Storage` instance +- [x] 4.3 Add coverage that an `ExtendingModule` extension on `Storage::class` is honored (repositories consume the swapped driver) + +## 5. Documentation + +- [x] 5.1 Rewrite `docs/storage-drivers.md`: update the `Storage` interface section to the per-call `Axis` signatures; document `Axis` as contract +- [x] 5.2 Replace the reference driver with a single `Axis`-branching `MetaStorage` (post meta for `Axis::Entity`, user meta for `Axis::User`) +- [x] 5.3 Replace the "Container override" section with an `ExtendingModule` snippet targeting `Storage\Storage::class`, and link the Modularity Extending Module docs + +## 6. Verification + +- [x] 6.1 Run the test suite and static analysis; confirm green +- [x] 6.2 `openspec validate single-storage-service --strict` diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/block-hooks/spec.md b/openspec/specs/block-hooks/spec.md new file mode 100644 index 0000000..ec6cbde --- /dev/null +++ b/openspec/specs/block-hooks/spec.md @@ -0,0 +1,59 @@ +# block-hooks Specification + +## Purpose + +Defines how the `konomi/konomi` block is auto-inserted after the post title via WordPress Block Hooks, and how such hooked instances are populated with their default inner blocks so they render on the front-end. WordPress inserts a *bare* hooked block (it does not apply the editor inner-blocks template), which would otherwise render empty; the `hooked_block_konomi/konomi` filter injects the same `core/group → reaction + bookmark` structure the editor defines, so a hooked instance renders — and stays editable — exactly like a hand-placed one. + +## Requirements + +### Requirement: Konomi block declares an after-post-title block hook +The `konomi/konomi` block SHALL declare, in its `block.json`, a `blockHooks` entry auto-inserting itself `after` the `core/post-title` block. + +#### Scenario: Block hook is registered for core/post-title +- **WHEN** WordPress resolves registered block hooks after block registration +- **THEN** `get_hooked_blocks()['core/post-title']['after']` SHALL include `konomi/konomi` + +### Requirement: Hooked instances receive default inner blocks via filter +The system SHALL provide `Konomi\HookedContent::injectDefaultInnerBlocks(mixed $parsedHookedBlock, string $hookedBlockType, string $relativePosition, mixed $parsedAnchorBlock, mixed $context)` as the callback for the `hooked_block_konomi/konomi` filter, which populates a bare hooked `konomi/konomi` instance with its default inner blocks. + +#### Scenario: Bare hooked block is populated +- **WHEN** the filter receives a parsed `konomi/konomi` array with an empty `innerBlocks` +- **THEN** `innerBlocks` SHALL resolve to a `core/group` containing `konomi/reaction` and `konomi/bookmark` +- **AND** `innerContent` and `innerHTML` SHALL be populated from the same default markup +- **AND** any incoming `attrs` on the parsed block SHALL be preserved + +#### Scenario: Prior filter suppressed insertion +- **WHEN** the filter receives a `$parsedHookedBlock` that is not an array (e.g. `null`, because a prior filter suppressed insertion) +- **THEN** it SHALL return the value unchanged + +#### Scenario: Instance already has inner blocks +- **WHEN** the filter receives a parsed `konomi/konomi` array whose `innerBlocks` is non-empty (e.g. a hand-placed block, or one already populated by another filter) +- **THEN** it SHALL return the block unchanged, performing no override + +### Requirement: Default inner markup mirrors the editor template +The default inner-block markup injected by `HookedContent` SHALL mirror the editor inner-blocks template defined in `sources/Blocks/Konomi/edit/index.tsx`: a `core/group` with a flex, no-wrap layout (`{"layout":{"type":"flex","flexWrap":"nowrap"}}`) wrapping `konomi/reaction` followed by `konomi/bookmark`. + +#### Scenario: Default markup structure +- **WHEN** `HookedContent` builds the default inner blocks +- **THEN** the outer inner block SHALL be `core/group` with layout `{"type":"flex","flexWrap":"nowrap"}` +- **AND** it SHALL contain `konomi/reaction` and `konomi/bookmark` in that order +- **AND** this structure SHALL match the `useInnerBlocksProps` template in `edit/index.tsx` + +### Requirement: Module wiring registers and applies the filter +`Blocks\Module` SHALL register `Konomi\HookedContent` in `services()` and SHALL add the `hooked_block_konomi/konomi` filter in `run()` via `initBlockHooks()`, at priority `10` with `5` accepted arguments, mirroring the `ConditionalBlockRender` wiring. + +#### Scenario: HookedContent is a registered service +- **WHEN** the Blocks module registers services +- **THEN** `Konomi\HookedContent::class` SHALL resolve to a `Konomi\HookedContent` instance + +#### Scenario: Filter is added during run +- **WHEN** `Blocks\Module::run()` executes +- **THEN** `initBlockHooks()` SHALL add the `hooked_block_konomi/konomi` filter bound to `HookedContent::injectDefaultInnerBlocks` at priority `10` accepting `5` arguments + +### Requirement: Hooked Konomi block renders after the post title +When post-title content is passed through the Block Hooks pipeline and rendered, the resulting front-end output SHALL contain the reaction and bookmark markup after the post title. + +#### Scenario: End-to-end front-end render +- **WHEN** `apply_block_hooks_to_content('', $post)` is applied and its output is passed through `do_blocks()` +- **THEN** the rendered output SHALL contain `konomi-reaction` and `konomi-bookmark` +- **AND** they SHALL appear after the rendered post title diff --git a/openspec/specs/table-schema/spec.md b/openspec/specs/table-schema/spec.md new file mode 100644 index 0000000..b4bbac7 --- /dev/null +++ b/openspec/specs/table-schema/spec.md @@ -0,0 +1,47 @@ +# table-schema Specification + +## Purpose + +Defines the custom database table schema for storing Konomi interactions, including its lifecycle management (creation on activation, removal on uninstall) and the orchestrating `Database` package. + +## Requirements + +### Requirement: Interactions table schema +The system SHALL create a `{prefix}konomi_interactions` table with columns: `id` (BIGINT UNSIGNED AUTO_INCREMENT PK), `entity_id` (BIGINT UNSIGNED NOT NULL), `user_id` (BIGINT UNSIGNED NOT NULL), `entity_type` (VARCHAR(50) NOT NULL), `group_key` (VARCHAR(50) NOT NULL), with a UNIQUE constraint on `(entity_id, user_id, group_key)` and an additional index on `(user_id, group_key)`. + +#### Scenario: Table created on activation +- **WHEN** the plugin is activated +- **THEN** the `{prefix}konomi_interactions` table SHALL exist with the defined schema and indexes + +#### Scenario: Table creation is idempotent +- **WHEN** the plugin is activated and the table already exists +- **THEN** no error SHALL occur and the existing table SHALL remain unchanged + +### Requirement: Schema manager orchestration +The `Database` package SHALL provide a `Database\SchemaManager` class that creates the table using `dbDelta()`. It SHALL be invoked via `register_activation_hook`. + +#### Scenario: Table created on activation +- **WHEN** `SchemaManager::create()` runs +- **THEN** the `konomi_interactions` table SHALL be created with all columns and indexes + +### Requirement: Table cleanup on uninstall +The `Database\SchemaManager` SHALL provide a `drop()` method to drop the custom table. It SHALL be invoked via `register_uninstall_hook`. + +#### Scenario: Table dropped on uninstall +- **WHEN** the plugin is uninstalled +- **THEN** the `konomi_interactions` table SHALL be dropped + +#### Scenario: Uninstall with missing table +- **WHEN** the plugin is uninstalled and the table does not exist +- **THEN** no error SHALL occur (DROP TABLE IF EXISTS) + +### Requirement: Database package +The `Database` package SHALL provide a modularity `Module` class that registers `SchemaManager`, hooks activation and uninstall lifecycle events, and exposes the `$wpdb` table name (with prefix) as a service for consumption by storage implementations. + +#### Scenario: Services registered +- **WHEN** the `Database` package modularity `Module` is loaded +- **THEN** `SchemaManager` and table name service SHALL be available in the container + +#### Scenario: Lifecycle events hooked +- **WHEN** the `Database` package modularity `Module` runs +- **THEN** it SHALL register `SchemaManager::create` on `register_activation_hook` and `SchemaManager::drop` on `register_uninstall_hook` diff --git a/openspec/specs/table-storage/spec.md b/openspec/specs/table-storage/spec.md new file mode 100644 index 0000000..d84c941 --- /dev/null +++ b/openspec/specs/table-storage/spec.md @@ -0,0 +1,129 @@ +# table-storage Specification + +## Purpose + +Defines a single shared, axis-bound `Storage` interface and its `TableStorage` implementation that read from and write to the custom `konomi_interactions` table. The storage layer exchanges a `Record` DTO (rather than per-module array shapes) and is configured with an `Axis` at construction so the same `TableStorage` serves both the Post and User domains, filtering by `entity_id` or `user_id` respectively. + +## Requirements + +### Requirement: Shared Storage interface uses Record DTO +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Storage` interface with two methods: `read(int $id, string $groupKey): list` and `write(int $id, string $groupKey, array $records): bool`. The `$records` parameter SHALL be a `list`. The interface SHALL replace the per-module `Post\Storage` and `User\Storage` interfaces. + +#### Scenario: Read returns a list of Record DTOs +- **WHEN** `read($id, $groupKey)` is called on any `Storage` implementation +- **THEN** it SHALL return a `list`, where each `Record` exposes readonly `entityId`, `userId`, and `entityType` properties + +#### Scenario: Write accepts a list of Record DTOs +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** the implementation SHALL persist exactly the rows described by the supplied `Record` list, scoped to the given `(id, groupKey)` + +### Requirement: Record DTO shape +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Record` readonly value object with three public readonly properties: `entityId: int`, `userId: int`, `entityType: string`. The class SHALL be `final`. + +#### Scenario: Construction +- **WHEN** `new Record($entityId, $userId, $entityType)` is invoked +- **THEN** the resulting instance SHALL expose those values via readonly public properties of the declared types + +### Requirement: Axis enum drives table-side filter column +The system SHALL provide a `SpaghettiDojo\Konomi\Storage\Axis` enum with cases `Entity` and `User`, and a method `column(): string` returning `"entity_id"` for `Entity` and `"user_id"` for `User`. Adding any new case SHALL force `match` updates at every consumption site. + +#### Scenario: Entity axis maps to entity_id +- **WHEN** `Axis::Entity->column()` is called +- **THEN** it SHALL return `"entity_id"` + +#### Scenario: User axis maps to user_id +- **WHEN** `Axis::User->column()` is called +- **THEN** it SHALL return `"user_id"` + +### Requirement: Shared TableStorage is axis-bound at construction +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\TableStorage` class implementing the shared `Storage` interface, taking `Database\InteractionsTable` and `Axis` via constructor. The configured `Axis` SHALL determine which column is used as the filter for both `read` and `write`. The class SHALL replace `Post\TableStorage` and `User\TableStorage`. + +#### Scenario: Read filters by axis-resolved column +- **WHEN** `read($id, $groupKey)` is called on a `TableStorage` constructed with a given `Axis` +- **THEN** it SHALL execute `SELECT entity_id, user_id, entity_type FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` + +#### Scenario: Read with invalid id or empty key +- **WHEN** `read($id, $groupKey)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return an empty list without querying the database + +#### Scenario: Read maps rows to Record at boundary +- **WHEN** the underlying query returns rows +- **THEN** each row SHALL be passed through a `mapRow` step that returns either a `Record` for valid rows or `null` for malformed rows +- **AND** rows mapping to `null` SHALL be skipped from the returned list +- **AND** `entity_id`, `user_id` SHALL be cast to non-negative integers and `entity_type` to a non-empty string for a row to be considered valid + +#### Scenario: Write filters and replaces by axis-resolved column +- **WHEN** `write($id, $groupKey, $records)` is called +- **THEN** it SHALL execute `DELETE FROM {table} WHERE {axis.column()} = $id AND group_key = $groupKey` followed by one `INSERT` per `Record`, all within a single database transaction + +#### Scenario: Write with invalid id or empty key +- **WHEN** `write($id, $groupKey, $records)` is called with `$id <= 0` or `$groupKey === ""` +- **THEN** it SHALL return `false` without modifying data + +#### Scenario: Write enforces axis invariant on each Record +- **WHEN** `write($id, $groupKey, $records)` is called with a configured `Axis` +- **THEN** for each `Record`, the field corresponding to `Axis::column()` SHALL be set to `$id` before insertion (overriding any divergent value on the input `Record`) + +#### Scenario: Write empty list clears scope +- **WHEN** `write($id, $groupKey, [])` is called with valid `$id` and `$groupKey` +- **THEN** existing rows for that `(axis.column() = $id, group_key = $groupKey)` SHALL be deleted and the call SHALL return `true` + +#### Scenario: Write is transactional +- **WHEN** the DELETE or any INSERT fails during `write` +- **THEN** the transaction SHALL be rolled back and `write` SHALL return `false` + +### Requirement: Module wiring constructs an axis-bound TableStorage per Repository +`Post\Module` and `User\Module` SHALL construct a `TableStorage` instance with the appropriate `Axis` inline within their `Repository::class` service definition and inject it as the `Storage\Storage` dependency. No standalone `SpaghettiDojo\Konomi\Storage\Storage::class` container binding SHALL be registered; the storage driver is swapped by overriding the module's `Repository::class` binding. + +#### Scenario: Post module wires Axis::Entity +- **WHEN** the Post module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `Post\Repository` SHALL be `Storage\TableStorage::new($interactionsTable, Storage\Axis::Entity)` + +#### Scenario: User module wires Axis::User +- **WHEN** the User module registers the `Repository::class` service +- **THEN** the `Storage\Storage` injected into `User\Repository` SHALL be `Storage\TableStorage::new($interactionsTable, Storage\Axis::User)` + +### Requirement: Repositories consume Record-typed Storage with flat serialization +`Post\Repository` and `User\Repository` SHALL consume the shared `Storage` interface, accepting `list` from `read()` and emitting `list` to `write()`. Both repositories SHALL build the records list by iterating their registry and instantiating one `Record` per registry entry. + +#### Scenario: Post repository serializes one Record per userId +- **WHEN** `Post\Repository::save($item, $user)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($item->id(), $userId, $item->type())` per `$userId` currently held in the registry for the post and group + +#### Scenario: User repository serializes one Record per entityId +- **WHEN** `User\Repository::save($user, $item)` calls the underlying `Storage::write()` +- **THEN** the records list SHALL contain exactly one `Record($entityId, $user->id(), $entityType)` per item currently held in the registry for the user and group + +#### Scenario: Registry rollback on write failure +- **WHEN** `Storage::write()` returns `false` from a repository `save()` call +- **THEN** the in-memory registry SHALL be restored to the snapshot captured before the save mutation + +### Requirement: Shared StorageKey deduplication +The system SHALL provide a single `SpaghettiDojo\Konomi\Storage\StorageKey` class that produces sanitized group strings. The class SHALL replace per-module `Post\StorageKey` and `User\StorageKey`. The constructor SHALL take no arguments. + +#### Scenario: Valid group +- **WHEN** `StorageKey::for($group)` is called with `ItemGroup` value `"reaction"` +- **THEN** it SHALL return `"reaction"` + +#### Scenario: Invalid characters +- **WHEN** `StorageKey::for($group)` is called with a value containing characters outside `[a-z0-9_]` +- **THEN** it SHALL throw `\UnexpectedValueException` + +#### Scenario: Empty group +- **WHEN** `StorageKey::for($group)` is called with an empty value +- **THEN** it SHALL throw `\InvalidArgumentException` + +#### Scenario: Construction takes no arguments +- **WHEN** `StorageKey::new()` is called +- **THEN** it SHALL accept no arguments and return a usable instance + +### Requirement: MetaStorage exists only as documented reference impl +The repository SHALL provide a `docs/storage-drivers.md` document that includes: a description of the `Storage` interface contract, a reference `MetaStorage` implementation showing how to back `Storage` by `wp_postmeta` and `wp_usermeta`, and a container-override snippet showing how to swap the storage driver by overriding each module's `Repository::class` binding from a consumer plugin or site. No `MetaStorage` class SHALL exist in `sources/`. + +#### Scenario: Documentation present +- **WHEN** the repository is checked out +- **THEN** `docs/storage-drivers.md` SHALL exist and SHALL contain a reference `MetaStorage` example for both post and user backends along with a DI override snippet + +#### Scenario: No MetaStorage in sources +- **WHEN** the codebase is searched +- **THEN** no `MetaStorage` class SHALL exist under `sources/Post/`, `sources/User/`, or `sources/Storage/` diff --git a/phpcs.xml b/phpcs.xml index d2378c6..3fb53cb 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -41,7 +41,7 @@ */vendor/* */wordpress/* */dist/* - */tests/helpers/* + */tests/Helpers/* */tests/fixtures/* */tests/stubs/* sources/Blocks/blocks-manifest.php diff --git a/phpstan.neon b/phpstan.neon index b1e7085..0d97164 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -4,8 +4,8 @@ parameters: - sources scanFiles: - vendor/inpsyde/wp-stubs-versions/6.8.php - - tests/helpers/functions.php - - tests/helpers/integration.php + - tests/Helpers/functions.php + - tests/Helpers/integration.php treatPhpDocTypesAsCertain: false includes: - vendor/bnf/phpstan-psr-container/extension.neon diff --git a/sources/Activation/Activable.php b/sources/Activation/Activable.php new file mode 100644 index 0000000..82b4705 --- /dev/null +++ b/sources/Activation/Activable.php @@ -0,0 +1,16 @@ + $modules + */ + public function prepare(iterable $modules): void + { + foreach ($modules as $module) { + if ($module instanceof Activable) { + $module->activate($this->tasks, $this->container); + } + } + } + + public function registerActivationLogic(): void + { + register_activation_hook( + $this->properties->pluginMainFile(), + function (): void { + foreach ($this->tasks->activationTasks() as $task) { + $task(); + } + } + ); + } + + public function registerDeactivationLogic(): void + { + register_deactivation_hook( + $this->properties->pluginMainFile(), + function (): void { + foreach ($this->tasks->deactivationTasks() as $task) { + $task(); + } + } + ); + } + + public function registerUninstallLogic(): void + { + // The registry is bridged to the static uninstall callback because + // `register_uninstall_hook()` stores the callback in the database and a + // closure cannot be serialized. Only a serializable static callable is + // safe here. + self::$uninstallTasks = $this->tasks; + + register_uninstall_hook( + $this->properties->pluginMainFile(), + // phpcs:ignore Inpsyde.CodeQuality.StaticClosure.PossiblyStaticClosure + [self::class, 'executeUninstallTasks'] + ); + } + + public static function executeUninstallTasks(): void + { + foreach (self::$uninstallTasks?->uninstallTasks() ?? [] as $task) { + $task(); + } + } +} diff --git a/sources/Activation/ActivationTasks.php b/sources/Activation/ActivationTasks.php new file mode 100644 index 0000000..c114b3f --- /dev/null +++ b/sources/Activation/ActivationTasks.php @@ -0,0 +1,88 @@ + */ + private \ArrayObject $activationTasks; + /** @var \ArrayObject */ + private \ArrayObject $deactivationTasks; + /** @var \ArrayObject */ + private \ArrayObject $uninstallTasks; + + public static function new(): self + { + return new self(); + } + + private function __construct() + { + $this->activationTasks = new \ArrayObject(); + $this->deactivationTasks = new \ArrayObject(); + $this->uninstallTasks = new \ArrayObject(); + } + + /** + * @param Task $callback + */ + public function addActivationTask(callable $callback): self + { + $this->activationTasks->append($callback); + return $this; + } + + /** + * @param Task $callback + */ + public function addDeactivationTask(callable $callback): self + { + $this->deactivationTasks->append($callback); + return $this; + } + + /** + * @param Task $callback + */ + public function addUninstallTask(callable $callback): self + { + $this->uninstallTasks->append($callback); + return $this; + } + + /** + * @return list + */ + public function activationTasks(): array + { + return array_values($this->activationTasks->getArrayCopy()); + } + + /** + * @return list + */ + public function deactivationTasks(): array + { + return array_values($this->deactivationTasks->getArrayCopy()); + } + + /** + * @return list + */ + public function uninstallTasks(): array + { + return array_values($this->uninstallTasks->getArrayCopy()); + } +} diff --git a/sources/Activation/Module.php b/sources/Activation/Module.php new file mode 100644 index 0000000..bda83db --- /dev/null +++ b/sources/Activation/Module.php @@ -0,0 +1,46 @@ + static fn (): ActivationTasks => ActivationTasks::new(), + ActivationExecute::class => static function ( + ContainerInterface $container + ): ActivationExecute { + /** @var PluginProperties $properties */ + $properties = $container->get(Package::PROPERTIES); + + return ActivationExecute::new( + $properties, + $container->get(ActivationTasks::class), + $container + ); + }, + ]; + } +} diff --git a/sources/Blocks/Konomi/HookedContent.php b/sources/Blocks/Konomi/HookedContent.php new file mode 100644 index 0000000..e112f9d --- /dev/null +++ b/sources/Blocks/Konomi/HookedContent.php @@ -0,0 +1,92 @@ +|null $parsedHookedBlock + * @param string $hookedBlockType + * @param string $relativePosition + * @param array|null $parsedAnchorBlock + * @param mixed $context + * @return array|null + */ + public function injectDefaultInnerBlocks( + mixed $parsedHookedBlock, + string $hookedBlockType, + string $relativePosition, + mixed $parsedAnchorBlock, + mixed $context + ): mixed { + + // A prior filter may have suppressed insertion by returning null. + if (!is_array($parsedHookedBlock)) { + return $parsedHookedBlock; + } + + // Never override an instance that already carries inner blocks (e.g. a + // hand-placed block, or one already populated by another filter). + if (!empty($parsedHookedBlock['innerBlocks'])) { + return $parsedHookedBlock; + } + + $defaults = parse_blocks(self::defaultBlockMarkup()); + $default = $defaults[0] ?? null; + if (!is_array($default)) { + return $parsedHookedBlock; + } + + $parsedHookedBlock['innerBlocks'] = $default['innerBlocks']; + $parsedHookedBlock['innerContent'] = $default['innerContent']; + $parsedHookedBlock['innerHTML'] = $default['innerHTML']; + + return $parsedHookedBlock; + } + + /** + * The default `konomi/konomi` markup, including its inner blocks. + * + * This MUST mirror the editor inner-blocks template defined in + * `sources/Blocks/Konomi/edit/index.tsx` (a `core/group` with a flex, no-wrap + * layout containing `konomi/reaction` and `konomi/bookmark`). + */ + private static function defaultBlockMarkup(): string + { + return <<<'HTML' + + +
+ +
+ + +HTML; + } +} diff --git a/sources/Blocks/Module.php b/sources/Blocks/Module.php index 19b2990..778516d 100644 --- a/sources/Blocks/Module.php +++ b/sources/Blocks/Module.php @@ -67,6 +67,7 @@ public function services(): array $container->get(User\UserFactory::class), $container->get(InstanceId::class) ), + Konomi\HookedContent::class => static fn () => Konomi\HookedContent::new(), /* * Reaction @@ -96,6 +97,7 @@ public function run(ContainerInterface $container): bool $this->initRest($container); $this->registerBlocks($container); $this->initBlocksConstraints($container); + $this->initBlockHooks($container); return true; } @@ -168,4 +170,17 @@ private function initBlocksConstraints(ContainerInterface $container): void 2 ); } + + private function initBlockHooks(ContainerInterface $container): void + { + /** @var Konomi\HookedContent $hookedContent */ + $hookedContent = $container->get(Konomi\HookedContent::class); + + add_filter( + 'hooked_block_konomi/konomi', + [$hookedContent, 'injectDefaultInnerBlocks'], + 10, + 5 + ); + } } diff --git a/sources/Database/InteractionsTable.php b/sources/Database/InteractionsTable.php new file mode 100644 index 0000000..73ef673 --- /dev/null +++ b/sources/Database/InteractionsTable.php @@ -0,0 +1,29 @@ +prefix . self::BASE_NAME; + } +} diff --git a/sources/Database/Module.php b/sources/Database/Module.php new file mode 100644 index 0000000..1ae064c --- /dev/null +++ b/sources/Database/Module.php @@ -0,0 +1,55 @@ + static function (): InteractionsTable { + global $wpdb; + return InteractionsTable::new($wpdb->prefix); + }, + SchemaManager::class => static fn ( + ContainerInterface $container + ) => SchemaManager::new( + $container->get(InteractionsTable::class) + ), + ]; + } + + public function activate(ActivationTasks $tasks, ContainerInterface $container): void + { + $tasks + ->addActivationTask( + static fn () => $container->get(SchemaManager::class)->create() + ) + ->addUninstallTask( + static fn () => $container->get(SchemaManager::class)->drop() + ); + } +} diff --git a/sources/Database/SchemaManager.php b/sources/Database/SchemaManager.php new file mode 100644 index 0000000..bde08b9 --- /dev/null +++ b/sources/Database/SchemaManager.php @@ -0,0 +1,58 @@ +table->name(); + $charsetCollate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE {$tableName} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + entity_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + entity_type VARCHAR(50) NOT NULL, + group_key VARCHAR(50) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY entity_user_group (entity_id, user_id, group_key), + KEY user_group (user_id, group_key) + ) {$charsetCollate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta($sql); + } + + public function drop(): void + { + global $wpdb; + + $tableName = $this->table->name(); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $wpdb->query("DROP TABLE IF EXISTS {$tableName}"); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/sources/Post/Module.php b/sources/Post/Module.php index ebb0d43..bf78e6b 100644 --- a/sources/Post/Module.php +++ b/sources/Post/Module.php @@ -10,6 +10,7 @@ Module\ExecutableModule, Module\ModuleClassNameIdTrait }; +use SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; class Module implements ServiceModule, ExecutableModule @@ -31,21 +32,17 @@ public function services(): array Post::class => static fn (ContainerInterface $container) => Post::new( $container->get(Repository::class) ), - Storage::class => static fn () => Storage::new(), ItemRegistryKey::class => static fn () => ItemRegistryKey::new(), ItemRegistry::class => static fn ( ContainerInterface $container ) => ItemRegistry::new( $container->get(ItemRegistryKey::class) ), - RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new('_konomi_items'), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( - $container->get(StorageKey::class), - $container->get(Storage::class), - $container->get(RawDataAssert::class), + Storage\StorageKey::new(), + $container->get(Storage\Storage::class), $container->get(User\ItemFactory::class), $container->get(ItemRegistry::class) ), diff --git a/sources/Post/RawDataAssert.php b/sources/Post/RawDataAssert.php deleted file mode 100644 index efd19b0..0000000 --- a/sources/Post/RawDataAssert.php +++ /dev/null @@ -1,83 +0,0 @@ - - * @phpstan-type StoredData = array - * @phpstan-type GeneratorStoredData = \Generator - */ -class RawDataAssert -{ - public static function new(): RawDataAssert - { - return new self(); - } - - final private function __construct() - { - } - - /** - * @param array $data - * @return GeneratorStoredData - */ - public function ensureDataStructure(array $data): \Generator - { - foreach ($data as $userId => $rawItems) { - if (!self::isValidUserId($userId)) { - continue; - } - if (!self::areRawItemsValid($rawItems)) { - continue; - } - - yield $userId => $rawItems; - } - } - - /** - * @phpstan-assert-if-true UserId $userId - */ - private static function isValidUserId(mixed $userId): bool - { - return is_int($userId) && $userId > 0; - } - - /** - * @phpstan-assert-if-true RawItems $rawItems - * @param mixed $rawItems - */ - private static function areRawItemsValid(mixed $rawItems): bool - { - if (!is_array($rawItems)) { - return false; - } - if (!self::isValidRawItem($rawItems[0] ?? [])) { - return false; - } - - return true; - } - - /** - * @phpstan-assert-if-true RawItem $rawItem - */ - private static function isValidRawItem(mixed $rawItem): bool - { - return is_array($rawItem) - && count($rawItem) === 2 - && isset($rawItem[0], $rawItem[1]) - && is_int($rawItem[0]) - && $rawItem[0] > 0 - && is_string($rawItem[1]) - && $rawItem[1] !== ''; - } -} diff --git a/sources/Post/Repository.php b/sources/Post/Repository.php index 9578565..50d8603 100644 --- a/sources/Post/Repository.php +++ b/sources/Post/Repository.php @@ -4,41 +4,34 @@ namespace SpaghettiDojo\Konomi\Post; +use SpaghettiDojo\Konomi\Storage; use SpaghettiDojo\Konomi\User; /** * @internal - * - * @phpstan-import-type UserId from RawDataAssert - * @phpstan-import-type RawItem from RawDataAssert - * @phpstan-import-type RawItems from RawDataAssert - * @phpstan-import-type StoredData from RawDataAssert - * @phpstan-import-type GeneratorStoredData from RawDataAssert */ class Repository { public static function new( - StorageKey $key, - Storage $storage, - RawDataAssert $rawDataAsserter, + Storage\StorageKey $key, + Storage\Storage $storage, User\ItemFactory $itemFactory, ItemRegistry $registry ): Repository { - return new self($key, $storage, $rawDataAsserter, $itemFactory, $registry); + return new self($key, $storage, $itemFactory, $registry); } final private function __construct( - readonly private StorageKey $key, - readonly private Storage $storage, - readonly private RawDataAssert $rawDataAsserter, + readonly private Storage\StorageKey $key, + readonly private Storage\Storage $storage, readonly private User\ItemFactory $itemFactory, readonly private ItemRegistry $registry ) { } /** - * @return array + * @return array */ public function find(int $entityId, User\ItemGroup $group): array { @@ -54,14 +47,15 @@ public function save(User\Item $item, User\User $user): bool $this->loadItems($item->id(), $item->group()); $registrySnapshot = clone $this->registry; - $dataToStore = $this->prepareDataToStore($item, $user); + $records = $this->prepareDataToStore($item, $user); do_action('konomi.post.collection.save', $item, $user, $this->key); $stored = $this->storage->write( + Storage\Axis::Entity, $item->id(), - "{$this->key->for($item->group())}", - $dataToStore + $this->key->for($item->group()), + $records ); $stored or $this->rollbackRegistry($registrySnapshot); @@ -75,7 +69,7 @@ private function canSave(User\User $user, User\Item $item): bool } /** - * @return StoredData + * @return list */ private function prepareDataToStore(User\Item $item, User\User $user): array { @@ -90,15 +84,15 @@ private function prepareDataToStore(User\Item $item, User\User $user): array } /** - * @return StoredData + * @return list */ private function serializeData(int $postId, User\ItemGroup $group): array { - $result = []; + $records = []; foreach ($this->registry->all($postId, $group) as $userId => $item) { - $result[$userId] = [[$item->id(), $item->type()]]; + $records[] = new Storage\Record($item->id(), (int) $userId, $item->type()); } - return $result; + return $records; } private function rollbackRegistry(ItemRegistry $registry): void @@ -112,29 +106,9 @@ private function loadItems(int $postId, User\ItemGroup $group): void return; } - foreach ($this->read($postId, $group) as $userId => $rawItems) { - $item = $this->unserialize($rawItems, $group); - $item->isValid() and $this->registry->set($postId, $userId, $item); + foreach ($this->storage->read(Storage\Axis::Entity, $postId, $this->key->for($group)) as $record) { + $item = $this->itemFactory->create($record->entityId, $record->entityType, true, $group); + $item->isValid() and $this->registry->set($postId, $record->userId, $item); } } - - /** - * @return GeneratorStoredData - */ - private function read(int $entityId, User\ItemGroup $group): \Generator - { - $storedData = $this->storage->read($entityId, $this->key->for($group)); - yield from $this->rawDataAsserter->ensureDataStructure($storedData); - } - - /** - * @param RawItems $rawItems - * @return User\Item - */ - private function unserialize(array $rawItems, User\ItemGroup $group): User\Item - { - $id = (int) ($rawItems[0][0] ?? null); - $type = (string) ($rawItems[0][1] ?? null); - return $this->itemFactory->create($id, $type, true, $group); - } } diff --git a/sources/Post/Storage.php b/sources/Post/Storage.php deleted file mode 100644 index 51b8633..0000000 --- a/sources/Post/Storage.php +++ /dev/null @@ -1,46 +0,0 @@ - - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_post_meta($id, $key, true); - return is_array($data) ? $data : []; - } - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_post_meta($id, $key, $data); - } -} diff --git a/sources/Post/StorageKey.php b/sources/Post/StorageKey.php deleted file mode 100644 index 5aeebb3..0000000 --- a/sources/Post/StorageKey.php +++ /dev/null @@ -1,46 +0,0 @@ -value; - - if (!$groupValue) { - throw new \InvalidArgumentException('Group value cannot be empty'); - } - if (!$this->base) { - throw new \InvalidArgumentException('Base value cannot be empty'); - } - - $expectedKey = $this->base . '.' . $groupValue; - $key = preg_replace('/[^a-z0-9._]/', '', $expectedKey); - - if ($key !== $expectedKey) { - throw new \UnexpectedValueException('Storage key cannot be empty after sanitization'); - } - - return $key; - } -} diff --git a/sources/Storage/Axis.php b/sources/Storage/Axis.php new file mode 100644 index 0000000..2037d42 --- /dev/null +++ b/sources/Storage/Axis.php @@ -0,0 +1,20 @@ + 'entity_id', + self::User => 'user_id', + }; + } +} diff --git a/sources/Storage/Module.php b/sources/Storage/Module.php new file mode 100644 index 0000000..e7bc8f4 --- /dev/null +++ b/sources/Storage/Module.php @@ -0,0 +1,37 @@ + static fn ( + ContainerInterface $container + ) => TableStorage::new( + $container->get(Database\InteractionsTable::class) + ), + ]; + } +} diff --git a/sources/Storage/Record.php b/sources/Storage/Record.php new file mode 100644 index 0000000..3596bb0 --- /dev/null +++ b/sources/Storage/Record.php @@ -0,0 +1,18 @@ + + */ + public function read(Axis $axis, int $id, string $groupKey): array; + + /** + * @param list $records + */ + public function write(Axis $axis, int $id, string $groupKey, array $records): bool; +} diff --git a/sources/Storage/StorageKey.php b/sources/Storage/StorageKey.php new file mode 100644 index 0000000..f7569f0 --- /dev/null +++ b/sources/Storage/StorageKey.php @@ -0,0 +1,40 @@ +value; + + if ($value === '') { + throw new \InvalidArgumentException('Group value cannot be empty'); + } + + $sanitized = preg_replace('/[^a-z0-9_]/', '', $value); + + if ($sanitized !== $value) { + throw new \UnexpectedValueException('Group value contains invalid characters'); + } + + return $sanitized; + } +} diff --git a/sources/Storage/TableStorage.php b/sources/Storage/TableStorage.php new file mode 100644 index 0000000..2a3cb51 --- /dev/null +++ b/sources/Storage/TableStorage.php @@ -0,0 +1,139 @@ + + */ + public function read(Axis $axis, int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + + global $wpdb; + + $column = $axis->column(); + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, Inpsyde.CodeQuality.LineLength.TooLong + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT entity_id, user_id, entity_type FROM %i WHERE {$column} = %d AND group_key = %s", + $this->table->name(), + $id, + $groupKey + ), ARRAY_A); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, Inpsyde.CodeQuality.LineLength.TooLong + + if (!is_array($rows)) { + return []; + } + + $records = []; + foreach ($rows as $row) { + $record = is_array($row) ? $this->mapRow($row) : null; + if ($record !== null) { + $records[] = $record; + } + } + + return $records; + } + + /** + * @param list $records + */ + public function write(Axis $axis, int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + + global $wpdb; + + $tableName = $this->table->name(); + $column = $axis->column(); + + $wpdb->query('START TRANSACTION'); + + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $deleted = $wpdb->query( + $wpdb->prepare( + "DELETE FROM %i WHERE {$column} = %d AND group_key = %s", + $tableName, + $id, + $groupKey + ) + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ($deleted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + + foreach ($records as $record) { + $payload = [ + 'entity_id' => $axis === Axis::Entity ? $id : $record->entityId, + 'user_id' => $axis === Axis::User ? $id : $record->userId, + 'entity_type' => $record->entityType, + 'group_key' => $groupKey, + ]; + + $inserted = $wpdb->insert( + $tableName, + $payload, + ['%d', '%d', '%s', '%s'] + ); + + if ($inserted === false) { + $wpdb->query('ROLLBACK'); + return false; + } + } + + $wpdb->query('COMMIT'); + return true; + } + + /** + * @param array $row + */ + private function mapRow(array $row): ?Record + { + $rawEntityId = $row['entity_id'] ?? null; + $rawUserId = $row['user_id'] ?? null; + $rawEntityType = $row['entity_type'] ?? null; + + if (!is_scalar($rawEntityId) || !is_scalar($rawUserId) || !is_scalar($rawEntityType)) { + return null; + } + + $entityId = (int) $rawEntityId; + $userId = (int) $rawUserId; + $entityType = (string) $rawEntityType; + + if ($entityId < 0 || $userId < 0 || $entityType === '') { + return null; + } + + return new Record($entityId, $userId, $entityType); + } +} diff --git a/sources/User/Module.php b/sources/User/Module.php index 49514b8..cedf378 100644 --- a/sources/User/Module.php +++ b/sources/User/Module.php @@ -9,6 +9,7 @@ Module\ServiceModule, Module\ModuleClassNameIdTrait }; +use SpaghettiDojo\Konomi\Storage; class Module implements ServiceModule { @@ -26,7 +27,6 @@ final private function __construct() public function services(): array { return [ - Storage::class => static fn () => Storage::new(), UserFactory::class => static fn ( ContainerInterface $container ) => UserFactory::new( @@ -40,16 +40,13 @@ public function services(): array ) => ItemRegistry::new( $container->get(ItemRegistryKey::class) ), - RawDataAssert::class => static fn () => RawDataAssert::new(), - StorageKey::class => static fn () => StorageKey::new('_konomi_items'), Repository::class => static fn ( ContainerInterface $container ) => Repository::new( - $container->get(StorageKey::class), - $container->get(Storage::class), + Storage\StorageKey::new(), + $container->get(Storage\Storage::class), $container->get(ItemFactory::class), - $container->get(ItemRegistry::class), - $container->get(RawDataAssert::class) + $container->get(ItemRegistry::class) ), ]; } diff --git a/sources/User/RawDataAssert.php b/sources/User/RawDataAssert.php deleted file mode 100644 index 8644338..0000000 --- a/sources/User/RawDataAssert.php +++ /dev/null @@ -1,46 +0,0 @@ - $rawItems - * @return \Generator - */ - public function ensureDataStructure(array $rawItems): \Generator - { - foreach ($rawItems as $item) { - self::isValidRawItem($item) and yield $item; - } - } - - /** - * @phpstan-assert-if-true RawItem $rawItem - */ - private static function isValidRawItem(mixed $rawItem): bool - { - return is_array($rawItem) - && count($rawItem) === 2 - && isset($rawItem[0], $rawItem[1]) - && is_int($rawItem[0]) - && $rawItem[0] > 0 - && is_string($rawItem[1]) - && $rawItem[1] !== ''; - } -} diff --git a/sources/User/Repository.php b/sources/User/Repository.php index 6ebb9ff..3283f79 100644 --- a/sources/User/Repository.php +++ b/sources/User/Repository.php @@ -4,33 +4,28 @@ namespace SpaghettiDojo\Konomi\User; +use SpaghettiDojo\Konomi\Storage; + /** * @internal - * - * @phpstan-type EntityId = int - * @phpstan-type EntityType = string - * @phpstan-type RawItem = array{0: EntityId, 1: EntityType} - * @phpstan-type RawItems = array */ class Repository { public static function new( - StorageKey $key, - Storage $storage, + Storage\StorageKey $key, + Storage\Storage $storage, ItemFactory $itemFactory, - ItemRegistry $registry, - RawDataAssert $rawDataAsserter + ItemRegistry $registry ): Repository { - return new self($key, $storage, $itemFactory, $registry, $rawDataAsserter); + return new self($key, $storage, $itemFactory, $registry); } final private function __construct( - readonly private StorageKey $storageKey, - readonly private Storage $storage, + readonly private Storage\StorageKey $storageKey, + readonly private Storage\Storage $storage, readonly private ItemFactory $itemFactory, readonly private ItemRegistry $registry, - readonly private RawDataAssert $rawDataAsserter ) { } @@ -65,12 +60,13 @@ public function save(User $user, Item $item): bool $this->loadItems($user, $item->group()); $registrySnapshot = clone $this->registry; - $dataToStore = $this->prepareDataToStore($user, $item); + $records = $this->prepareDataToStore($user, $item); $stored = $this->storage->write( + Storage\Axis::User, $user->id(), $this->storageKey->for($item->group()), - $dataToStore + $records ); $stored @@ -92,7 +88,7 @@ private function canSave(User $user, Item $item): bool } /** - * @return RawItems + * @return list */ private function prepareDataToStore(User $user, Item $item): array { @@ -104,14 +100,15 @@ private function prepareDataToStore(User $user, Item $item): array } /** - * @return RawItems + * @return list */ private function serializeData(User $user, ItemGroup $group): array { - return \array_map( - static fn (Item $item) => [$item->id(), $item->type()], - $this->registry->all($user, $group) - ); + $records = []; + foreach ($this->registry->all($user, $group) as $item) { + $records[] = new Storage\Record($item->id(), $user->id(), $item->type()); + } + return $records; } private function rollbackRegistry(ItemRegistry $registry): void @@ -125,23 +122,14 @@ private function loadItems(User $user, ItemGroup $group): void return; } - foreach ($this->read($user, $group) as [$entityId, $entityType]) { + foreach ($this->storage->read(Storage\Axis::User, $user->id(), $this->storageKey->for($group)) as $record) { $item = $this->itemFactory->create( - $entityId, - $entityType, + $record->entityId, + $record->entityType, true, $group ); $item->isValid() and $this->registry->set($user, $item); } } - - /** - * @return \Generator - */ - private function read(User $user, ItemGroup $group): \Generator - { - $storedData = $this->storage->read($user->id(), $this->storageKey->for($group)); - yield from $this->rawDataAsserter->ensureDataStructure($storedData); - } } diff --git a/sources/User/Storage.php b/sources/User/Storage.php deleted file mode 100644 index bf10a87..0000000 --- a/sources/User/Storage.php +++ /dev/null @@ -1,45 +0,0 @@ - - */ - public function read(int $id, string $key): array - { - if ($id <= 0 || $key === '') { - return []; - } - - $data = get_user_meta($id, $key, true); - return is_array($data) ? $data : []; - } - - /** - * @param array $data - */ - public function write(int $id, string $key, array $data): bool - { - if ($id <= 0 || $key === '') { - return false; - } - - return (bool) update_user_meta($id, $key, $data); - } -} diff --git a/sources/User/StorageKey.php b/sources/User/StorageKey.php deleted file mode 100644 index 1b2f9bd..0000000 --- a/sources/User/StorageKey.php +++ /dev/null @@ -1,50 +0,0 @@ -value; - - if (!$groupValue) { - throw new \InvalidArgumentException('Group value cannot be empty'); - } - if (!$this->base) { - throw new \InvalidArgumentException('Base value cannot be empty'); - } - - $expectedKey = $this->base . '.' . $groupValue; - $key = preg_replace('/[^a-z0-9._]/', '', $expectedKey); - - if ($key !== $expectedKey) { - throw new \UnexpectedValueException('Storage key cannot be empty after sanitization'); - } - - return $key; - } -} diff --git a/tests/Helpers/InMemoryStorage.php b/tests/Helpers/InMemoryStorage.php new file mode 100644 index 0000000..e74853d --- /dev/null +++ b/tests/Helpers/InMemoryStorage.php @@ -0,0 +1,80 @@ +> */ + private array $data = []; + + private bool $writeFails = false; + + public int $reads = 0; + + public int $writes = 0; + + public static function new(): InMemoryStorage + { + return new self(); + } + + public function read(Axis $axis, int $id, string $groupKey): array + { + if ($id <= 0 || $groupKey === '') { + return []; + } + $this->reads++; + return $this->data[self::keyFor($id, $groupKey)] ?? []; + } + + public function write(Axis $axis, int $id, string $groupKey, array $records): bool + { + if ($id <= 0 || $groupKey === '') { + return false; + } + if ($this->writeFails) { + return false; + } + $this->writes++; + $this->data[self::keyFor($id, $groupKey)] = array_values($records); + return true; + } + + public function failWrites(bool $fail = true): void + { + $this->writeFails = $fail; + } + + /** + * @param list $records + */ + public function seed(int $id, User\ItemGroup $groupKey, array $records): void + { + $this->data[self::keyFor($id, $groupKey->value)] = $records; + } + + /** + * @return list + */ + public function get(int $id, string $groupKey): array + { + return $this->data[self::keyFor($id, $groupKey)] ?? []; + } + + public function has(int $id, string $groupKey): bool + { + return isset($this->data[self::keyFor($id, $groupKey)]); + } + + private static function keyFor(int $id, string $groupKey): string + { + return $id . '|' . $groupKey; + } +} diff --git a/tests/helpers/functions.php b/tests/Helpers/functions.php similarity index 100% rename from tests/helpers/functions.php rename to tests/Helpers/functions.php diff --git a/tests/Helpers/integration.php b/tests/Helpers/integration.php new file mode 100644 index 0000000..1daac16 --- /dev/null +++ b/tests/Helpers/integration.php @@ -0,0 +1,29 @@ + $rows other-axis id => entity type + */ +function seedRecords( + Helpers\InMemoryStorage $storage, + int $id, + User\ItemGroup $group, + array $rows, + Storage\Axis $axis = Storage\Axis::User +): void { + + $records = []; + foreach ($rows as $otherId => $type) { + $records[] = $axis === Storage\Axis::User + ? new Storage\Record($otherId, $id, $type) + : new Storage\Record($id, $otherId, $type); + } + $storage->seed($id, $group, $records); +} diff --git a/tests/WpLoad.php b/tests/WpLoad.php index 4a3fed4..c249f07 100644 --- a/tests/WpLoad.php +++ b/tests/WpLoad.php @@ -29,7 +29,13 @@ public static function load(): void // phpcs:enable Inpsyde.CodeQuality.VariablesName.SnakeCaseVar require_once ABSPATH . 'wp-includes/plugin.php'; - require_once dirname(__DIR__) . '/konomi.php'; + + // Load the plugin the same way core does: during muplugins_loaded, after + // wp-includes/functions.php is available (get_file_data) and before plugins_loaded + // fires, so the plugin's own plugins_loaded/boot registration still lands in time. + add_action('muplugins_loaded', static function (): void { + require_once dirname(__DIR__) . '/konomi.php'; + }); require_once ABSPATH . '/wp-settings.php'; require_once ABSPATH . 'wp-admin/includes/admin.php'; diff --git a/tests/WpTestCase.php b/tests/WpTestCase.php index 9220a1f..2a9af8f 100644 --- a/tests/WpTestCase.php +++ b/tests/WpTestCase.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use WorDBless\Sqlite; +use SpaghettiDojo\Konomi\Database; // phpcs:disable Inpsyde.CodeQuality.NoAccessors.NoSetter @@ -45,6 +46,7 @@ protected function setUp(): void { parent::setUp(); Sqlite::init(); + self::createKonomiTables(); self::insertUsers(); self::insertPosts(); } @@ -129,6 +131,14 @@ private static function cleanUp(): void // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared } + Database\SchemaManager::new(Database\InteractionsTable::new($wpdb->prefix))->drop(); + wp_cache_flush(); } + + private static function createKonomiTables(): void + { + global $wpdb; + Database\SchemaManager::new(Database\InteractionsTable::new($wpdb->prefix))->create(); + } } diff --git a/tests/functional/php/Blocks/BlockHooksTest.php b/tests/functional/php/Blocks/BlockHooksTest.php new file mode 100644 index 0000000..825068c --- /dev/null +++ b/tests/functional/php/Blocks/BlockHooksTest.php @@ -0,0 +1,72 @@ + reaction + bookmark` inner blocks so it renders on the front-end just +// like a hand-placed block. +describe('Block Hooks', function (): void { + it('registers konomi/konomi as hooked after core/post-title', function (): void { + $hooked = get_hooked_blocks(); + + expect($hooked)->toHaveKey('core/post-title'); + expect($hooked['core/post-title'])->toHaveKey('after'); + expect($hooked['core/post-title']['after'])->toContain('konomi/konomi'); + }); + + it('injects the default inner blocks into the auto-inserted block', function (): void { + $post = get_posts(['post_type' => 'post', 'numberposts' => 1])[0] ?? null; + expect($post)->not->toBeNull(); + + $withHooks = apply_block_hooks_to_content('', $post); + + // The block is inserted after the title, now WITH its inner blocks. + expect($withHooks)->toContain('wp:konomi/konomi'); + expect($withHooks)->toContain('wp:konomi/reaction'); + expect($withHooks)->toContain('wp:konomi/bookmark'); + expect(strpos($withHooks, 'wp:konomi/konomi')) + ->toBeGreaterThan(strpos($withHooks, 'wp:post-title')); + }); + + it('renders the Reaction and Bookmark buttons on the front-end', function (): void { + $this->signInUser('subscriber'); + + $post = get_posts(['post_type' => 'post', 'numberposts' => 1])[0] ?? null; + $withHooks = apply_block_hooks_to_content('', $post); + $rendered = do_blocks($withHooks); + + expect($rendered)->toContain('data-wp-interactive="konomi"'); + expect($rendered)->toContain('konomi-reaction'); + expect($rendered)->toContain('konomi-bookmark'); + + wp_logout(); + }); + + it('does not override a block that already has inner blocks', function (): void { + $hookedContent = \SpaghettiDojo\Konomi\Blocks\Konomi\HookedContent::new(); + + $alreadyPopulated = [ + 'blockName' => 'konomi/konomi', + 'attrs' => [], + 'innerBlocks' => [ + ['blockName' => 'core/paragraph', 'attrs' => [], 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => []], + ], + 'innerHTML' => '', + 'innerContent' => [null], + ]; + + $result = $hookedContent->injectDefaultInnerBlocks( + $alreadyPopulated, + 'konomi/konomi', + 'after', + null, + null + ); + + expect($result)->toBe($alreadyPopulated); + }); +}); diff --git a/tests/functional/php/Post/RepositoryTest.php b/tests/functional/php/Post/RepositoryTest.php new file mode 100644 index 0000000..b67122d --- /dev/null +++ b/tests/functional/php/Post/RepositoryTest.php @@ -0,0 +1,115 @@ +signInUser('subscriber'); + + $container = package()->container(); + + $this->repo = $container->get(Post\Repository::class); + $this->user = $container->get(User\UserFactory::class)->create(); + $this->itemFactory = $container->get(User\ItemFactory::class); + + $postIds = get_posts([ + 'fields' => 'ids', + 'numberposts' => 1, + 'post_status' => 'publish', + ]); + $this->postId = (int) ($postIds[0] ?? 0); +}); + +describe('Post Repository', function (): void { + it('Create: save records the user reaction on the post', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + + expect($this->repo->save($item, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toHaveKey($this->user->id()) + ->and($byUser[$this->user->id()]->id())->toBe($this->postId) + ->and($byUser[$this->user->id()]->isActive())->toBeTrue(); + }); + + it('Read: find returns a map keyed by userId', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($item, $this->user); + + $byPost = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byPost)->toHaveCount(1) + ->and(array_keys($byPost))->toBe([$this->user->id()]); + }); + + it('Update: re-saving replaces the previous entry for the same user', function (): void { + $first = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($first, $this->user); + + $again = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($again, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toHaveCount(1); + }); + + it('Delete: saving an inactive item removes the user from the map', function (): void { + $active = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($active, $this->user); + + $inactive = $this->itemFactory->create( + $this->postId, + 'post', + false, + User\ItemGroup::REACTION + ); + expect($this->repo->save($inactive, $this->user))->toBeTrue(); + + $byUser = $this->repo->find($this->postId, User\ItemGroup::REACTION); + expect($byUser)->toBe([]); + }); + + it('Isolation: a reaction on one post does not appear on another', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($item, $this->user); + + $otherPostId = $this->postId + 999; + $byUser = $this->repo->find($otherPostId, User\ItemGroup::REACTION); + expect($byUser)->toBe([]); + }); +}); diff --git a/tests/functional/php/User/RepositoryTest.php b/tests/functional/php/User/RepositoryTest.php new file mode 100644 index 0000000..8cf2e9d --- /dev/null +++ b/tests/functional/php/User/RepositoryTest.php @@ -0,0 +1,115 @@ +signInUser('subscriber'); + + $container = package()->container(); + + $this->repo = $container->get(User\Repository::class); + $this->user = $container->get(User\UserFactory::class)->create(); + $this->itemFactory = $container->get(User\ItemFactory::class); + + $postIds = get_posts([ + 'fields' => 'ids', + 'numberposts' => 1, + 'post_status' => 'publish', + ]); + $this->postId = (int) ($postIds[0] ?? 0); +}); + +describe('User Repository', function (): void { + it('Create: save persists an active reaction and find returns it', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + + expect($this->repo->save($this->user, $item))->toBeTrue(); + + $found = $this->repo->find($this->user, $this->postId, User\ItemGroup::REACTION); + expect($found->id())->toBe($this->postId) + ->and($found->type())->toBe('post') + ->and($found->isActive())->toBeTrue() + ->and($found->group())->toBe(User\ItemGroup::REACTION); + }); + + it('Read: all returns every saved item in the requested group', function (): void { + $item = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $item); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toHaveCount(1) + ->and($items[$this->postId]->id())->toBe($this->postId); + }); + + it('Update: re-saving the same item replaces the previous row', function (): void { + $first = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $first); + + $again = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $again))->toBeTrue(); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toHaveCount(1) + ->and($items[$this->postId]->id())->toBe($this->postId); + }); + + it('Delete: saving an inactive item removes the row', function (): void { + $active = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $active))->toBeTrue(); + + $inactive = $this->itemFactory->create( + $this->postId, + 'post', + false, + User\ItemGroup::REACTION + ); + expect($this->repo->save($this->user, $inactive))->toBeTrue(); + + $items = $this->repo->all($this->user, User\ItemGroup::REACTION); + expect($items)->toBe([]); + }); + + it('Isolation: saving a reaction does not leak into the bookmark group', function (): void { + $reaction = $this->itemFactory->create( + $this->postId, + 'post', + true, + User\ItemGroup::REACTION + ); + $this->repo->save($this->user, $reaction); + + $bookmarks = $this->repo->all($this->user, User\ItemGroup::BOOKMARK); + expect($bookmarks)->toBe([]); + }); +}); diff --git a/tests/helpers/integration.php b/tests/helpers/integration.php deleted file mode 100644 index acabbfc..0000000 --- a/tests/helpers/integration.php +++ /dev/null @@ -1,68 +0,0 @@ -> $data - * @return array{0: callable, 1: callable, 2: callable, 3: callable} - */ -function setupIntegrationUserMetaStorage(array &$data): array -{ - $stubsCounter = [ - 'get_user_meta' => 0, - 'update_user_meta' => 0, - ]; - - return [ - static function () use (&$stubsCounter): array { - return $stubsCounter; - }, - static function (int $entityId, string $key) use (&$data, &$stubsCounter): array { - $stubsCounter['get_user_meta']++; - return (array)($data[$entityId][$key] ?? null); - }, - static function (int $entityId, string $key, array $newData) use (&$data, &$stubsCounter): bool { - $stubsCounter['update_user_meta']++; - $data[$entityId][$key] = $newData; - return true; - }, - ]; -} - -/** - * @return array{0: callable, 1: callable, 2: callable, 3: callable} - */ -function setupIntegrationPostMetaStorage(array &$data): array -{ - $stubsCounter = [ - 'get_post_meta' => 0, - 'update_post_meta' => 0, - ]; - - return [ - static function () use (&$stubsCounter): array { - return $stubsCounter; - }, - static function (int $entityId, string $key, bool $single) use (&$data, &$stubsCounter): array { - $stubsCounter['get_post_meta']++; - return (array)($data[$entityId][$key] ?? null); - }, - static function (int $entityId, string $key, array $newData) use (&$data, &$stubsCounter): bool { - $stubsCounter['update_post_meta']++; - $data[$entityId][$key] = $newData; - return true; - }, - ]; -} diff --git a/tests/integration/php/PostRepositoryTest.php b/tests/integration/php/PostRepositoryTest.php index eff999f..08f14fa 100644 --- a/tests/integration/php/PostRepositoryTest.php +++ b/tests/integration/php/PostRepositoryTest.php @@ -6,34 +6,45 @@ use Brain\Monkey\Functions; use SpaghettiDojo\Konomi\Post; +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\Tests\Helpers; use SpaghettiDojo\Konomi\User; beforeEach(function (): void { $this->wpUser = \Mockery::mock('\WP_User'); $this->wpUser->ID = 34; - $this->postMetaStorage = includeValidPostUserLikes(); - [, $getter, $setter] = setupIntegrationPostMetaStorage($this->postMetaStorage); - - Functions\when('get_post_meta')->alias($getter); - Functions\when('update_post_meta')->alias($setter); Functions\when('wp_get_current_user')->justReturn($this->wpUser); + $this->postStorage = Helpers\InMemoryStorage::new(); + $this->userStorage = Helpers\InMemoryStorage::new(); + + seedRecords($this->postStorage, 10, User\ItemGroup::REACTION, [ + 100 => 'post', + 21 => 'product', + 33 => 'video', + 45 => 'page', + 53 => 'post', + 6 => 'post', + 79 => 'page', + 83 => 'page', + 92 => 'post', + 1000 => 'post', + ], Storage\Axis::Entity); + $itemRegistryKey = User\ItemRegistryKey::new(); $this->currentUser = User\CurrentUser::new( User\Repository::new( - User\StorageKey::new('_konomi_items'), - User\Storage::new(), + Storage\StorageKey::new(), + $this->userStorage, User\ItemFactory::new(), - User\ItemRegistry::new($itemRegistryKey), - User\RawDataAssert::new() + User\ItemRegistry::new($itemRegistryKey) ) ); $postItemRegistryKey = Post\ItemRegistryKey::new(); $this->repository = Post\Repository::new( - Post\StorageKey::new('_konomi_items'), - Post\Storage::new(), - Post\RawDataAssert::new(), + Storage\StorageKey::new(), + $this->postStorage, User\ItemFactory::new(), Post\ItemRegistry::new($postItemRegistryKey) ); @@ -43,14 +54,10 @@ it('find items from post repository', function (): void { $items = $this->repository->find(10, User\ItemGroup::REACTION); - expect($items) - ->toBeArray() - ->and(count($items))->toBe(10); + expect($items)->toBeArray()->and(count($items))->toBe(10); foreach ($items as $userId => $item) { - expect($userId) - ->toBeInt() - ->and($item instanceof User\Item)->toBeTrue(); + expect($userId)->toBeInt()->and($item instanceof User\Item)->toBeTrue(); } }); @@ -78,17 +85,17 @@ it('save items to post repository', function (): void { $itemToStore = User\Item::new(1, 'type', true); $result = $this->repository->save($itemToStore, $this->currentUser); - $storedItem = User\Item::new( - $this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID][0][0], - $this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID][0][1], - $itemToStore->isActive() - ); + + $stored = $this->postStorage->get(1, 'reaction'); + $matching = array_values(array_filter( + $stored, + fn (Storage\Record $record) => $record->userId === $this->wpUser->ID + )); expect($result)->toBeTrue() - ->and($storedItem->id())->toEqual($itemToStore->id()) - ->and($storedItem->type())->toEqual($itemToStore->type()) - ->and($storedItem->isActive())->toEqual($itemToStore->isActive()) - ->and($storedItem->isValid())->toBeTrue(); + ->and($matching)->toHaveCount(1) + ->and($matching[0]->entityId)->toBe(1) + ->and($matching[0]->entityType)->toBe('type'); }); it('override existing item in post repository', function (): void { @@ -98,10 +105,13 @@ $itemToStore = User\Item::new(1, 'type', false); $result = $this->repository->save($itemToStore, $this->currentUser); - expect($result) - ->toBeTrue() - ->and(isset($this->postMetaStorage[1]['_konomi_items.reaction'][$this->wpUser->ID])) - ->toBeFalse(); + $matching = array_filter( + $this->postStorage->get(1, 'reaction'), + fn (Storage\Record $record) => $record->userId === $this->wpUser->ID + ); + + expect($result)->toBeTrue() + ->and($matching)->toBeEmpty(); }); it('do not store invalid items', function (): void { @@ -109,14 +119,12 @@ $this->repository->save($itemToStore, $this->currentUser); $result = $this->repository->save($itemToStore, $this->currentUser); - expect($result) - ->toBeFalse() - ->and($this->postMetaStorage[-1]['_konomi_items.reaction'][$this->wpUser->ID] ?? []) - ->toBeEmpty(); + expect($result)->toBeFalse() + ->and($this->postStorage->has(-1, 'reaction'))->toBeFalse(); }); it('rollback registry when storage write fails for a new active item', function (): void { - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $itemToStore = User\Item::new(50, 'post', true); @@ -137,7 +145,7 @@ $found = $this->repository->find(10, User\ItemGroup::REACTION); expect($found[$this->wpUser->ID]->isActive())->toBeTrue(); - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $inactiveItem = User\Item::new(10, 'post', false); $result = $this->repository->save($inactiveItem, $this->currentUser); @@ -151,7 +159,7 @@ $find = $this->repository->find(10, User\ItemGroup::REACTION); expect($find)->toHaveCount(10); - Functions\when('update_post_meta')->justReturn(false); + $this->postStorage->failWrites(); $itemToStore = User\Item::new(10, 'post', false); $afterSaving = $this->repository->save($itemToStore, $this->currentUser); diff --git a/tests/integration/php/UserRepositoryTest.php b/tests/integration/php/UserRepositoryTest.php index 3d5ec47..2a6e570 100644 --- a/tests/integration/php/UserRepositoryTest.php +++ b/tests/integration/php/UserRepositoryTest.php @@ -6,6 +6,8 @@ use Brain\Monkey\Actions; use Brain\Monkey\Functions; +use SpaghettiDojo\Konomi\Storage; +use SpaghettiDojo\Konomi\Tests\Helpers; use SpaghettiDojo\Konomi\User; beforeAll(function (): void { @@ -15,20 +17,26 @@ }); beforeEach(function (): void { - $this->userMetaStorage = includeValidUsersLikes(); - [$stubsCounter, $getter, $setter] = setupIntegrationUserMetaStorage($this->userMetaStorage); - $this->stubsCounter = $stubsCounter; - - Functions\when('get_user_meta')->alias($getter); - Functions\when('update_user_meta')->alias($setter); + $this->storage = Helpers\InMemoryStorage::new(); + seedRecords($this->storage, 1, User\ItemGroup::REACTION, [ + 1 => 'product', + 2 => 'page', + ]); + seedRecords($this->storage, 2, User\ItemGroup::REACTION, [ + 100 => 'product', + 20 => 'page', + ]); + seedRecords($this->storage, 3, User\ItemGroup::REACTION, [ + 11 => 'page', + 2 => 'post', + ]); $itemRegistryKey = User\ItemRegistryKey::new(); $this->repository = User\Repository::new( - User\StorageKey::new('_konomi_items'), - User\Storage::new(), + Storage\StorageKey::new(), + $this->storage, User\ItemFactory::new(), - User\ItemRegistry::new($itemRegistryKey), - User\RawDataAssert::new() + User\ItemRegistry::new($itemRegistryKey) ); }); @@ -46,24 +54,7 @@ $this->repository->find($user, 2, User\ItemGroup::REACTION); $this->repository->find($user, 2, User\ItemGroup::REACTION); - expect(($this->stubsCounter)()['get_user_meta'])->toBe(1); - }); - - it('skip invalid stored items when loading', function (): void { - $this->userMetaStorage[1]['_konomi_items.reaction'] = [ - 1 => [1, 'product'], - 2 => 'invalid', - 3 => [3, 'page', 'extra'], - 4 => ['not_int', 'post'], - 5 => [5, 123], // the type must be string - ]; - - $user = User\CurrentUser::new($this->repository); - $items = $this->repository->find($user, 1, User\ItemGroup::REACTION); - - // Only the first valid item should be loaded - expect($items->id())->toBe(1); - expect($items->type())->toBe('product'); + expect($this->storage->reads)->toBe(1); }); it('cannot save an invalid item', function (): void { @@ -75,7 +66,7 @@ $result = $this->repository->save($user, $invalidItem); expect($result)->toBeFalse(); - expect(($this->stubsCounter)()['update_user_meta'])->toBe(0); + expect($this->storage->writes)->toBe(0); }); it('save a valid item', function (): void { @@ -84,9 +75,15 @@ $result = $this->repository->save($user, $item); + $stored = $this->storage->get(1, 'bookmark'); + $matching = array_values(array_filter( + $stored, + fn (Storage\Record $record) => $record->entityId === 1 + )); + expect($result)->toBeTrue(); - expect(($this->stubsCounter)()['update_user_meta'])->toBe(1); - expect($this->userMetaStorage[1]['_konomi_items.bookmark'][1])->toBe([1, 'product']); + expect($this->storage->writes)->toBe(1); + expect($matching)->toHaveCount(1)->and($matching[0]->entityType)->toBe('product'); expect(did_action('konomi.user.repository.save-successfully'))->toBe(1); }); @@ -94,87 +91,71 @@ $user = User\CurrentUser::new($this->repository); $inactiveItem = User\Item::new(1, 'product', false); - expect($this->userMetaStorage[1]['_konomi_items.reaction'])->toEqual([ - 1 => [1, 'product'], - 2 => [2, 'page'], - ]); + $beforeIds = array_map(fn (Storage\Record $record) => $record->entityId, $this->storage->get(1, 'reaction')); + expect($beforeIds)->toBe([1, 2]); $this->repository->save($user, $inactiveItem); - expect($this->userMetaStorage[1]['_konomi_items.reaction'])->toEqual([ - 2 => [2, 'page'], - ]); + $afterIds = array_map(fn (Storage\Record $record) => $record->entityId, $this->storage->get(1, 'reaction')); + expect($afterIds)->toBe([2]); }); it('rollback registry when storage write fails for a new active item', function (): void { $user = User\CurrentUser::new($this->repository); $item = User\Item::new(3, 'post', true, User\ItemGroup::BOOKMARK); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Verify an item is not in the registry before save $foundBefore = $this->repository->find($user, 3, User\ItemGroup::BOOKMARK); - expect($foundBefore->id())->toBe(0); // null item + expect($foundBefore->id())->toBe(0); - // Attempt to save (will fail) $result = $this->repository->save($user, $item); expect($result)->toBeFalse(); - // Verify registry was rolled back - item should not be in the registry $foundAfter = $this->repository->find($user, 3, User\ItemGroup::BOOKMARK); - expect($foundAfter->id())->toBe(0); // null item - rollback successful + expect($foundAfter->id())->toBe(0); }); it('rollback registry when storage write fails for inactive item', function (): void { $user = User\CurrentUser::new($this->repository); $inactiveItem = User\Item::new(1, 'product', false); - // Verify item is in registry before save $foundBefore = $this->repository->find($user, 1, User\ItemGroup::REACTION); expect($foundBefore->id())->toBe(1); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Attempt to save inactive item (will fail) $result = $this->repository->save($user, $inactiveItem); expect($result)->toBeFalse(); - // Verify registry was rolled back - item should still be in the registry as active $foundAfter = $this->repository->find($user, 1, User\ItemGroup::REACTION); - expect($foundAfter->id())->toBe(1); // item restored - rollback successful - expect($foundAfter->isActive())->toBeTrue(); // restored as active + expect($foundAfter->id())->toBe(1); + expect($foundAfter->isActive())->toBeTrue(); }); it('replace method restores entire registry state on rollback with multiple items', function (): void { $user = User\CurrentUser::new($this->repository); - // Load initial items from storage (items 1 and 2) $itemsBefore = $this->repository->all($user, User\ItemGroup::REACTION); expect($itemsBefore)->toHaveCount(2); expect($itemsBefore[1]->id())->toBe(1); expect($itemsBefore[2]->id())->toBe(2); - // Make update_user_meta return false to simulate storage write failure - Functions\when('update_user_meta')->justReturn(false); + $this->storage->failWrites(); - // Attempt to save a new item (will fail) $newItem = User\Item::new(3, 'post', true); $result = $this->repository->save($user, $newItem); expect($result)->toBeFalse(); - // Verify replace method restored the entire registry - all original items should be present $itemsAfter = $this->repository->all($user, User\ItemGroup::REACTION); - expect($itemsAfter)->toHaveCount(2); // Should have same count as before - expect($itemsAfter[1]->id())->toBe(1); // Original item 1 still present - expect($itemsAfter[2]->id())->toBe(2); // Original item 2 still present - expect(isset($itemsAfter[3]))->toBeFalse(); // New item 3 should not be present + expect($itemsAfter)->toHaveCount(2); + expect($itemsAfter[1]->id())->toBe(1); + expect($itemsAfter[2]->id())->toBe(2); + expect(isset($itemsAfter[3]))->toBeFalse(); - // Verify individual items can still be found $found1 = $this->repository->find($user, 1, User\ItemGroup::REACTION); expect($found1->id())->toBe(1); expect($found1->isActive())->toBeTrue(); @@ -184,6 +165,6 @@ expect($found2->isActive())->toBeTrue(); $found3 = $this->repository->find($user, 3, User\ItemGroup::REACTION); - expect($found3->id())->toBe(0); // Should be null item - not in registry + expect($found3->id())->toBe(0); }); }); diff --git a/tests/stubs/php/valid-post-user-reactions.php b/tests/stubs/php/valid-post-user-reactions.php deleted file mode 100644 index b88815c..0000000 --- a/tests/stubs/php/valid-post-user-reactions.php +++ /dev/null @@ -1,40 +0,0 @@ - [ - '_konomi_items.reaction' => [ - 100 => [ - [10, 'post'], - ], - 21 => [ - [10, 'product'], - ], - 33 => [ - [10, 'video'], - ], - 45 => [ - [10, 'page'], - ], - 53 => [ - [10, 'post'], - ], - 6 => [ - [10, 'post'], - ], - 79 => [ - [10, 'page'], - ], - 83 => [ - [10, 'page'], - ], - 92 => [ - [10, 'post'], - ], - 1000 => [ - [10, 'post'], - ], - ], - ], -]; diff --git a/tests/stubs/php/valid-users-reactions.php b/tests/stubs/php/valid-users-reactions.php deleted file mode 100644 index 1a69f63..0000000 --- a/tests/stubs/php/valid-users-reactions.php +++ /dev/null @@ -1,24 +0,0 @@ - [ - '_konomi_items.reaction' => [ - 1 => [1, 'product'], - 2 => [2, 'page'], - ], - ], - 2 => [ - '_konomi_items.reaction' => [ - 100 => [100, 'product'], - 20 => [20, 'page'], - ], - ], - 3 => [ - '_konomi_items.reaction' => [ - 11 => [11, 'page'], - 2 => [2, 'post'], - ], - ], -]; diff --git a/tests/unit/php/PackageTest.php b/tests/unit/php/PackageTest.php index e88c14c..554653a 100644 --- a/tests/unit/php/PackageTest.php +++ b/tests/unit/php/PackageTest.php @@ -5,15 +5,29 @@ namespace SpaghettiDojo\Konomi\Tests\Unit; use Brain\Monkey\Functions; +use Psr\Container\ContainerInterface; +use SpaghettiDojo\Konomi\Activation\ActivationExecute; describe('Package', function (): void { - it('bootstrap the package during plugins_loaded action', function (): void { + it('bootstrap the package and register the lifecycle logic at plugin load', function (): void { $properties = \Mockery::mock( 'alias:Inpsyde\Modularity\Properties\PluginProperties', 'Inpsyde\Modularity\Properties\Properties', ); $properties->shouldReceive('new')->andReturnSelf(); + $activation = \Mockery::mock(ActivationExecute::class); + $activation->expects('prepare'); + $activation->expects('registerActivationLogic'); + $activation->expects('registerDeactivationLogic'); + $activation->expects('registerUninstallLogic'); + + $container = \Mockery::mock(ContainerInterface::class); + $container + ->shouldReceive('get') + ->with(ActivationExecute::class) + ->andReturn($activation); + $package = \Mockery::mock( 'alias:Inpsyde\Modularity\Package', [ @@ -22,7 +36,8 @@ ); $package->shouldReceive('new')->with($properties)->andReturn($package); $package->shouldReceive('addModule')->andReturnSelf(); - + $package->shouldReceive('container')->andReturn($container); + $package->expects('build'); $package->expects('boot'); Functions\expect('add_action') diff --git a/tests/unit/php/Post/ItemRegistryTest.php b/tests/unit/php/Post/ItemRegistryTest.php deleted file mode 100644 index e69de29..0000000 diff --git a/tests/unit/php/Post/RawDataAssertTest.php b/tests/unit/php/Post/RawDataAssertTest.php deleted file mode 100644 index 5f9a278..0000000 --- a/tests/unit/php/Post/RawDataAssertTest.php +++ /dev/null @@ -1,71 +0,0 @@ -rawDataAsserter = RawDataAssert::new(); -}); - -describe('ensureDataStructure', function (): void { - it('validates correct data structure', function (): void { - $data = [ - 1 => [[1, 'post']], - 2 => [[2, 'page'], [3, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe($data); - }); - - it('filters out invalid user IDs', function (): void { - $data = [ - 0 => [[1, 'post']], - -1 => [[2, 'page']], - 'string' => [[3, 'post']], - 1 => [[4, 'page']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([1 => [[4, 'page']]]); - }); - - it('filters out invalid raw items structure', function (): void { - $data = [ - 1 => 'not an array', - 2 => [], - 3 => [[1, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([3 => [[1, 'post']]]); - }); - - it('filters out invalid item format', function (): void { - $data = [ - 1 => [[0, 'post']], - 2 => [['1', 'post']], - 3 => [[1, '']], - 4 => [[1, 123]], - 5 => [[1]], - 6 => [[1, 'post', 'extra']], - 7 => [[1, 'post']], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([7 => [[1, 'post']]]); - }); - - it('handles empty input array', function (): void { - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure([])); - - expect($result)->toBe([]); - }); -}); diff --git a/tests/unit/php/Post/StorageKeyTest.php b/tests/unit/php/Post/StorageKeyTest.php deleted file mode 100644 index 3889649..0000000 --- a/tests/unit/php/Post/StorageKeyTest.php +++ /dev/null @@ -1,49 +0,0 @@ -toBeInstanceOf(StorageKey::class); - }); - - it('throws an assertion error when base key is empty', function (): void { - expect(static fn () => StorageKey::new('')->for(ItemGroup::BOOKMARK))->toThrow(\InvalidArgumentException::class); - }); -}); - -describe('for', function (): void { - it('generates a valid storage key for REACTION group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('test_base.reaction'); - }); - - it('generates a valid storage key for BOOKMARK group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::BOOKMARK); - expect($key)->toBe('test_base.bookmark'); - }); - - it('sanitizes the key by removing non-alphanumeric characters', function (): void { - $base = 'test-base!@#'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('testbase.reaction'); - }); - - it('throws a RuntimeException when key is empty after sanitization', function (): void { - $base = '!@#$%^&*()'; - $storageKey = StorageKey::new($base); - expect(fn () => $storageKey->for(ItemGroup::REACTION))->toThrow(\UnexpectedValueException::class, 'Storage key cannot be empty after sanitization'); - }); -}); diff --git a/tests/unit/php/Post/StorageTest.php b/tests/unit/php/Post/StorageTest.php deleted file mode 100644 index 08845ce..0000000 --- a/tests/unit/php/Post/StorageTest.php +++ /dev/null @@ -1,56 +0,0 @@ -once()->with(1, 'key', true)->andReturn(['data']); - $data = $storage->read(1, 'key'); - expect($data)->toBe(['data']); - }); - - it('write data to the storage', function (): void { - $storage = Storage::new(); - Functions\expect('update_post_meta')->once()->with(1, 'key', ['data'])->andReturn(true); - $result = $storage->write(1, 'key', ['data']); - expect($result)->toBeTrue(); - }); - - it('read returns empty array when id is invalid', function (): void { - $storage = Storage::new(); - Functions\expect('get_post_meta')->never(); - expect($storage->read(0, 'key'))->toBe([]); - expect($storage->read(-1, 'key'))->toBe([]); - }); - - it('read returns empty array when key is empty', function (): void { - $storage = Storage::new(); - Functions\expect('get_post_meta')->never(); - expect($storage->read(1, ''))->toBe([]); - }); - - it('read returns empty array when result is not an array', function (): void { - $storage = Storage::new(); - Functions\expect('get_post_meta')->once()->with(1, 'key', true)->andReturn('not-an-array'); - expect($storage->read(1, 'key'))->toBe([]); - }); - - it('write returns false when id is invalid', function (): void { - $storage = Storage::new(); - Functions\expect('update_post_meta')->never(); - expect($storage->write(0, 'key', ['data']))->toBeFalse(); - expect($storage->write(-1, 'key', ['data']))->toBeFalse(); - }); - - it('write returns false when key is empty', function (): void { - $storage = Storage::new(); - Functions\expect('update_post_meta')->never(); - expect($storage->write(1, '', ['data']))->toBeFalse(); - }); -}); diff --git a/tests/unit/php/Storage/ModuleTest.php b/tests/unit/php/Storage/ModuleTest.php new file mode 100644 index 0000000..1c90e68 --- /dev/null +++ b/tests/unit/php/Storage/ModuleTest.php @@ -0,0 +1,68 @@ +shouldReceive('get') + ->with(InteractionsTable::class) + ->andReturn(InteractionsTable::new('wp_')); + + return $container; +} + +describe('Storage\Module', function (): void { + it('binds a single shared Storage service under Storage::class', function (): void { + $services = Module::new()->services(); + + expect($services)->toHaveKey(Storage::class); + + $storage = $services[Storage::class](storageContainer()); + + expect($storage) + ->toBeInstanceOf(Storage::class) + ->toBeInstanceOf(TableStorage::class); + }); + + it('is swappable via an ExtendingModule keyed to the same Storage::class id', function (): void { + $override = new class () implements ExtendingModule { + use ModuleClassNameIdTrait; + + public function extensions(): array + { + return [ + Storage::class => static fn ( + Storage $original, + ContainerInterface $container + ): Storage => InMemoryStorage::new(), + ]; + } + }; + + $extensions = $override->extensions(); + expect($extensions)->toHaveKey(Storage::class); + + // The extension replaces the shared service the container hands back. + $base = Module::new()->services()[Storage::class](storageContainer()); + $swapped = $extensions[Storage::class]($base, storageContainer()); + + expect($swapped)->toBeInstanceOf(InMemoryStorage::class); + }); +}); diff --git a/tests/unit/php/Storage/RecordTest.php b/tests/unit/php/Storage/RecordTest.php new file mode 100644 index 0000000..5a19e7e --- /dev/null +++ b/tests/unit/php/Storage/RecordTest.php @@ -0,0 +1,16 @@ +entityId)->toBe(10) + ->and($record->userId)->toBe(5) + ->and($record->entityType)->toBe('post'); + }); +}); diff --git a/tests/unit/php/Storage/StorageKeyTest.php b/tests/unit/php/Storage/StorageKeyTest.php new file mode 100644 index 0000000..59bc624 --- /dev/null +++ b/tests/unit/php/Storage/StorageKeyTest.php @@ -0,0 +1,24 @@ +toBeInstanceOf(StorageKey::class); + }); +}); + +describe('for', function (): void { + it('returns the sanitized group value for REACTION', function (): void { + expect(StorageKey::new()->for(ItemGroup::REACTION))->toBe('reaction'); + }); + + it('returns the sanitized group value for BOOKMARK', function (): void { + expect(StorageKey::new()->for(ItemGroup::BOOKMARK))->toBe('bookmark'); + }); +}); diff --git a/tests/unit/php/Storage/TableStorageTest.php b/tests/unit/php/Storage/TableStorageTest.php new file mode 100644 index 0000000..bf1135e --- /dev/null +++ b/tests/unit/php/Storage/TableStorageTest.php @@ -0,0 +1,49 @@ +column())->toBe('entity_id'); + }); + + it('maps User to user_id', function (): void { + expect(Axis::User->column())->toBe('user_id'); + }); +}); + +dataset('axes', [ + 'Entity axis' => [Axis::Entity], + 'User axis' => [Axis::User], +]); + +describe('TableStorage validation', function (): void { + it('returns empty list for invalid id on read', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->read($axis, 0, 'reaction'))->toBe([]) + ->and($storage->read($axis, -1, 'reaction'))->toBe([]); + })->with('axes'); + + it('returns empty list for empty key on read', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->read($axis, 1, ''))->toBe([]); + })->with('axes'); + + it('returns false for invalid id on write', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->write($axis, 0, 'reaction', []))->toBeFalse() + ->and($storage->write($axis, -1, 'reaction', []))->toBeFalse(); + })->with('axes'); + + it('returns false for empty key on write', function (Axis $axis): void { + $storage = TableStorage::new(InteractionsTable::new('wp_')); + expect($storage->write($axis, 1, '', [new Record(1, 1, 'post')]))->toBeFalse(); + })->with('axes'); +}); diff --git a/tests/unit/php/User/RawDataAssertTest.php b/tests/unit/php/User/RawDataAssertTest.php deleted file mode 100644 index 3ce59f3..0000000 --- a/tests/unit/php/User/RawDataAssertTest.php +++ /dev/null @@ -1,74 +0,0 @@ -rawDataAsserter = RawDataAssert::new(); -}); - -describe('ensureDataStructure', function (): void { - it('validates correct data structure', function (): void { - $data = [ - [1, 'post'], - [2, 'page'], - [3, 'video'], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe($data); - }); - - it('filters out invalid raw entities', function (): void { - $data = [ - ['0', 'post'], - [-1, 'video'], - ['abc', 'product'], - [2, 'page'], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect(count($result))->toBe(1); - expect($result)->toBe([[2, 'page']]); - }); - - it('filters out invalid raw items structure', function (): void { - $data = [ - null, - 'string', - [], - [1, 'administrator'], - (object) ['id' => 1], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([[1, 'administrator']]); - }); - - it('filters out invalid item format', function (): void { - $data = [ - [1, ''], - [1, null], - [1], - [1, 'author', 'extra'], - [2, 'editor'], - [3, 123], - ]; - - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure($data)); - - expect($result)->toBe([[2, 'editor']]); - }); - - it('handles empty input array', function (): void { - $result = iterator_to_array($this->rawDataAsserter->ensureDataStructure([])); - - expect($result)->toBe([]); - }); -}); diff --git a/tests/unit/php/User/StorageKeyTest.php b/tests/unit/php/User/StorageKeyTest.php deleted file mode 100644 index 4e4a6e9..0000000 --- a/tests/unit/php/User/StorageKeyTest.php +++ /dev/null @@ -1,50 +0,0 @@ -toBeInstanceOf(StorageKey::class); - }); - - it('throws an assertion error when base key is empty', function (): void { - expect(static fn () => StorageKey::new('')->for(ItemGroup::BOOKMARK))->toThrow(\InvalidArgumentException::class); - }); -}); - -describe('for', function (): void { - it('generates a valid storage key for REACTION group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('test_base.reaction'); - }); - - it('generates a valid storage key for BOOKMARK group', function (): void { - $base = 'test_base'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::BOOKMARK); - expect($key)->toBe('test_base.bookmark'); - }); - - it('sanitizes the key by removing non-alphanumeric characters', function (): void { - $base = 'test-base!@#'; - $storageKey = StorageKey::new($base); - $key = $storageKey->for(ItemGroup::REACTION); - expect($key)->toBe('testbase.reaction'); - }); - - it('throws an UnexpectedValueException when key is empty after sanitization', function (): void { - $base = '!@#$%^&*()'; - $storageKey = StorageKey::new($base); - expect(fn () => $storageKey->for(ItemGroup::REACTION))->toThrow(\UnexpectedValueException::class, 'Storage key cannot be empty after sanitization'); - }); -}); diff --git a/tests/unit/php/User/StorageTest.php b/tests/unit/php/User/StorageTest.php deleted file mode 100644 index 0c4110a..0000000 --- a/tests/unit/php/User/StorageTest.php +++ /dev/null @@ -1,68 +0,0 @@ -storage = User\Storage::new(); -}); - -describe('Storage', function (): void { - it('returns empty array for invalid ID', function (): void { - expect($this->storage->read(0, 'test_key'))->toBe([]); - }); - - it('returns empty array for empty key', function (): void { - expect($this->storage->read(1, ''))->toBe([]); - }); - - it('returns array when meta value is an array', function (): void { - $expected = ['item1', 'item2']; - - Functions\expect('get_user_meta') - ->once() - ->with(1, 'likes', true) - ->andReturn($expected); - - expect($this->storage->read(1, 'likes'))->toBe($expected); - }); - - it('returns empty array when meta value is not an array', function (): void { - Functions\expect('get_user_meta') - ->once() - ->with(1, 'likes', true) - ->andReturn('string_value'); - - expect($this->storage->read(1, 'likes'))->toBe([]); - }); - - it('returns false for invalid ID', function (): void { - expect($this->storage->write(0, 'test_key', []))->toBeFalse(); - }); - - it('returns false for empty key', function (): void { - expect($this->storage->write(1, '', []))->toBeFalse(); - }); - - it('returns true when update is successful', function (): void { - Functions\expect('update_user_meta') - ->once() - ->with(1, 'likes', ['item1', 'item2']) - ->andReturn(1); - - expect($this->storage->write(1, 'likes', ['item1', 'item2']))->toBeTrue(); - }); - - it('returns false when update fails', function (): void { - Functions\expect('update_user_meta') - ->once() - ->with(1, 'likes', []) - ->andReturn(false); - - expect($this->storage->write(1, 'likes', []))->toBeFalse(); - }); -});