From c263735bb904cd79a0290afa274ef8f0dab4ebcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 17:06:04 +0200 Subject: [PATCH 01/13] feat(scope): scoped entity attributes with multi-axis hierarchical fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces marko/scope, marko/scope-mysql, and marko/scope-pgsql — a three-package family that lets entities declare #[Scoped] properties whose overrides are stored in a JSON/JSONB `scopes` column via the existing extender mechanism. Key capabilities: - Multi-axis declared-priority resolution (ScopeWalker) - ScopeContext mutable singleton for request-scoped axis values - ScopeResolver service for read/write/clear of per-scope overrides - ScopedOrderBy QuerySpecification for COALESCE ORDER BY via driver renderers - ScopeRegistryInterface + PhpScopeRegistry (swappable for DB-driven impl) - ScopedEntityValidator for boot-time integrity checks - orderByRaw() added to QueryBuilderInterface and both driver builders Co-Authored-By: Claude Sonnet 4.6 --- .claude/architecture.md | 8 + .../plans/scope/001-bootstrap-marko-scope.md | 37 ++ .../plans/scope/002-scope-value-objects.md | 36 ++ .claude/plans/scope/003-scope-hierarchy.md | 35 ++ .claude/plans/scope/004-scope-exceptions.md | 35 ++ .claude/plans/scope/005-scoped-attribute.md | 27 + .claude/plans/scope/006-scope-registry.md | 31 ++ .claude/plans/scope/007-scope-context.md | 30 ++ .claude/plans/scope/008-scope-metadata.md | 30 ++ .../plans/scope/009-scoped-data-serializer.md | 44 ++ .../scope/010-scoped-overrides-companion.md | 56 ++ .claude/plans/scope/011-scope-walker.md | 49 ++ .claude/plans/scope/012-scope-resolver.md | 34 ++ .claude/plans/scope/013-hydrate-plugin.md | 35 ++ .claude/plans/scope/014-save-plugin.md | 42 ++ .claude/plans/scope/015-sort-expression.md | 38 ++ .../plans/scope/016-scoped-orderby-spec.md | 36 ++ .claude/plans/scope/017-scope-module-php.md | 30 ++ .../plans/scope/018-bootstrap-scope-mysql.md | 27 + .../plans/scope/019-mysql-sort-renderer.md | 38 ++ .../plans/scope/020-mysql-migration-helper.md | 36 ++ .../plans/scope/021-scope-mysql-module-php.md | 24 + .../plans/scope/022-bootstrap-scope-pgsql.md | 32 ++ .../plans/scope/023-pgsql-sort-renderer.md | 38 ++ .../plans/scope/024-pgsql-migration-helper.md | 37 ++ .../plans/scope/025-scope-pgsql-module-php.md | 24 + .claude/plans/scope/026-scope-readme.md | 55 ++ .claude/plans/scope/027-scope-mysql-readme.md | 33 ++ .claude/plans/scope/028-scope-pgsql-readme.md | 34 ++ .../plans/scope/029-orderbyraw-interface.md | 37 ++ .claude/plans/scope/030-orderbyraw-mysql.md | 28 + .claude/plans/scope/031-orderbyraw-pgsql.md | 29 ++ .../plans/scope/032-scoped-orderby-factory.md | 27 + .claude/plans/scope/_devils_advocate.md | 181 +++++++ .claude/plans/scope/_plan.md | 195 +++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 3 + .github/ISSUE_TEMPLATE/feature_request.yml | 3 + composer.json | 18 + docs/src/content/docs/packages/database.md | 4 +- docs/src/content/docs/packages/scope-mysql.md | 119 +++++ docs/src/content/docs/packages/scope-pgsql.md | 98 ++++ docs/src/content/docs/packages/scope.md | 349 +++++++++++++ .../src/Query/MySqlQueryBuilder.php | 75 ++- .../Query/MySqlQueryBuilderOrderByRawTest.php | 200 ++++++++ .../src/Query/PgSqlQueryBuilder.php | 75 ++- .../Query/PgSqlQueryBuilderOrderByRawTest.php | 108 ++++ .../src/Query/QueryBuilderInterface.php | 24 +- .../src/Repository/RepositoryQueryBuilder.php | 58 ++- .../RelationshipLoaderBelongsToManyTest.php | 18 +- .../Entity/RelationshipLoaderNestedTest.php | 9 +- .../tests/Entity/RelationshipLoaderTest.php | 18 +- .../Entity/RelationshipValidationTest.php | 7 + .../tests/Query/QueryBuilderInterfaceTest.php | 20 + .../tests/Query/QuerySpecificationTest.php | 14 + .../Query/SpecEagerLoadCompositionTest.php | 15 + .../Repository/RepositoryMatchingTest.php | 7 + .../RepositoryQueryBuilderEnhancedTest.php | 40 ++ .../tests/Repository/RepositoryTest.php | 7 + .../tests/Repository/RepositoryWithTest.php | 28 + .../tests/Repository/StringPrimaryKeyTest.php | 5 + packages/scope-mysql/.gitattributes | 6 + packages/scope-mysql/LICENSE | 21 + packages/scope-mysql/README.md | 36 ++ packages/scope-mysql/composer.json | 35 ++ packages/scope-mysql/module.php | 15 + .../src/Query/MySqlScopeSortRenderer.php | 78 +++ .../tests/Feature/AutoMigrationTest.php | 186 +++++++ .../tests/PackageScaffoldingTest.php | 58 +++ packages/scope-mysql/tests/Pest.php | 21 + .../scope-mysql/tests/Unit/ModuleTest.php | 34 ++ .../Unit/Query/MySqlScopeSortRendererTest.php | 152 ++++++ .../scope-mysql/tests/Unit/ReadmeTest.php | 44 ++ packages/scope-pgsql/.gitattributes | 5 + packages/scope-pgsql/LICENSE | 21 + packages/scope-pgsql/README.md | 36 ++ packages/scope-pgsql/composer.json | 35 ++ packages/scope-pgsql/module.php | 15 + .../src/Query/PgSqlScopeSortRenderer.php | 63 +++ .../tests/Feature/AutoMigrationTest.php | 177 +++++++ .../tests/PackageScaffoldingTest.php | 58 +++ .../scope-pgsql/tests/Unit/ModuleTest.php | 34 ++ .../Unit/Query/PgSqlScopeSortRendererTest.php | 140 +++++ .../scope-pgsql/tests/Unit/ReadmeTest.php | 45 ++ packages/scope/.gitattributes | 6 + packages/scope/LICENSE | 21 + packages/scope/README.md | 50 ++ packages/scope/composer.json | 35 ++ packages/scope/module.php | 28 + packages/scope/src/Attributes/Scoped.php | 18 + packages/scope/src/Axis/ScopeAxis.php | 15 + packages/scope/src/Context/ScopeContext.php | 77 +++ .../src/Exceptions/NoDriverException.php | 28 + .../ScopeConfigurationException.php | 81 +++ .../src/Exceptions/ScopeContextException.php | 58 +++ .../src/Exceptions/ScopeStorageException.php | 25 + .../src/Exceptions/UnknownAxisException.php | 22 + .../src/Exceptions/UnknownScopeException.php | 25 + .../scope/src/Hierarchy/ScopeHierarchy.php | 98 ++++ packages/scope/src/Metadata/ScopeMetadata.php | 44 ++ .../src/Metadata/ScopeMetadataFactory.php | 66 +++ .../scope/src/Query/ScopeSortExpression.php | 30 ++ .../src/Query/ScopeSortRendererInterface.php | 31 ++ packages/scope/src/Query/ScopedOrderBy.php | 75 +++ .../scope/src/Query/ScopedOrderByFactory.php | 33 ++ .../scope/src/Registry/PhpScopeRegistry.php | 93 ++++ .../src/Registry/ScopeRegistryInterface.php | 29 ++ .../scope/src/Resolution/ScopeWalkResult.php | 33 ++ packages/scope/src/Resolution/ScopeWalker.php | 81 +++ packages/scope/src/Resolver/ScopeResolver.php | 184 +++++++ packages/scope/src/Scope.php | 40 ++ .../src/Storage/ScopedDataSerializer.php | 75 +++ .../src/Storage/ScopedOverridesEntity.php | 72 +++ .../src/Validation/ScopedEntityValidator.php | 65 +++ ...ScopedOverridesEntityDirtyTrackingTest.php | 119 +++++ .../ScopedOverridesPersistenceTest.php | 313 ++++++++++++ .../scope/tests/PackageScaffoldingTest.php | 61 +++ packages/scope/tests/Pest.php | 21 + .../tests/Unit/Attributes/ScopedTest.php | 51 ++ .../tests/Unit/Context/ScopeContextTest.php | 121 +++++ .../Unit/Exceptions/ScopeExceptionsTest.php | 66 +++ .../Unit/Hierarchy/ScopeHierarchyTest.php | 57 +++ .../Metadata/ScopeMetadataFactoryTest.php | 129 +++++ packages/scope/tests/Unit/ModulePhpTest.php | 80 +++ .../Unit/Query/ScopeSortExpressionTest.php | 42 ++ .../Query/ScopeSortRendererInterfaceTest.php | 37 ++ .../Unit/Query/ScopedOrderByFactoryTest.php | 115 +++++ .../tests/Unit/Query/ScopedOrderByTest.php | 482 ++++++++++++++++++ packages/scope/tests/Unit/ReadmeTest.php | 52 ++ .../Unit/Registry/PhpScopeRegistryTest.php | 147 ++++++ .../Registry/ScopeRegistryInterfaceTest.php | 15 + .../tests/Unit/Resolution/ScopeWalkerTest.php | 222 ++++++++ .../tests/Unit/Resolver/ScopeResolverTest.php | 235 +++++++++ packages/scope/tests/Unit/ScopeAxisTest.php | 14 + packages/scope/tests/Unit/ScopeTest.php | 38 ++ .../Unit/Storage/ScopedDataSerializerTest.php | 85 +++ .../Storage/ScopedOverridesEntityTest.php | 116 +++++ .../Validation/ScopedEntityValidatorTest.php | 186 +++++++ 137 files changed, 8465 insertions(+), 22 deletions(-) create mode 100644 .claude/plans/scope/001-bootstrap-marko-scope.md create mode 100644 .claude/plans/scope/002-scope-value-objects.md create mode 100644 .claude/plans/scope/003-scope-hierarchy.md create mode 100644 .claude/plans/scope/004-scope-exceptions.md create mode 100644 .claude/plans/scope/005-scoped-attribute.md create mode 100644 .claude/plans/scope/006-scope-registry.md create mode 100644 .claude/plans/scope/007-scope-context.md create mode 100644 .claude/plans/scope/008-scope-metadata.md create mode 100644 .claude/plans/scope/009-scoped-data-serializer.md create mode 100644 .claude/plans/scope/010-scoped-overrides-companion.md create mode 100644 .claude/plans/scope/011-scope-walker.md create mode 100644 .claude/plans/scope/012-scope-resolver.md create mode 100644 .claude/plans/scope/013-hydrate-plugin.md create mode 100644 .claude/plans/scope/014-save-plugin.md create mode 100644 .claude/plans/scope/015-sort-expression.md create mode 100644 .claude/plans/scope/016-scoped-orderby-spec.md create mode 100644 .claude/plans/scope/017-scope-module-php.md create mode 100644 .claude/plans/scope/018-bootstrap-scope-mysql.md create mode 100644 .claude/plans/scope/019-mysql-sort-renderer.md create mode 100644 .claude/plans/scope/020-mysql-migration-helper.md create mode 100644 .claude/plans/scope/021-scope-mysql-module-php.md create mode 100644 .claude/plans/scope/022-bootstrap-scope-pgsql.md create mode 100644 .claude/plans/scope/023-pgsql-sort-renderer.md create mode 100644 .claude/plans/scope/024-pgsql-migration-helper.md create mode 100644 .claude/plans/scope/025-scope-pgsql-module-php.md create mode 100644 .claude/plans/scope/026-scope-readme.md create mode 100644 .claude/plans/scope/027-scope-mysql-readme.md create mode 100644 .claude/plans/scope/028-scope-pgsql-readme.md create mode 100644 .claude/plans/scope/029-orderbyraw-interface.md create mode 100644 .claude/plans/scope/030-orderbyraw-mysql.md create mode 100644 .claude/plans/scope/031-orderbyraw-pgsql.md create mode 100644 .claude/plans/scope/032-scoped-orderby-factory.md create mode 100644 .claude/plans/scope/_devils_advocate.md create mode 100644 .claude/plans/scope/_plan.md create mode 100644 docs/src/content/docs/packages/scope-mysql.md create mode 100644 docs/src/content/docs/packages/scope-pgsql.md create mode 100644 docs/src/content/docs/packages/scope.md create mode 100644 packages/database-mysql/tests/Query/MySqlQueryBuilderOrderByRawTest.php create mode 100644 packages/database-pgsql/tests/Query/PgSqlQueryBuilderOrderByRawTest.php create mode 100644 packages/scope-mysql/.gitattributes create mode 100644 packages/scope-mysql/LICENSE create mode 100644 packages/scope-mysql/README.md create mode 100644 packages/scope-mysql/composer.json create mode 100644 packages/scope-mysql/module.php create mode 100644 packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php create mode 100644 packages/scope-mysql/tests/Feature/AutoMigrationTest.php create mode 100644 packages/scope-mysql/tests/PackageScaffoldingTest.php create mode 100644 packages/scope-mysql/tests/Pest.php create mode 100644 packages/scope-mysql/tests/Unit/ModuleTest.php create mode 100644 packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php create mode 100644 packages/scope-mysql/tests/Unit/ReadmeTest.php create mode 100644 packages/scope-pgsql/.gitattributes create mode 100644 packages/scope-pgsql/LICENSE create mode 100644 packages/scope-pgsql/README.md create mode 100644 packages/scope-pgsql/composer.json create mode 100644 packages/scope-pgsql/module.php create mode 100644 packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php create mode 100644 packages/scope-pgsql/tests/Feature/AutoMigrationTest.php create mode 100644 packages/scope-pgsql/tests/PackageScaffoldingTest.php create mode 100644 packages/scope-pgsql/tests/Unit/ModuleTest.php create mode 100644 packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php create mode 100644 packages/scope-pgsql/tests/Unit/ReadmeTest.php create mode 100644 packages/scope/.gitattributes create mode 100644 packages/scope/LICENSE create mode 100644 packages/scope/README.md create mode 100644 packages/scope/composer.json create mode 100644 packages/scope/module.php create mode 100644 packages/scope/src/Attributes/Scoped.php create mode 100644 packages/scope/src/Axis/ScopeAxis.php create mode 100644 packages/scope/src/Context/ScopeContext.php create mode 100644 packages/scope/src/Exceptions/NoDriverException.php create mode 100644 packages/scope/src/Exceptions/ScopeConfigurationException.php create mode 100644 packages/scope/src/Exceptions/ScopeContextException.php create mode 100644 packages/scope/src/Exceptions/ScopeStorageException.php create mode 100644 packages/scope/src/Exceptions/UnknownAxisException.php create mode 100644 packages/scope/src/Exceptions/UnknownScopeException.php create mode 100644 packages/scope/src/Hierarchy/ScopeHierarchy.php create mode 100644 packages/scope/src/Metadata/ScopeMetadata.php create mode 100644 packages/scope/src/Metadata/ScopeMetadataFactory.php create mode 100644 packages/scope/src/Query/ScopeSortExpression.php create mode 100644 packages/scope/src/Query/ScopeSortRendererInterface.php create mode 100644 packages/scope/src/Query/ScopedOrderBy.php create mode 100644 packages/scope/src/Query/ScopedOrderByFactory.php create mode 100644 packages/scope/src/Registry/PhpScopeRegistry.php create mode 100644 packages/scope/src/Registry/ScopeRegistryInterface.php create mode 100644 packages/scope/src/Resolution/ScopeWalkResult.php create mode 100644 packages/scope/src/Resolution/ScopeWalker.php create mode 100644 packages/scope/src/Resolver/ScopeResolver.php create mode 100644 packages/scope/src/Scope.php create mode 100644 packages/scope/src/Storage/ScopedDataSerializer.php create mode 100644 packages/scope/src/Storage/ScopedOverridesEntity.php create mode 100644 packages/scope/src/Validation/ScopedEntityValidator.php create mode 100644 packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php create mode 100644 packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php create mode 100644 packages/scope/tests/PackageScaffoldingTest.php create mode 100644 packages/scope/tests/Pest.php create mode 100644 packages/scope/tests/Unit/Attributes/ScopedTest.php create mode 100644 packages/scope/tests/Unit/Context/ScopeContextTest.php create mode 100644 packages/scope/tests/Unit/Exceptions/ScopeExceptionsTest.php create mode 100644 packages/scope/tests/Unit/Hierarchy/ScopeHierarchyTest.php create mode 100644 packages/scope/tests/Unit/Metadata/ScopeMetadataFactoryTest.php create mode 100644 packages/scope/tests/Unit/ModulePhpTest.php create mode 100644 packages/scope/tests/Unit/Query/ScopeSortExpressionTest.php create mode 100644 packages/scope/tests/Unit/Query/ScopeSortRendererInterfaceTest.php create mode 100644 packages/scope/tests/Unit/Query/ScopedOrderByFactoryTest.php create mode 100644 packages/scope/tests/Unit/Query/ScopedOrderByTest.php create mode 100644 packages/scope/tests/Unit/ReadmeTest.php create mode 100644 packages/scope/tests/Unit/Registry/PhpScopeRegistryTest.php create mode 100644 packages/scope/tests/Unit/Registry/ScopeRegistryInterfaceTest.php create mode 100644 packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php create mode 100644 packages/scope/tests/Unit/Resolver/ScopeResolverTest.php create mode 100644 packages/scope/tests/Unit/ScopeAxisTest.php create mode 100644 packages/scope/tests/Unit/ScopeTest.php create mode 100644 packages/scope/tests/Unit/Storage/ScopedDataSerializerTest.php create mode 100644 packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php create mode 100644 packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php diff --git a/.claude/architecture.md b/.claude/architecture.md index 1007046c..8eeeabec 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -220,6 +220,14 @@ When your code depends on `marko/log` (interface) instead of `marko/log-file` (d | `marko/errors-simple` | Driver | Basic error logging | | `marko/errors-advanced` | Driver | Pretty stack traces, suggestions | +### Scope + +| Package | Type | Description | +|---------|------|-------------| +| `marko/scope` | Interface | Scoped entity attributes with multi-axis hierarchical fallback | +| `marko/scope-mysql` | Driver | MySQL/MariaDB driver for scope-aware `ORDER BY` queries | +| `marko/scope-pgsql` | Driver | PostgreSQL driver for scope-aware `ORDER BY` queries | + ### Other Packages | Package | Type | Description | diff --git a/.claude/plans/scope/001-bootstrap-marko-scope.md b/.claude/plans/scope/001-bootstrap-marko-scope.md new file mode 100644 index 00000000..7cb00187 --- /dev/null +++ b/.claude/plans/scope/001-bootstrap-marko-scope.md @@ -0,0 +1,37 @@ +# Task 001: Bootstrap `marko/scope` package skeleton + +**Status**: complete +**Depends on**: none +**Retry count**: 0 + +## Description +Create the `marko/scope` package skeleton: `composer.json`, empty `module.php`, PSR-4 autoload, and src/tests directory layout matching existing Marko packages. + +## Context +- Related files: `packages/scope/` (new), `packages/database/composer.json` (reference), `packages/cache/composer.json` (interface-package reference) +- Patterns to follow: Existing interface packages (`marko/database`, `marko/cache`, `marko/log`). See `.claude/module-development.md` and `.claude/architecture.md` § Package Architecture. + +## Requirements (Test Descriptions) +- [x] `it has a valid composer.json with name marko/scope and extra.marko.module set to true` +- [x] `it requires PHP ^8.5, marko/core, marko/config, and marko/database in composer.json` +- [x] `it autoloads PSR-4 namespace Marko\Scope\ from packages/scope/src/` +- [x] `it autoloads PSR-4 test namespace Marko\Scope\Tests\ from packages/scope/tests/` +- [x] `it has a module.php returning array with empty bindings` +- [x] `it has no version field in composer.json` + +## Required Dependencies +- `marko/core` — DI / plugin / module foundations. +- `marko/config` — `ConfigRepositoryInterface` is read by `PhpScopeRegistry`. +- `marko/database` — base `Entity` and the extender mechanism are required for the override-companion entity. + +## Acceptance Criteria +- Package directory `packages/scope/` exists with `src/`, `tests/Unit/`, `tests/Feature/`, `composer.json`, `module.php`, `LICENSE`. +- `composer install --dry-run` from monorepo root succeeds. +- Lint passes for the new files. + +## Implementation Notes +- Created `packages/scope/` with `src/`, `tests/Unit/`, `tests/Feature/`, `composer.json`, `module.php`, `LICENSE`, and `tests/Pest.php`. +- Package type is `marko-module` (matching `marko/cache` and `marko/log` patterns). +- `module.php` returns `['bindings' => []]` (empty bindings, following `packages/pagination/module.php` pattern). +- Registered the package in the monorepo root `composer.json`: added repository path entry, `require` entry, and `autoload-dev` PSR-4 entry. +- All 6 tests pass; lint clean with no changes needed. diff --git a/.claude/plans/scope/002-scope-value-objects.md b/.claude/plans/scope/002-scope-value-objects.md new file mode 100644 index 00000000..71756ff5 --- /dev/null +++ b/.claude/plans/scope/002-scope-value-objects.md @@ -0,0 +1,36 @@ +# Task 002: `Scope` and `ScopeAxis` value objects + +**Status**: complete +**Depends on**: 001 +**Retry count**: 0 + +## Description +Define the core value objects representing a single scope (`axis:path` tuple) and an axis (named tree of scope paths). Both are immutable readonly classes. + +## Context +- Related files: `packages/scope/src/Axis/ScopeAxis.php` (new), `packages/scope/src/Scope.php` (new) +- Patterns to follow: Existing readonly value objects in `packages/database/src/Schema/Column.php`. Use `readonly class` since all properties are immutable. + +## Requirements (Test Descriptions) +- [x] `it creates a ScopeAxis with name and hierarchy reference` +- [x] `it creates a Scope with axis name and path` +- [x] `it parses a scope string "geo:eu.de" via Scope::fromString` +- [x] `it throws ScopeConfigurationException for malformed scope strings` +- [x] `it formats a Scope back to "axis:path" via Scope::toString` +- [x] `it considers two Scope instances equal when axis and path match` + +## Acceptance Criteria +- `ScopeAxis` is a `readonly class` with name (`string`) and hierarchy (`ScopeHierarchy`) — the hierarchy reference is forward-declared; the class lives in task 003. +- `Scope` is a `readonly class` with `axisName` and `path` accessible via `public private(set)` or getters. +- Round-trip: `Scope::fromString($s)->toString() === $s` for valid inputs. +- Both classes have `@throws` tags where relevant. + +## Implementation Notes +- Created `packages/scope/src/Scope.php` as a `readonly class` with `axisName` and `path` public properties, plus `fromString()` (static factory), `toString()`, and `equals()` methods. +- Created `packages/scope/src/Axis/ScopeAxis.php` as a `readonly class` with `name` and `hierarchy` public properties. +- Created `packages/scope/src/Hierarchy/ScopeHierarchy.php` as a minimal stub (with default empty constructor) for use by `ScopeAxis`; task 003 will flesh this out. +- Created `packages/scope/src/Exception/ScopeConfigurationException.php` as a minimal stub extending `RuntimeException`; task 004 will flesh this out. +- `Scope::fromString()` has `@throws ScopeConfigurationException` tag. +- Round-trip verified: `Scope::fromString($s)->toString() === $s` for valid inputs. +- Requirement 4 passed immediately because the exception-throwing logic was required to implement requirement 3 (both handle `fromString` behavior). +- Ran php-cs-fixer on all created files; linter made minor formatting adjustments (empty constructor braces). diff --git a/.claude/plans/scope/003-scope-hierarchy.md b/.claude/plans/scope/003-scope-hierarchy.md new file mode 100644 index 00000000..0eec30af --- /dev/null +++ b/.claude/plans/scope/003-scope-hierarchy.md @@ -0,0 +1,35 @@ +# Task 003: `ScopeHierarchy` (per-axis tree) + +**Status**: complete +**Depends on**: 001 +**Retry count**: 0 + +## Description +Per-axis tree of scope paths. Supports building from a nested config array, walking from any path up to the root, checking path existence, and ancestor relationships. Materialized-path scheme — paths like `eu.de` imply `eu` is an ancestor. + +## Context +- Related files: `packages/scope/src/Hierarchy/ScopeHierarchy.php` (new) +- Patterns to follow: Readonly value-object style. Pure-PHP — no DB, no I/O. + +## Requirements (Test Descriptions) +- [x] `it builds a hierarchy from a nested array of paths` +- [x] `it walks up from a deep path to root returning the path and all ancestors in order` +- [x] `it returns true from exists for known paths and false for unknown` +- [x] `it identifies parent-child relationships via isAncestor` +- [x] `it throws UnknownScopeException when walking from an unknown path` +- [x] `it rejects duplicate path declarations at construction time` +- [x] `it lists all paths in declaration order` + +## Acceptance Criteria +- Hierarchy is immutable after construction. +- `walkUp('eu.de')` returns `['eu.de', 'eu']` (excludes synthetic root — root path is implicit, represented as empty in the walk or as a special "all" marker; finalize during impl). +- O(1) `exists()` lookup via internal map; O(depth) `walkUp()`. + +## Implementation Notes +- `ScopeHierarchy` uses a flat list of dotted paths (`['eu', 'eu.de', 'eu.fr']`) as the canonical input format via `fromPaths()`. +- Internal `$pathMap` (array) enables O(1) `exists()` lookups. +- `walkUp()` uses `strrpos('.', ...)` to traverse upward in O(depth) time. +- `isAncestor()` uses `str_starts_with($descendant, $ancestor . '.')` — pure string check, no hierarchy state needed. +- Duplicate detection in constructor throws `ScopeConfigurationException::duplicatePath()` (added `duplicatePath` factory method to that exception). +- The constructor accepts `array $paths = []` for backward compatibility with the `ScopeAxisTest` that calls `new ScopeHierarchy()`. +- `UnknownScopeException` was already created by another task; a minimal stub was not needed. diff --git a/.claude/plans/scope/004-scope-exceptions.md b/.claude/plans/scope/004-scope-exceptions.md new file mode 100644 index 00000000..8d7b0004 --- /dev/null +++ b/.claude/plans/scope/004-scope-exceptions.md @@ -0,0 +1,35 @@ +# Task 004: Scope exception family + +**Status**: complete +**Depends on**: 001 +**Retry count**: 0 + +## Description +Define loud-error exceptions for scope misconfiguration, missing axes, missing paths, missing context, and storage errors. All extend `MarkoException` with named `message`/`context`/`suggestion` parameters per the framework's loud-error standard. + +## Context +- Related files: `packages/scope/src/Exceptions/*.php` (new) +- Patterns to follow: `packages/database/src/Exceptions/*.php`, `.claude/code-standards.md` § Exception Standards. Use static factory methods. + +## Requirements (Test Descriptions) +- [x] `it provides UnknownAxisException with axis name and suggestion to register it` +- [x] `it provides UnknownScopeException with axis and path and suggestion` +- [x] `it provides ScopeConfigurationException for malformed scope config` +- [x] `it provides ScopeContextException when reading context for an unset axis or invalid path` +- [x] `it provides ScopeStorageException when the scopes column is missing on save` +- [x] `it extends MarkoException for all scope exceptions` + +## Acceptance Criteria +- Each exception has at least one static factory method covering the most common case. +- Messages include actionable variable values; suggestions guide toward resolution. +- All exceptions registered in `Marko\Scope\Exceptions\` namespace. + +## Implementation Notes +- Created five exception classes in `packages/scope/src/Exceptions/`: + - `UnknownAxisException` — `forAxis(string $axis)` factory + - `UnknownScopeException` — `forAxisAndPath(string $axis, string $path)` factory + - `ScopeConfigurationException` — `malformedConfig(string $axis, string $reason)` and `duplicatePath(string $path)` factories + - `ScopeContextException` — `axisNotSet(string $axis)` and `invalidPath(string $axis, string $path)` factories + - `ScopeStorageException` — `missingColumn(string $column, string $table)` factory +- All extend `MarkoException` with named `message`/`context`/`suggestion` parameters +- Tests in `packages/scope/tests/Unit/Exceptions/ScopeExceptionsTest.php` diff --git a/.claude/plans/scope/005-scoped-attribute.md b/.claude/plans/scope/005-scoped-attribute.md new file mode 100644 index 00000000..93fef17e --- /dev/null +++ b/.claude/plans/scope/005-scoped-attribute.md @@ -0,0 +1,27 @@ +# Task 005: `#[Scoped]` attribute + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Define the `#[Scoped]` PHP attribute that marks an entity property as scope-aware. Accepts an ordered array of axis names — order is the declared-priority for multi-axis resolution. + +## Context +- Related files: `packages/scope/src/Attributes/Scoped.php` (new) +- Patterns to follow: `packages/database/src/Attributes/Column.php`, `.claude/architecture.md` § PHP Attributes. + +## Requirements (Test Descriptions) +- [ ] `it is a readonly class targeting properties only` +- [ ] `it accepts an axes array in the constructor` +- [ ] `it defaults axes to empty array meaning single-axis fallback to registry default` +- [ ] `it preserves axes order as declared` +- [ ] `it is reflectable on a property and round-trips via getAttributes` + +## Acceptance Criteria +- `Attribute::TARGET_PROPERTY` only — applying to methods/classes is a PHP-level error. +- `axes` property is `public readonly array` (or `public private(set)`). +- Validation of axis names against the registry happens in `ScopeMetadataFactory` (task 008), not in the attribute itself. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/006-scope-registry.md b/.claude/plans/scope/006-scope-registry.md new file mode 100644 index 00000000..af1031ab --- /dev/null +++ b/.claude/plans/scope/006-scope-registry.md @@ -0,0 +1,31 @@ +# Task 006: `ScopeRegistryInterface` + `PhpScopeRegistry` + +**Status**: pending +**Depends on**: 002, 003, 004 +**Retry count**: 0 + +## Description +Define the registry contract apps depend on and ship the PHP-config-backed default implementation. The interface is the extension point for the future DB-driven registry — apps never depend on the implementation. + +## Context +- Related files: `packages/scope/src/Registry/ScopeRegistryInterface.php` (new), `packages/scope/src/Registry/PhpScopeRegistry.php` (new) +- Patterns to follow: Interface/impl split — see `marko/database` ConnectionInterface vs driver impls. Load axes from `marko/config` via `ConfigRepositoryInterface`. +- Config shape (from `config/scope.php`): `['axes' => ['geo' => ['hierarchy' => [...]], 'locale' => [...]]]`. + +## Requirements (Test Descriptions) +- [ ] `it defines ScopeRegistryInterface with hasAxis, getAxis, listAxes, getHierarchy methods` +- [ ] `it loads axes from injected config into PhpScopeRegistry` +- [ ] `it returns ScopeAxis instances from getAxis for registered names` +- [ ] `it throws UnknownAxisException when getAxis is called with unknown axis` +- [ ] `it throws ScopeConfigurationException when config has duplicate axis names` +- [ ] `it throws ScopeConfigurationException when config shape is malformed` +- [ ] `it returns the list of all registered axis names in registration order` + +## Acceptance Criteria +- Interface is in `Marko\Scope\Registry\ScopeRegistryInterface`. +- `PhpScopeRegistry` is `readonly class` if all properties are immutable after construction. +- Validation runs in the constructor; no axis is half-registered on failure. +- `getHierarchy(axisName)` returns the `ScopeHierarchy` from the axis. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/007-scope-context.md b/.claude/plans/scope/007-scope-context.md new file mode 100644 index 00000000..035a49ae --- /dev/null +++ b/.claude/plans/scope/007-scope-context.md @@ -0,0 +1,30 @@ +# Task 007: `ScopeContext` (request-scoped current scope) + +**Status**: pending +**Depends on**: 002, 004, 006 +**Retry count**: 0 + +## Description +Holds the current `Scope` per axis for the active request. Mutable, fluent (`->in($axis, $path)`), validated against the registry. Will be registered as a singleton in `module.php` so the same instance is shared across the request. + +## Context +- Related files: `packages/scope/src/Context/ScopeContext.php` (new) +- Patterns to follow: Singleton service pattern; validation via injected `ScopeRegistryInterface`. + +## Requirements (Test Descriptions) +- [ ] `it accepts a current scope for an axis via in and is fluent` +- [ ] `it returns the current scope path for a set axis via get` +- [ ] `it returns null from get for an unset axis` +- [ ] `it throws UnknownAxisException when in is called with an unknown axis` +- [ ] `it throws ScopeContextException when in is called with a path not in the axis hierarchy` +- [ ] `it clears a single axis via clear and all axes via clearAll` +- [ ] `it lists all axes currently set via activeAxes` + +## Acceptance Criteria +- Constructor takes `ScopeRegistryInterface`. +- Internal state is `array` (axis → path). +- Not `readonly class` — mutable state. +- Class docblock explicitly documents the lifecycle: mutable singleton, intended to be set up once per HTTP request / CLI command / queue job. In long-running PHP processes (FPM workers, queue daemons), the bootstrap layer MUST call `clearAll()` between requests/jobs to avoid cross-request leakage. Marked with `@noinspection` notes if the linter complains about mutable singletons. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/008-scope-metadata.md b/.claude/plans/scope/008-scope-metadata.md new file mode 100644 index 00000000..9017f6ca --- /dev/null +++ b/.claude/plans/scope/008-scope-metadata.md @@ -0,0 +1,30 @@ +# Task 008: `ScopeMetadata` + `ScopeMetadataFactory` + +**Status**: pending +**Depends on**: 005, 006 +**Retry count**: 0 + +## Description +Reflection-based metadata layer that introspects an entity class for `#[Scoped]` properties, validates declared axes against the registry, and caches the resulting metadata per class. + +## Context +- Related files: `packages/scope/src/Metadata/ScopeMetadata.php`, `packages/scope/src/Metadata/ScopeMetadataFactory.php` (new) +- Patterns to follow: `packages/database/src/Entity/EntityMetadataFactory.php` for reflection + caching style. Independent of `EntityMetadata` — does not modify `marko/database`. + +## Requirements (Test Descriptions) +- [ ] `it returns empty metadata for entity classes with no Scoped properties` +- [ ] `it discovers Scoped properties on an entity class via reflection` +- [ ] `it returns declared axes for a scoped property in declaration order` +- [ ] `it caches metadata per class within the factory` +- [ ] `it throws UnknownAxisException when a Scoped property declares an unknown axis` +- [ ] `it reports whether a property is scoped via isScoped` +- [ ] `it lists all scoped property names via scopedProperties` + +## Acceptance Criteria +- `ScopeMetadata` is `readonly class`. +- Factory caches by FQCN; the same factory instance returns the same metadata object for repeated calls. +- Validation of axes against the registry happens once per class at factory time, not per call. +- `ScopeMetadata::hasScopedProperties(): bool` is exposed so callers (resolver, validator, factory) can cheaply short-circuit for non-scoped classes. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/009-scoped-data-serializer.md b/.claude/plans/scope/009-scoped-data-serializer.md new file mode 100644 index 00000000..45855098 --- /dev/null +++ b/.claude/plans/scope/009-scoped-data-serializer.md @@ -0,0 +1,44 @@ +# Task 009: `ScopedDataSerializer` + +**Status**: complete +**Depends on**: 002, 004 +**Retry count**: 0 + +## Description +JSON serialization/deserialization for the `scopes` column. Implements the agreed shape: `{"axis:path": {"property": value, ...}, ...}`. Handles type conversion for `BackedEnum` and `DateTimeImmutable` so saved JSON is portable. + +## Context +- Related files: `packages/scope/src/Storage/ScopedDataSerializer.php` (new) +- Patterns to follow: `Marko\Database\Repository\Repository::convertToDbValue()` for the BackedEnum/DateTime conversion pattern. + +## Data Shape Contract +The canonical override map is keyed by scope key first, property second: + +```php +[ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], +] +``` + +This shape is shared with `ScopedOverridesEntity` (task 010), `ScopeWalker` (task 011), `ScopedEntityValidator` (task 013), and the SQL renderers (tasks 015, 019, 023). The serializer round-trips this map; the JSON column stores `null` when the map is empty. + +## Requirements (Test Descriptions) +- [x] `it serializes an empty overrides map to null rather than empty object` +- [x] `it serializes nested overrides keyed by axis colon path` +- [x] `it deserializes valid JSON into a flat array structure` +- [x] `it round-trips BackedEnum values via their backing scalar` +- [x] `it round-trips DateTimeImmutable values via formatted string` +- [x] `it throws ScopeConfigurationException when deserialized JSON is malformed` +- [x] `it deserializes null or empty string into an empty overrides map` + +## Acceptance Criteria +- Uses `json_encode`/`json_decode` with `JSON_THROW_ON_ERROR` flag; converts to `ScopeConfigurationException`. +- Stateless — `readonly class` with no dependencies, or a final-free static-like utility. +- Round-trip: `deserialize(serialize($x))` returns equivalent structure for `$x`. + +## Implementation Notes +- `ScopedDataSerializer` is a `readonly class` with no constructor dependencies. +- `serialize()` converts `BackedEnum` via `.value` and `DateTimeImmutable` via `format('Y-m-d H:i:s')` before calling `json_encode(..., JSON_THROW_ON_ERROR)`. Returns `null` for empty map. +- `deserialize()` returns `[]` for `null`/`''` input; wraps `JsonException` in `ScopeConfigurationException`. +- PHP's `json_encode` natively serializes `BackedEnum` to its backing scalar, but explicit conversion is used for clarity and consistency with the Database `convertToDbValue()` pattern. diff --git a/.claude/plans/scope/010-scoped-overrides-companion.md b/.claude/plans/scope/010-scoped-overrides-companion.md new file mode 100644 index 00000000..95ada6f0 --- /dev/null +++ b/.claude/plans/scope/010-scoped-overrides-companion.md @@ -0,0 +1,56 @@ +# Task 010: `ScopedOverridesEntity` base class (entity extender) + +**Status**: pending +**Depends on**: 002, 009 +**Retry count**: 0 + +## Description +The per-entity override container, implemented as an abstract base `Entity` subclass intended to be extended once per scoped parent entity via the existing `marko/database` extender mechanism (`#[Table(extends: Parent::class)]`). Stores a single JSON column `scopes` on the parent's table (no JOIN required, mirroring the `TimestampsExtender` pattern). Dirty tracking, INSERT, and UPDATE all flow through the existing `Repository` companion path — no custom save plugin is needed. + +App developers declare one companion class per scoped parent entity, e.g.: + +```php +#[Table(extends: Product::class)] +class ProductScopedOverrides extends ScopedOverridesEntity {} +``` + +The base class supplies the `#[Column(name: 'scopes', type: 'json', nullable: true)]` array property and the override accessors. Subclasses add only the `#[Table(extends: ...)]` attribute. + +## Context +- Related files: `packages/scope/src/Storage/ScopedOverridesEntity.php` (new), `packages/database/src/Entity/Entity.php` (read-only — extender mechanism in `EntityMetadataFactory` / `SchemaRegistry`) +- Patterns to follow: Extender pattern — see `packages/database/src/Attributes/Table.php`, `EntityMetadataFactory::linkExtenders`, and `Repository::update` companion dirty-tracking (lines 632-700). The base class declares the column; subclasses declare the parent linkage. + +## Data Shape Contract +Internal storage of the JSON column is a flat map keyed by scope key first, property second: + +```php +// $this->scopes maps to: +[ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], +] +``` + +This shape is shared with `ScopedDataSerializer` (task 009), `ScopeWalker` (task 011), and the SQL renderers (tasks 015, 019, 023). Scope-key-first ordering enables O(1) lookups during walker resolution. + +## Requirements (Test Descriptions) +- [ ] `it is an abstract Entity subclass with a Scoped column named scopes typed as json` +- [ ] `it stores an override keyed by scope key and property via setOverride` +- [ ] `it returns the stored override via getOverride for the same property and scope` +- [ ] `it returns null from getOverride when no override exists at that scope` +- [ ] `it removes an override via clearOverride and getOverride returns null afterward` +- [ ] `it removes the entire scope-key sub-map when its last property is cleared` +- [ ] `it distinguishes an explicit null override from no override via hasOverride` +- [ ] `it lists all overrides via allOverrides as the flat scope-key-first map` +- [ ] `it functions as a companion attached via Entity::attachCompanion and is retrievable via Entity::companion` +- [ ] `it participates in Repository::save dirty tracking via the existing companion path` (feature test) + +## Acceptance Criteria +- Class is `abstract class ScopedOverridesEntity extends Entity`. +- Declares a single `#[Column(name: 'scopes', type: 'json', nullable: true)] public ?array $scopes = null;` property whose value follows the Data Shape Contract above. +- Accessor methods (`setOverride`, `getOverride`, `clearOverride`, `hasOverride`, `allOverrides`) operate on `$this->scopes` directly so that the existing `EntityHydrator::getDirtyProperties` flow detects changes. +- App-developer subclasses only need `#[Table(extends: ParentEntity::class)]` to wire into the parent. +- No `markClean`/baseline snapshotting — dirty tracking is delegated to `EntityHydrator::originalValues` via the existing companion path. + +## Implementation Notes +- **Dirty-tracking gotcha:** `EntityHydrator::valuesEqual` uses strict `===` for non-DateTime / non-Enum values. PHP arrays are `===` only when keys/values are in the same insertion order. Mutating `$this->scopes` via `setOverride` may reorder keys. To keep dirty tracking accurate, `setOverride` / `clearOverride` MUST canonicalise the scope-key ordering (e.g. ksort the top-level keys, ksort the inner property arrays) on every mutation. The serializer (task 009) should canonicalise on the way out as well so JSON in the database is deterministic. diff --git a/.claude/plans/scope/011-scope-walker.md b/.claude/plans/scope/011-scope-walker.md new file mode 100644 index 00000000..dd2cf32f --- /dev/null +++ b/.claude/plans/scope/011-scope-walker.md @@ -0,0 +1,49 @@ +# Task 011: `ScopeWalker` (multi-axis resolution) + +**Status**: pending +**Depends on**: 003, 006, 007, 010 +**Retry count**: 0 + +## Description +The core resolution algorithm. Given a property's declared axes (in priority order), a `ScopedOverrides` companion, and the current `ScopeContext`, walks each axis from its current scope up to the root and returns the first matching override. Returns a sentinel "no override" result so callers can distinguish "found null" from "nothing found." + +## Context +- Related files: `packages/scope/src/Resolution/ScopeWalker.php` (new), `packages/scope/src/Resolution/ScopeWalkResult.php` (new — small value object for the not-found sentinel) +- Patterns to follow: Pure-PHP, no I/O. Walker emits the same resolution as the SQL emitters in the driver packages. + +## Data Shape Contract +The walker reads from the scope-key-first override map (see tasks 009 / 010): + +```php +[ + 'geo:eu.de' => ['name' => 'Hemd'], + 'geo:eu' => ['name' => 'Shirt-EU'], + 'locale:de' => ['name_label' => 'Hallo'], +] +``` + +For property `'name'` with declared axes `['geo', 'locale']` and a `ScopeContext` of `geo=eu.de`, `locale=de-DE`: +1. Walk `geo` from `eu.de → eu`: check `'geo:eu.de'` → hit, return `'Hemd'`. +2. Otherwise walk `locale` from `de-DE → de`: check `'locale:de-DE'` → miss, `'locale:de'` → miss for `name`. +3. Result: `notFound`. + +An explicit `null` stored at any walked scope key counts as a hit and short-circuits the walk. + +## Requirements (Test Descriptions) +- [ ] `it returns the override at the current scope when one exists` +- [ ] `it returns an ancestor override when no override exists at the current scope` +- [ ] `it returns notFound when no override exists at any walked scope` +- [ ] `it walks axes in declared priority order returning the first axis match` +- [ ] `it skips axes that are not set in ScopeContext` +- [ ] `it preserves an explicit null override and does not fall through it within an axis` +- [ ] `it falls through an axis with no overrides for the property to the next axis (cross-axis fallthrough)` +- [ ] `it stops cross-axis fallthrough when an explicit null is found in an axis (null counts as a found value)` +- [ ] `it returns notFound when no axes are declared and no overrides exist` + +## Acceptance Criteria +- `ScopeWalker::walk(ScopedOverrides $overrides, string $property, array $axes): ScopeWalkResult` +- `ScopeWalkResult` carries either `found(mixed $value)` or `notFound()`. +- Pure function — no mutation, no I/O. Fully unit-testable without a DB. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/012-scope-resolver.md b/.claude/plans/scope/012-scope-resolver.md new file mode 100644 index 00000000..58a843a9 --- /dev/null +++ b/.claude/plans/scope/012-scope-resolver.md @@ -0,0 +1,34 @@ +# Task 012: `ScopeResolver` service + +**Status**: pending +**Depends on**: 008, 010, 011 +**Retry count**: 0 + +## Description +The app-facing API for resolving and mutating scoped attributes on entities. Combines metadata + walker + override mutations. This is the primary surface app code uses; the hydration/save plugins (tasks 013/014) sit underneath it. + +## Context +- Related files: `packages/scope/src/Resolver/ScopeResolver.php` (new) +- Patterns to follow: Plain service, constructor injection of `ScopeMetadataFactory`, `ScopeWalker`, `ScopeContext`. No state. + +## Requirements (Test Descriptions) +- [ ] `it resolves a property value via current ScopeContext returning the walker match` +- [ ] `it falls back to the entity's column property value when no override is found` +- [ ] `it resolves at an explicit scope via resolvedAt without consulting ScopeContext` +- [ ] `it sets an override via setOverride attaching a ScopedOverridesEntity companion if missing` +- [ ] `it sets an override on a new (unsaved) entity then saves so both rows reflect the override` (feature) +- [ ] `it clears an override via clearOverride leaving the companion otherwise intact` +- [ ] `it throws ScopeContextException when resolving an unknown property` +- [ ] `it throws ScopeContextException when setOverride targets a property without Scoped` +- [ ] `it discovers the correct ScopedOverridesEntity subclass for a given parent entity class via EntityMetadata::extenders` + +## Acceptance Criteria +- `readonly class` — no mutable state. +- Constructor takes `ScopeMetadataFactory`, `ScopeWalker`, `ScopeContext`, and `EntityMetadataFactory` (the last is used to find the correct `ScopedOverridesEntity` subclass via `EntityMetadata::extenders`). +- `resolved(Entity $entity, string $property): mixed` reads ScopeContext. +- `resolvedAt(Entity $entity, string $property, Scope $scope): mixed` builds a one-shot context view of `$scope`. +- `setOverride` / `clearOverride` validate that `$scope`'s axis is among the property's declared axes. +- `setOverride` on an entity without an attached companion instantiates the correct `ScopedOverridesEntity` subclass (looked up via `EntityMetadata::extenders` filtered by `is_subclass_of(..., ScopedOverridesEntity::class)`), attaches it via `$entity->attachCompanion()`, and writes the override. The parent's PK does not need to exist yet — `Repository::insert` will handle the companion at save time. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/013-hydrate-plugin.md b/.claude/plans/scope/013-hydrate-plugin.md new file mode 100644 index 00000000..6d210943 --- /dev/null +++ b/.claude/plans/scope/013-hydrate-plugin.md @@ -0,0 +1,35 @@ +# Task 013: `ScopedEntityValidator` (boot-time integrity check) + +**Status**: pending +**Depends on**: 008, 010 + +## Description +With the override companion implemented as a normal `Entity` extender (task 010), hydration and persistence work via the existing `marko/database` machinery — no `EntityHydrator::hydrate` plugin is required. + +This task replaces the original "hydrate plugin" with a boot-time validator: for every entity class that declares at least one `#[Scoped]` property, there MUST be a registered `ScopedOverridesEntity` subclass with `#[Table(extends: ThatEntity::class)]`. Otherwise, an entity could declare scoped fields whose overrides would silently go nowhere. + +The validator scans entity classes via `ScopeMetadataFactory` (the same factory that drives the resolver) and asks `EntityMetadata::extenders` for the corresponding override extender. The exception is loud and actionable — it names the missing companion class and shows the exact one-line declaration the developer needs to add. + +## Context +- Related files: `packages/scope/src/Validation/ScopedEntityValidator.php` (new), `packages/scope/src/Exceptions/ScopeConfigurationException.php` (extend with `missingOverridesExtender` factory) +- Patterns to follow: Boot-time validation pattern used elsewhere in marko-* packages. Loud error with `ScopeConfigurationException` (task 004). +- Integration: invoked by `module.php` `boot` hook OR by a CLI `scope:check` command (decide during implementation; if both, boot hook is authoritative). The test surface is a direct unit test against the validator class. + +## Requirements (Test Descriptions) +- [ ] `it passes for an entity with no Scoped properties` +- [ ] `it passes for an entity with Scoped properties when a matching ScopedOverridesEntity extender is registered` +- [ ] `it throws ScopeConfigurationException when an entity has Scoped properties but no extender is linked` +- [ ] `it throws ScopeConfigurationException when the linked extender does not extend ScopedOverridesEntity` +- [ ] `it includes the parent entity FQCN in the exception message` +- [ ] `it lists each Scoped property and its declared axes in the exception context` +- [ ] `it provides a one-line class declaration including the Table extends attribute in the exception suggestion` + +## Acceptance Criteria +- `ScopedEntityValidator::validate(string $entityClass): void` accepts an entity FQCN and throws on failure. +- Reads `ScopeMetadataFactory::for($entityClass)` to learn whether the entity has any scoped properties. Returns immediately when `hasScopedProperties()` is false. +- Reads `EntityMetadataFactory::parse($entityClass)->extenders` and asserts at least one entry is a subclass of `ScopedOverridesEntity`. +- Exception thrown as `ScopeConfigurationException::missingOverridesExtender(string $parentClass, array $scopedProperties)` static factory. The message names the parent FQCN; context enumerates `#[Scoped]` properties with their axes; suggestion shows the exact `#[Table(extends: ...)] class {Parent}ScopedOverrides extends ScopedOverridesEntity {}` snippet ready to paste. +- A second variant covers "extender exists but wrong base class" — `ScopeConfigurationException::wrongOverridesExtenderBase(string $parentClass, string $extenderClass)`. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/014-save-plugin.md b/.claude/plans/scope/014-save-plugin.md new file mode 100644 index 00000000..0d3bfb36 --- /dev/null +++ b/.claude/plans/scope/014-save-plugin.md @@ -0,0 +1,42 @@ +# Task 014: Save-path integration test for `ScopedOverridesEntity` + +**Status**: complete +**Depends on**: 010, 012, 013 + +## Description +With overrides stored on a normal `Entity` extender (task 010), `Repository::save` already persists them — `Repository::insert` calls `EntityHydrator::extractAll` which iterates companions, and `Repository::update` dirty-tracks companion fields and merges them into the same UPDATE. No save plugin is required. + +This task adds end-to-end feature tests that confirm the extender path works for scoped overrides: +- Insert a fresh entity with no overrides → JSON column written as NULL. +- Insert with overrides → JSON column written with the serialized map. +- Update an existing entity, mutating only the override → only the `scopes` column is updated, parent columns untouched. +- Clearing all overrides on an entity that previously had some → `scopes` reverts to NULL. + +The original "Repository::save plugin" approach is dropped because the `marko/core` plugin system does not walk parent class hierarchies (`Repository` is `abstract`, user repositories are concrete subclasses, and `PluginRegistry::getEffectiveTargetClass` only inspects interfaces — confirmed in `packages/core/src/Plugin/PluginRegistry.php`). + +## Context +- Related files: `packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php` (new) +- Patterns to follow: Existing repository feature tests in `packages/database/tests/Feature/`. Anonymous-class repositories or fixture entity classes that exercise insert + update through a real (sqlite-in-memory or pgsql/mysql Docker) connection. +- Cross-driver: feature tests parameterized over both drivers if practical; otherwise per-driver smoke tests in the driver packages with the heavy logic exercised against sqlite in the base package. + +## Requirements (Test Descriptions) +- [x] `it saves an entity with no overrides leaving the scopes column null` (feature) +- [x] `it saves an entity with overrides serializing them into the scopes column` (feature) +- [x] `it updates only the scopes column when only overrides change` (feature) +- [x] `it writes null into the scopes column when all overrides are cleared` (feature) +- [x] `it round-trips overrides via save then re-hydrate via find` (feature) +- [x] `it dirty-tracks the override companion via Repository::update without a custom plugin` (feature) + +## Acceptance Criteria +- Tests live in `packages/scope/tests/Feature/`. +- No `#[Plugin]` classes are introduced for save — the companion is just an extender entity. +- Tests cover both insert and update code paths in `Repository`. + +## Implementation Notes +Tests implemented in `packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php`. + +Pattern used: mock `ConnectionInterface` that logs SQL/bindings to a reference array. All 6 tests passed immediately because `Repository::insert` (via `extractAll`) and `Repository::update` (via companion dirty-tracking) already handled the companion extender path without any plugin. + +The round-trip test uses a more sophisticated mock connection that captures the serialized scopes from INSERT bindings and returns them on SELECT, enabling `hydrate()` to reconstruct the companion with its original values. + +No `#[Plugin]` classes were introduced — the existing companion extender path handles everything. diff --git a/.claude/plans/scope/015-sort-expression.md b/.claude/plans/scope/015-sort-expression.md new file mode 100644 index 00000000..b96a2b72 --- /dev/null +++ b/.claude/plans/scope/015-sort-expression.md @@ -0,0 +1,38 @@ +# Task 015: `ScopeSortExpression` + `ScopeSortRendererInterface` + +**Status**: complete +**Depends on**: 002, 008 +**Retry count**: 0 + +## Description +Driver-agnostic representation of a scope-aware sort: which property, the column it falls back to, the axes-and-paths in priority+walk order, and the direction. The `ScopeSortRendererInterface` is implemented by drivers to emit DB-specific SQL. + +## Context +- Related files: `packages/scope/src/Query/ScopeSortExpression.php`, `packages/scope/src/Query/ScopeSortRendererInterface.php` (new) +- Patterns to follow: Value object + interface. Renderers in driver packages implement the interface. + +## Requirements (Test Descriptions) +- [x] `it creates a ScopeSortExpression with property, column, axis walk paths in order, and direction` +- [x] `it is a readonly value object` +- [x] `it validates direction is asc or desc` +- [x] `it defines ScopeSortRendererInterface with render(ScopeSortExpression) returning a SQL fragment` +- [x] `it documents that renderers must produce a COALESCE expression matching the walker order` + +## Acceptance Criteria +- `ScopeSortExpression` is `readonly class`. +- `direction` is validated against `['asc', 'desc']` allowlist at construction. +- The expression carries: + - `string $property` — entity property name (must pass `IdentifierValidator::isValidIdentifier`). + - `string $column` — entity column name (must pass `IdentifierValidator::isValidIdentifier`). + - `string $jsonColumn` — the JSON column name, defaulting to `'scopes'` (must pass `IdentifierValidator::isValidIdentifier`). + - `array $paths` — ordered list of `['axis' => string, 'path' => string]` entries in declared-axis-priority + deepest-first walk order. **Each `axis` must pass `IdentifierValidator::isValidIdentifier`. The `path` part may contain `.` separators (e.g. `eu.de`) — each dot-segment must pass the identifier check separately. Renderers compose the JSON key from validated parts.** + - `string $direction`. +- Interface in `Marko\Scope\Query\ScopeSortRendererInterface` with method `render(ScopeSortExpression $expression): string`. +- Interface PHPDoc documents that renderers must produce a COALESCE expression matching the PHP walker resolution order and that renderers MUST NOT pass the colon-composed scope key (`axis:path`) through `IdentifierValidator::isValidIdentifier` — composition happens after validation. + +## Implementation Notes +- `ScopeSortExpression` is a `readonly class` with constructor property promotion. +- Direction validated against `['asc', 'desc']` allowlist, throwing `ScopeConfigurationException` for invalid values. +- `$jsonColumn` defaults to `'scopes'` as a constructor parameter default. +- `ScopeSortRendererInterface` PHPDoc documents COALESCE requirement and walker resolution order matching. +- Tests: `packages/scope/tests/Unit/Query/ScopeSortExpressionTest.php` and `packages/scope/tests/Unit/Query/ScopeSortRendererInterfaceTest.php`. diff --git a/.claude/plans/scope/016-scoped-orderby-spec.md b/.claude/plans/scope/016-scoped-orderby-spec.md new file mode 100644 index 00000000..f1ceca6c --- /dev/null +++ b/.claude/plans/scope/016-scoped-orderby-spec.md @@ -0,0 +1,36 @@ +# Task 016: `ScopedOrderBy` QuerySpecification + +**Status**: complete +**Depends on**: 008, 015, 029 + +## Description +A `QuerySpecification` that adds a scope-aware `ORDER BY` to a `RepositoryQueryBuilder`. The spec is a value object — its dependencies (renderer, metadata factory, scope context) are injected via constructor; do NOT pull from the container at apply-time. App code typically constructs the spec via the `ScopedOrderByFactory` service (task 032). + +`apply()` builds a `ScopeSortExpression` from the metadata of the entity class associated with the builder, asks the injected renderer for SQL, and calls `$builder->orderByRaw($expression, $direction)` (added in task 029). + +## Context +- Related files: `packages/scope/src/Query/ScopedOrderBy.php` (new) +- Patterns to follow: Existing `QuerySpecification` interface and existing specs in `packages/database/tests/Query/`. The builder param is `EntityQueryBuilderInterface` — `orderByRaw` must therefore exist on that interface too (provided by task 029 through `QueryBuilderInterface`). + +## Requirements (Test Descriptions) +- [x] `it accepts a property name and optional direction defaulting to asc` +- [x] `it accepts ScopeMetadataFactory, ScopeContext, and ScopeSortRendererInterface in constructor` +- [x] `it builds a ScopeSortExpression by reading ScopeMetadata for the entity class on apply` +- [x] `it throws ScopeContextException when the property is not Scoped` +- [x] `it falls back to plain orderBy when no scope is active for any of the property's axes` +- [x] `it calls orderByRaw with the renderer-generated COALESCE expression when scope is active` +- [x] `it preserves direction asc or desc on the emitted ORDER BY` + +## Acceptance Criteria +- Implements `Marko\Database\Query\QuerySpecification`. +- Constructor parameters: `string $property`, `string $direction`, `ScopeMetadataFactory $metadataFactory`, `ScopeContext $scopeContext`, `ScopeSortRendererInterface $scopeSortRenderer`, `string $entityClass`. +- `apply(EntityQueryBuilderInterface $builder): void` — pulls metadata, builds the expression, decides plain-vs-scoped, calls `orderBy` / `orderByRaw` accordingly. +- The spec class itself is **not** registered as a service; it is instantiated via the factory (task 032). + +## Implementation Notes +- `ScopedOrderBy` implemented in `packages/scope/src/Query/ScopedOrderBy.php`. +- Added `ScopeContextException::propertyNotScoped()` factory method to `packages/scope/src/Exceptions/ScopeContextException.php`. +- Constructor parameter order: `property`, `scopeMetadataFactory`, `scopeContext`, `scopeSortRenderer`, `entityClass`, `direction` (with `direction` defaulting to `'asc'` as last optional param). +- `apply()` uses `ScopeContext::registry()->getHierarchy(axis)->walkUp(path)` to build the paths list for `ScopeSortExpression`. +- Falls back to `orderBy($property, strtoupper($direction))` when no axis in the property's metadata has an active scope. +- Calls `orderByRaw($sql, strtoupper($direction))` when scope paths exist. diff --git a/.claude/plans/scope/017-scope-module-php.md b/.claude/plans/scope/017-scope-module-php.md new file mode 100644 index 00000000..337ffcb1 --- /dev/null +++ b/.claude/plans/scope/017-scope-module-php.md @@ -0,0 +1,30 @@ +# Task 017: `marko/scope` `module.php` bindings + singletons + +**Status**: pending +**Depends on**: 006, 007, 015, 032 +**Retry count**: 0 + +## Description +Wire all interfaces and shared services for `marko/scope`. Binds `ScopeRegistryInterface` to `PhpScopeRegistry`, registers `ScopeContext`, `ScopeMetadataFactory`, `ScopeResolver`, and `ScopedOrderByFactory` as singletons (shared across the request). + +## Context +- Related files: `packages/scope/module.php` +- Patterns to follow: `.claude/architecture.md` § Dependency Injection. Use simple bindings where possible; closure bindings only for config-dependent construction. + +## Requirements (Test Descriptions) +- [ ] `it binds ScopeRegistryInterface to PhpScopeRegistry` +- [ ] `it constructs PhpScopeRegistry from injected config repository` +- [ ] `it registers ScopeContext as a singleton` +- [ ] `it registers ScopeMetadataFactory as a singleton` +- [ ] `it registers ScopeResolver as a singleton` +- [ ] `it registers ScopedOrderByFactory as a singleton` +- [ ] `it does not bind ScopeSortRendererInterface` (driver packages bind it) +- [ ] `it throws a loud error if a ScopedOrderBy is used while no ScopeSortRendererInterface is bound` + +## Acceptance Criteria +- `module.php` returns an array with `bindings` and `singletons` keys per the framework convention. +- `PhpScopeRegistry` construction reads `config/scope.php` via `ConfigRepositoryInterface`. +- The "missing renderer" error is exercised in a feature test. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/018-bootstrap-scope-mysql.md b/.claude/plans/scope/018-bootstrap-scope-mysql.md new file mode 100644 index 00000000..511f76ba --- /dev/null +++ b/.claude/plans/scope/018-bootstrap-scope-mysql.md @@ -0,0 +1,27 @@ +# Task 018: Bootstrap `marko/scope-mysql` package skeleton + +**Status**: completed +**Depends on**: 001 +**Retry count**: 0 + +## Description +Create the `marko/scope-mysql` driver package skeleton following sibling-module conventions. Requires `marko/scope` and `marko/database-mysql`. + +## Context +- Related files: `packages/scope-mysql/` (new), `packages/database-mysql/composer.json` (reference) +- Patterns to follow: `.claude/sibling-modules.md` — class prefix `MySql*`, namespace `Marko\Scope\MySql\`. + +## Requirements (Test Descriptions) +- [ ] `it has a valid composer.json with name marko/scope-mysql and extra.marko.module true` +- [ ] `it requires marko/scope and marko/database-mysql in composer.json` +- [ ] `it autoloads PSR-4 namespace Marko\Scope\MySql\ from packages/scope-mysql/src/` +- [ ] `it autoloads tests namespace Marko\Scope\MySql\Tests\ from tests/` +- [ ] `it has a module.php returning an array with bindings key` +- [ ] `it has no version field in composer.json` + +## Acceptance Criteria +- Directory layout matches existing driver packages (`packages/database-mysql/`). +- `composer install --dry-run` succeeds. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/019-mysql-sort-renderer.md b/.claude/plans/scope/019-mysql-sort-renderer.md new file mode 100644 index 00000000..936f69b6 --- /dev/null +++ b/.claude/plans/scope/019-mysql-sort-renderer.md @@ -0,0 +1,38 @@ +# Task 019: `MySqlScopeSortRenderer` + +**Status**: complete +**Depends on**: 015, 018 +**Retry count**: 0 + +## Description +Implements `ScopeSortRendererInterface` for MySQL/MariaDB. Emits a COALESCE expression over JSON_UNQUOTE/JSON_EXTRACT calls and the fallback column, in declared-axis-priority order, matching the PHP walker's resolution. + +## Context +- Related files: `packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php` (new) +- Patterns to follow: `.claude/sibling-modules.md` — identical method signatures across siblings (mirror task 023). +- Reference: MySQL `JSON_UNQUOTE(JSON_EXTRACT(col, '$."key"'))` or `col->>"$.key"` shorthand. + +## Requirements (Test Descriptions) +- [x] `it renders a single-axis sort as COALESCE over JSON paths and the fallback column` +- [x] `it renders a multi-axis sort with axes in declared priority order` +- [x] `it composes the JSON key from already-validated axis name and path segments` +- [x] `it validates fallback column, property, and json column identifiers against the safe pattern` +- [x] `it preserves direction asc or desc in the output` +- [x] `it falls back to plain ORDER BY column when the expression has no axis paths` +- [x] `it embeds path segments containing dots correctly into MySQL JSON paths (e.g. eu.de)` + +## Acceptance Criteria +- Class name `MySqlScopeSortRenderer` per sibling convention. +- Identifier validation strategy: + - Validate `$expression->column`, `$expression->property`, `$expression->jsonColumn` via `IdentifierValidator::isValidIdentifier`. + - For each `$path` entry, validate `axis` and each dot-segment of `path` separately. **Never validate the composed `"axis:path"` key — colons would fail the identifier pattern.** + - On any validation failure, throw `InvalidColumnException` (mirroring marko/database conventions). +- After validation, compose the JSON path as `$."axis:path".prop` and inline-escape — caller-supplied strings have already passed identifier validation so direct interpolation is safe. +- Method signature `render(ScopeSortExpression $expression): string` exactly matches the interface. + +## Implementation Notes +- `MySqlScopeSortRenderer` in `packages/scope-mysql/src/Query/` implements `ScopeSortRendererInterface`. +- Validates `column`, `property`, `jsonColumn` with `IdentifierValidator::isValidIdentifier`; for each path validates `axis` and each dot-segment of `path` individually. +- Composes JSON key as `axis:path`, uses `JSON_UNQUOTE(JSON_EXTRACT(\`jsonColumn\`, '$."axis:path".property'))`. +- Empty paths → plain `` `column` ASC/DESC `` fallback. +- Dot-separated path segments (e.g. `eu.de`) appear inside the quoted JSON key so remain literal in the SQL output. diff --git a/.claude/plans/scope/020-mysql-migration-helper.md b/.claude/plans/scope/020-mysql-migration-helper.md new file mode 100644 index 00000000..9057fdc5 --- /dev/null +++ b/.claude/plans/scope/020-mysql-migration-helper.md @@ -0,0 +1,36 @@ +# Task 020: MySQL auto-migration integration test for `ScopedOverridesEntity` + +**Status**: complete +**Depends on**: 010, 018 +**Retry count**: 0 + +## Description +Integration test that registers a `Product` parent entity and a `ProductScopedOverrides` extender, then runs `MigrationGenerator` against an empty MySQL schema. Asserts that the emitted ALTER TABLE includes `ADD COLUMN scopes JSON NULL`. Locks in the contract that the existing `SchemaRegistry` + `MigrationGenerator` pipeline picks up the override extender automatically — no migration helper required from `marko/scope-mysql`. + +This task replaces the previously-planned `MySqlScopeColumn` helper. The helper added no real value beyond a typed `Column` factory while the developer still had to know the parent table and write the migration. The auto-migration path is preferable because it requires zero developer migration code: declaring the extender entity is sufficient. + +## Context +- Related files: `packages/scope-mysql/tests/Feature/AutoMigrationTest.php` (new) +- Patterns to follow: Existing feature tests under `packages/database-mysql/tests/Feature/`; `SchemaRegistry::register()` (lines 116–177) merges extender columns into the parent's table; `MigrationGenerator` diffs target schema against current schema and emits ALTER TABLE statements. +- Test fixtures (`Product`, `ProductScopedOverrides`) defined file-top per `.claude/testing.md` § Test Fixtures at File Top. + +## Requirements (Test Descriptions) +- [x] `it registers a Product entity and a ProductScopedOverrides extender in the SchemaRegistry` +- [x] `it merges the scopes column into the parent products table at schema-build time` +- [x] `it emits ALTER TABLE products ADD COLUMN scopes JSON NULL when diffing against an empty schema` +- [x] `it does not emit a separate scopes_overrides table or treat the extender as a standalone table` +- [x] `it preserves the parent entitys existing columns in the merged schema` +- [x] `it does not emit any ALTER TABLE statement when the scopes column already exists` + +## Acceptance Criteria +- Feature test under `packages/scope-mysql/tests/Feature/`. +- Uses the actual `MigrationGenerator` + MySQL SQL generator — no mocking of generator output. +- Asserts on emitted SQL string, so the test runs without a live DB connection. +- Test fixtures use the file-top fixture pattern from `.claude/testing.md`. + +## Implementation Notes +- Created `packages/scope-mysql/tests/Feature/AutoMigrationTest.php` with file-top fixture classes `AutoMigrationProduct` and `AutoMigrationProductScopedOverrides`. +- `AutoMigrationProductScopedOverrides` extends `ScopedOverridesEntity` with `#[Table(extends: AutoMigrationProduct::class)]` — no extra code needed in the extender body. +- Tests use actual `SchemaRegistry`, `DiffCalculator`, and `MySqlGenerator` — no mocking. +- For the ALTER TABLE test, the "existing" DB schema must use the abstract types (`integer`, `varchar`) that `EntityMetadataFactory` produces (not MySQL native types like `INT`, `VARCHAR`) to avoid spurious MODIFY COLUMN statements in the diff. +- All 6 requirements passed; tests run without a live DB connection. diff --git a/.claude/plans/scope/021-scope-mysql-module-php.md b/.claude/plans/scope/021-scope-mysql-module-php.md new file mode 100644 index 00000000..3b8b4ec2 --- /dev/null +++ b/.claude/plans/scope/021-scope-mysql-module-php.md @@ -0,0 +1,24 @@ +# Task 021: `marko/scope-mysql` `module.php` + +**Status**: pending +**Depends on**: 019, 030 +**Retry count**: 0 + +## Description +Bind `ScopeSortRendererInterface` to `MySqlScopeSortRenderer`. This is the only wiring the driver package needs; the rest of `marko/scope`'s bindings already cover everything. + +## Context +- Related files: `packages/scope-mysql/module.php` (new) +- Patterns to follow: `packages/database-mysql/module.php` for driver-package wiring style. + +## Requirements (Test Descriptions) +- [ ] `it returns an array with bindings key` +- [ ] `it binds ScopeSortRendererInterface to MySqlScopeSortRenderer` +- [ ] `it does not re-bind marko/scope interfaces` + +## Acceptance Criteria +- Single binding in the file. +- Loads without error in a Marko bootstrap context. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/022-bootstrap-scope-pgsql.md b/.claude/plans/scope/022-bootstrap-scope-pgsql.md new file mode 100644 index 00000000..56c0d3b5 --- /dev/null +++ b/.claude/plans/scope/022-bootstrap-scope-pgsql.md @@ -0,0 +1,32 @@ +# Task 022: Bootstrap `marko/scope-pgsql` package skeleton + +**Status**: complete +**Depends on**: 001 +**Retry count**: 0 + +## Description +Create the `marko/scope-pgsql` driver package skeleton following sibling-module conventions. Requires `marko/scope` and `marko/database-pgsql`. + +## Context +- Related files: `packages/scope-pgsql/` (new), `packages/database-pgsql/composer.json` (reference) +- Patterns to follow: `.claude/sibling-modules.md` — class prefix `PgSql*`, namespace `Marko\Scope\PgSql\`. Mirror task 018 structure exactly. + +## Requirements (Test Descriptions) +- [x] `it has a valid composer.json with name marko/scope-pgsql and extra.marko.module true` +- [x] `it requires marko/scope and marko/database-pgsql in composer.json` +- [x] `it autoloads PSR-4 namespace Marko\Scope\PgSql\ from packages/scope-pgsql/src/` +- [x] `it autoloads tests namespace Marko\Scope\PgSql\Tests\ from tests/` +- [x] `it has a module.php returning an array with bindings key` +- [x] `it has no version field in composer.json` + +## Acceptance Criteria +- Directory layout matches `packages/database-pgsql/`. +- `composer install --dry-run` succeeds. + +## Implementation Notes +- Created `packages/scope-pgsql/` directory with `src/` and `tests/` subdirectories +- Created `composer.json` following `database-pgsql` pattern with `marko/scope` and `marko/database-pgsql` dependencies +- Created `module.php` returning empty bindings array (skeleton, to be filled in task 025) +- Created `LICENSE` (MIT, Copyright Devtomic LLC) and `.gitattributes` for proper packaging +- Registered in root `composer.json`: repository path entry, require entry, autoload-dev entry for `Marko\Scope\PgSql\Tests\` +- Added `scope`, `scope-mysql`, `scope-pgsql` to GitHub issue templates (bug_report.yml and feature_request.yml) diff --git a/.claude/plans/scope/023-pgsql-sort-renderer.md b/.claude/plans/scope/023-pgsql-sort-renderer.md new file mode 100644 index 00000000..876b8acb --- /dev/null +++ b/.claude/plans/scope/023-pgsql-sort-renderer.md @@ -0,0 +1,38 @@ +# Task 023: `PgSqlScopeSortRenderer` + +**Status**: complete +**Depends on**: 015, 022 +**Retry count**: 0 + +## Description +Implements `ScopeSortRendererInterface` for PostgreSQL. Emits a COALESCE expression over jsonb `->>` path lookups and the fallback column, in declared-axis-priority order, matching the PHP walker's resolution and the MySQL renderer's semantics. + +## Context +- Related files: `packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php` (new) +- Patterns to follow: `.claude/sibling-modules.md` — identical method signatures with `MySqlScopeSortRenderer` (task 019). +- Reference: PG `scopes->'axis:path'->>'prop'` returns text. + +## Requirements (Test Descriptions) +- [x] `it renders a single-axis sort as COALESCE over jsonb path lookups and the fallback column` +- [x] `it renders a multi-axis sort with axes in declared priority order` +- [x] `it composes the JSON key from already-validated axis name and path segments` +- [x] `it validates fallback column, property, and json column identifiers against the safe pattern` +- [x] `it preserves direction asc or desc in the output` +- [x] `it falls back to plain ORDER BY column when the expression has no axis paths` +- [x] `it embeds path segments containing dots correctly into PG jsonb paths (e.g. eu.de)` + +## Acceptance Criteria +- Class name `PgSqlScopeSortRenderer` per sibling convention. +- Method signature `render(ScopeSortExpression $expression): string` identical to MySQL sibling. +- Identifier validation strategy mirrors `MySqlScopeSortRenderer` (task 019): + - Validate `column`, `property`, `jsonColumn` via `IdentifierValidator::isValidIdentifier`. + - For each path entry, validate `axis` and each dot-segment separately. Never pass the colon-composed key through the identifier validator. + - Throw `InvalidColumnException` on failure. +- Composes the JSON key as `scopes->'axis:path'->>'prop'` only after the constituent parts pass validation. + +## Implementation Notes +- `PgSqlScopeSortRenderer` implements `ScopeSortRendererInterface` in `packages/scope-pgsql/src/Query/`. +- Identifier validation uses `IdentifierValidator::isValidIdentifier` for `column`, `property`, `jsonColumn`, `axis`, and each dot-segment of each path separately. +- PG JSON syntax: `"jsonColumn"->'axis:path'->>'property'` with double-quoted column identifiers and single-quoted JSON keys. +- No-paths fallback: `"column" ASC/DESC`. +- Tests in `packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php`. diff --git a/.claude/plans/scope/024-pgsql-migration-helper.md b/.claude/plans/scope/024-pgsql-migration-helper.md new file mode 100644 index 00000000..b154a348 --- /dev/null +++ b/.claude/plans/scope/024-pgsql-migration-helper.md @@ -0,0 +1,37 @@ +# Task 024: PostgreSQL auto-migration integration test for `ScopedOverridesEntity` + +**Status**: complete +**Depends on**: 010, 022 +**Retry count**: 0 + +## Description +Integration test that registers a `Product` parent entity and a `ProductScopedOverrides` extender, then runs `MigrationGenerator` against an empty PostgreSQL schema. Asserts that the emitted ALTER TABLE includes `ADD COLUMN "scopes" JSONB` — PG's `PgSqlGenerator::TYPE_MAP` aliases `'json' → 'JSONB'` at DDL time, so the same `Column::type = 'json'` from the extender materialises as JSONB on this driver. Sibling of task 020. + +Replaces the previously-planned `PgSqlScopeColumn` helper for the same reason: developers should not need to author migration code when the existing pipeline handles it. + +## Context +- Related files: `packages/scope-pgsql/tests/Feature/AutoMigrationTest.php` (new) +- Patterns to follow: Mirror task 020 structure section-by-section. Use the PG SQL generator instead of MySQL. +- Reference: `packages/database-pgsql/src/Sql/PgSqlGenerator.php` TYPE_MAP maps `'json' → 'JSONB'`. + +## Requirements (Test Descriptions) +- [x] `it registers a Product entity and a ProductScopedOverrides extender in the SchemaRegistry` +- [x] `it merges the scopes column into the parent products table at schema-build time` +- [x] `it emits ALTER TABLE products ADD COLUMN scopes JSONB when diffing against an empty schema` +- [x] `it does not emit a separate scopes_overrides table` +- [x] `it preserves the parent entitys existing columns in the merged schema` +- [x] `it aliases json to JSONB via PgSqlGenerator TYPE_MAP rather than emitting JSON` +- [x] `it does not emit any ALTER TABLE statement when the scopes column already exists` + +## Acceptance Criteria +- Feature test under `packages/scope-pgsql/tests/Feature/`. +- Uses the actual `MigrationGenerator` + PG SQL generator — no mocking. +- Asserts on emitted SQL string, no live DB connection required. +- Test fixtures file-top per `.claude/testing.md` standards. + +## Implementation Notes +- Created `packages/scope-pgsql/tests/Feature/AutoMigrationTest.php` with file-top fixtures (`Product` and `ProductScopedOverrides` classes). +- All tests use `SchemaRegistry` + `DiffCalculator` + `PgSqlGenerator` directly — no mocking. +- The "empty schema" for requirement 3 means the products table exists in DB but lacks the scopes column, producing ALTER TABLE ADD COLUMN. +- `PgSqlGenerator::TYPE_MAP` maps `'json' → 'JSONB'` so the scopes column (type=json from `ScopedOverridesEntity`) emits JSONB in DDL. +- Database schema column types must match entity types exactly (e.g., `varchar` not `string`) to avoid spurious diffs. diff --git a/.claude/plans/scope/025-scope-pgsql-module-php.md b/.claude/plans/scope/025-scope-pgsql-module-php.md new file mode 100644 index 00000000..71153447 --- /dev/null +++ b/.claude/plans/scope/025-scope-pgsql-module-php.md @@ -0,0 +1,24 @@ +# Task 025: `marko/scope-pgsql` `module.php` + +**Status**: pending +**Depends on**: 023, 031 +**Retry count**: 0 + +## Description +Bind `ScopeSortRendererInterface` to `PgSqlScopeSortRenderer`. Mirror of task 021. + +## Context +- Related files: `packages/scope-pgsql/module.php` (new) +- Patterns to follow: `packages/database-pgsql/module.php`. Mirror task 021's shape. + +## Requirements (Test Descriptions) +- [ ] `it returns an array with bindings key` +- [ ] `it binds ScopeSortRendererInterface to PgSqlScopeSortRenderer` +- [ ] `it does not re-bind marko/scope interfaces` + +## Acceptance Criteria +- Single binding in the file. +- Loads without error in a Marko bootstrap context. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/026-scope-readme.md b/.claude/plans/scope/026-scope-readme.md new file mode 100644 index 00000000..480e08fc --- /dev/null +++ b/.claude/plans/scope/026-scope-readme.md @@ -0,0 +1,55 @@ +# Task 026: `marko/scope` README + +**Status**: pending +**Depends on**: 001, 002, 003, 004, 005, 006, 007, 008, 009, 010, 011, 012, 013, 014, 015, 016, 017, 032 +**Retry count**: 0 + +## Description +Write the `marko/scope` README per `.claude/code-standards.md` § Package README Standards. Interface-package format: state what it defines, note it has no implementation, show type-hinting. + +## Context +- Related files: `packages/scope/README.md` (new) +- Patterns to follow: `packages/database/README.md`, `packages/cache/README.md`. Use the section structure from code-standards: Title + One-Liner, Overview, Installation, Usage, Customization, API Reference. + +## Requirements (Test Descriptions) +- [ ] `it has Title + One-Liner section stating the benefit` +- [ ] `it has Overview section of 2-4 sentences` +- [ ] `it has Installation section with composer command` +- [ ] `it has Usage section showing #[Scoped] attribute, ScopeContext, and ScopeResolver` +- [ ] `it has Usage section showing the per-entity ScopedOverridesEntity companion class declaration` +- [ ] `it has Usage section showing ScopedOrderBy QuerySpecification via the ScopedOrderByFactory` +- [ ] `it has a worked Product name scoped by locale example covering entity declaration, companion, config, write, read, and ordered query` +- [ ] `it has a worked Product price scoped by market example covering entity declaration, companion, config, write, read, and ordered query` +- [ ] `it has Customization section noting ScopeRegistryInterface as the DB-driven extension point` +- [ ] `it has API Reference listing the public interfaces and key services` +- [ ] `it has a Caveats section noting (a) ScopeContext is a mutable singleton requiring clearAll() between requests in long-running processes, (b) Repository::insertBatch does not support scoped entities, (c) the terminology overlap with marko/config's tenant scope parameter` + +## Acceptance Criteria +- README exists at `packages/scope/README.md`. +- Examples follow code standards (strict_types, no magic, declared types). +- One-liner states "Scoped attributes for entities with multi-axis hierarchical fallback". +- Notes that no driver is bound by default — apps must install `marko/scope-mysql` or `marko/scope-pgsql`. + +## Worked Examples (must appear in the Usage section) + +### Example 1 — Product name scoped by `locale` +- Demonstrates a string-typed scoped attribute on a single axis with a small hierarchy (e.g. `en` → `en-US`/`en-GB`; `de` → `de-DE`/`de-AT`; `fr` → `fr-FR`/`fr-CA`). +- Shows the `Product` entity with `#[Scoped(axes: ['locale'])]` on `$name`. +- Shows the one-line `ProductScopedOverrides extends ScopedOverridesEntity` companion class. +- Shows `config/scope.php` declaring the `locale` axis and its hierarchy. +- Shows writing a default name, adding a German override via `ScopeResolver::setOverride`, and saving through the standard `Repository::save`. +- Shows reading: plain `$product->name` (column value) vs `$resolver->resolved($product, 'name')` resolving through `ScopeContext::in('locale', 'de-DE')` and walking up to `'de'`. +- Shows `Repository::matching($scopedOrderByFactory->create(Product::class, 'name', 'asc'))` and the emitted SQL fragment using a `COALESCE(scopes->'locale:de-DE'->>'name', scopes->'locale:de'->>'name', name)` pattern. + +### Example 2 — Product price scoped by `market` +- Demonstrates a numeric-typed scoped attribute on a different axis (`market`) with a different shape of hierarchy (e.g. `global` root; `eu` with children `eu.de`, `eu.fr`; `us`; `apac` with child `apac.jp`). +- Shows the `Product` entity with `#[Scoped(axes: ['market'])]` on `$price` (typed as `float` or `string` for decimal — pick one and stay consistent within the example). +- Shows that the same `ProductScopedOverrides` companion supports both `name` and `price` overrides — one extender per parent entity, not one per scoped property. +- Shows `config/scope.php` adding the `market` axis alongside `locale`. +- Shows writing a global default price, adding a `market:eu` override (inherited by `eu.de` and `eu.fr`), and a per-country override at `market:eu.de`. +- Shows reading: resolution at `market:eu.fr` falling back from `eu.fr` → `eu` → column; resolution at `market:apac.jp` falling all the way through to the column when no market override exists. +- Shows ordering products by resolved price in the current market context. +- Briefly contrasts with Example 1 to make the "one axis per concern, not one axis for everything" point explicit (locale is *not* market — a French speaker can shop the EU market, etc.). No multi-axis declaration in these examples — that's a separate (linked) note in the Caveats / Advanced section. + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/027-scope-mysql-readme.md b/.claude/plans/scope/027-scope-mysql-readme.md new file mode 100644 index 00000000..35a5a734 --- /dev/null +++ b/.claude/plans/scope/027-scope-mysql-readme.md @@ -0,0 +1,33 @@ +# Task 027: `marko/scope-mysql` README + +**Status**: complete +**Depends on**: 018, 019, 020, 021, 030 +**Retry count**: 0 + +## Description +Write the `marko/scope-mysql` README per `.claude/code-standards.md` § Package README Standards. Implementation-package format: state what it does, explain concrete benefit, show it works automatically once installed. + +## Context +- Related files: `packages/scope-mysql/README.md` (new) +- Patterns to follow: `packages/database-mysql/README.md` for driver-package style. + +## Requirements (Test Descriptions) +- [x] `it has Title + One-Liner section stating the driver provides MySQL/MariaDB JSON support for marko/scope` +- [x] `it has Overview section of 2-4 sentences explaining ORDER BY emission and auto-migration via the existing extender pipeline` +- [x] `it has Installation section with composer command` +- [x] `it has Usage section noting the scopes column is added automatically by MigrationGenerator when a ScopedOverridesEntity extender is registered — no migration helper needed` +- [x] `it has Usage section noting ORDER BY support is automatic via ScopedOrderBy on Repository::matching` +- [x] `it has API Reference listing MySqlScopeSortRenderer` +- [x] `it notes MariaDB 10.3+ compatibility for JSON_EXTRACT and JSON_UNQUOTE` + +## Acceptance Criteria +- README exists at `packages/scope-mysql/README.md`. +- Examples follow code standards. +- Notes `marko/scope` is pulled in transitively. +- No "migration helper" usage block — explains the auto-migration flow instead, with the one-line `ProductScopedOverrides` extender as the trigger. + +## Implementation Notes +- Created `packages/scope-mysql/README.md` with all required sections. +- Created `packages/scope-mysql/tests/Unit/ReadmeTest.php` with 7 tests asserting README content via `->toContain()`. +- README includes: `ProductScopedOverrides extends ScopedOverridesEntity` extender example and `$repo->matching($factory->create(Product::class, 'name'))` ORDER BY example. +- Notes `marko/scope` is pulled in transitively; no migration helper block present. diff --git a/.claude/plans/scope/028-scope-pgsql-readme.md b/.claude/plans/scope/028-scope-pgsql-readme.md new file mode 100644 index 00000000..f3eae328 --- /dev/null +++ b/.claude/plans/scope/028-scope-pgsql-readme.md @@ -0,0 +1,34 @@ +# Task 028: `marko/scope-pgsql` README + +**Status**: complete +**Depends on**: 022, 023, 024, 025, 031 +**Retry count**: 0 + +## Description +Write the `marko/scope-pgsql` README per `.claude/code-standards.md` § Package README Standards. Mirror task 027 structure for sibling-module symmetry. + +## Context +- Related files: `packages/scope-pgsql/README.md` (new) +- Patterns to follow: `packages/database-pgsql/README.md`. Mirror task 027 structure section-by-section. + +## Requirements (Test Descriptions) +- [x] `it has Title + One-Liner section stating the driver provides PostgreSQL jsonb support for marko/scope` +- [x] `it has Overview section of 2-4 sentences explaining ORDER BY emission and auto-migration via the existing extender pipeline` +- [x] `it has Installation section with composer command` +- [x] `it has Usage section noting the scopes column is added automatically by MigrationGenerator when a ScopedOverridesEntity extender is registered — no migration helper needed` +- [x] `it has Usage section noting ORDER BY support is automatic via ScopedOrderBy on Repository::matching` +- [x] `it has API Reference listing PgSqlScopeSortRenderer` +- [x] `it notes Column type json materialises as JSONB via PgSqlGenerator TYPE_MAP` + +## Acceptance Criteria +- README exists at `packages/scope-pgsql/README.md`. +- Section structure identical to `marko/scope-mysql`'s README. +- Notes `marko/scope` is pulled in transitively. +- No "migration helper" usage block — explains the auto-migration flow with the one-line `ProductScopedOverrides` extender as the trigger. + +## Implementation Notes +- Created `packages/scope-pgsql/tests/Unit/ReadmeTest.php` mirroring the scope-mysql ReadmeTest structure. +- Created `packages/scope-pgsql/README.md` with all required sections. +- README notes `marko/scope` is pulled in transitively and uses `ProductScopedOverrides` as the trigger example. +- No migration helper usage block; auto-migration flow described via `ScopedOverridesEntity` extender. +- `json` → `JSONB` mapping via `PgSqlGenerator::TYPE_MAP` is documented in both Overview and Usage sections. diff --git a/.claude/plans/scope/029-orderbyraw-interface.md b/.claude/plans/scope/029-orderbyraw-interface.md new file mode 100644 index 00000000..41577607 --- /dev/null +++ b/.claude/plans/scope/029-orderbyraw-interface.md @@ -0,0 +1,37 @@ +# Task 029: Add `orderByRaw` to `QueryBuilderInterface` and `RepositoryQueryBuilder` + +**Status**: complete +**Depends on**: none + +## Description +`ScopedOrderBy` (task 016) needs to emit a `COALESCE(...)` expression in the `ORDER BY` clause. The current `QueryBuilderInterface::orderBy(string $column, string $direction)` quotes its first argument as an identifier (see `MySqlQueryBuilder::buildOrderByClause` line 858 — `quoteIdentifier($order['column'])`). There is no raw-expression escape hatch. + +This task adds a new method `orderByRaw(string $expression, string $direction = 'ASC'): static` to: +- `Marko\Database\Query\QueryBuilderInterface` +- `Marko\Database\Repository\RepositoryQueryBuilder` (passes through to the wrapped builder) + +The driver implementations land in tasks 030 (MySQL) and 031 (PostgreSQL). + +Security: `$expression` must be rejected if it contains `;`, `--`, `/*`, `*/`, or backticks (reuse the validation pattern from `IdentifierValidator::rejectDangerousPatterns`). Direction must be validated against `['ASC', 'DESC']` allowlist. + +## Context +- Related files: `packages/database/src/Query/QueryBuilderInterface.php`, `packages/database/src/Repository/RepositoryQueryBuilder.php` +- Patterns to follow: Existing `having($expression, $bindings)` pattern which already accepts raw expressions with dangerous-pattern rejection. + +## Requirements (Test Descriptions) +- [x] `it adds orderByRaw to QueryBuilderInterface with expression and direction parameters` +- [x] `it adds orderByRaw to RepositoryQueryBuilder delegating to the wrapped builder` +- [x] `it returns static for chaining` +- [x] `it preserves expression text passing through to driver implementations` (unit test against a fake/mock builder) + +## Acceptance Criteria +- `orderByRaw(string $expression, string $direction = 'ASC'): static` on `QueryBuilderInterface`. +- `RepositoryQueryBuilder::orderByRaw()` delegates to `$this->queryBuilder->orderByRaw(...)` and returns `$this`. +- Documentation in the interface PHPDoc notes the security caveat (no semicolons, comments, etc.) and that the caller is responsible for using `?` for any user-supplied values. + +## Implementation Notes +- Added `orderByRaw(string $expression, string $direction = 'ASC'): static` to `QueryBuilderInterface` with security PHPDoc. +- Added `orderByRaw()` to `RepositoryQueryBuilder` delegating to the wrapped builder. +- Added `rawOrders` property and updated `buildOrderByClause` in `MySqlQueryBuilder` and `PgSqlQueryBuilder` to support raw expressions (minimal implementation to satisfy the interface; full driver tests in tasks 030/031). +- Security validation (rejecting `;`, `--`, `/*`, `*/`, backticks) implemented in both driver classes. +- All existing anonymous `QueryBuilderInterface` stub implementations in tests updated to include `orderByRaw`. diff --git a/.claude/plans/scope/030-orderbyraw-mysql.md b/.claude/plans/scope/030-orderbyraw-mysql.md new file mode 100644 index 00000000..da8339c9 --- /dev/null +++ b/.claude/plans/scope/030-orderbyraw-mysql.md @@ -0,0 +1,28 @@ +# Task 030: Implement `orderByRaw` on `MySqlQueryBuilder` + +**Status**: completed +**Depends on**: 029 + +## Description +Implement `orderByRaw(string $expression, string $direction): static` on `Marko\Database\MySql\Query\MySqlQueryBuilder`. The expression is appended to the existing `$orders` array as a raw entry that bypasses `quoteIdentifier()` in `buildOrderByClause()`. + +## Context +- Related files: `packages/database-mysql/src/Query/MySqlQueryBuilder.php` +- Patterns to follow: Existing `orderBy()` method; the `$orders` array currently stores `['column' => string, 'direction' => string]`. Extend with a discriminator (`'raw' => bool`) or two separate slots. + +## Requirements (Test Descriptions) +- [ ] `it appends a raw expression to the order clause without quoting` +- [ ] `it rejects expressions containing semicolons` +- [ ] `it rejects expressions containing SQL comments (-- or /*)` +- [ ] `it preserves direction asc or desc on the emitted ORDER BY` +- [ ] `it composes correctly with a regular orderBy call before or after` +- [ ] `it emits a single ORDER BY clause with comma-separated entries for mixed regular and raw orders` + +## Acceptance Criteria +- Method on `MySqlQueryBuilder` matches the interface signature. +- `buildOrderByClause()` is updated to render raw entries verbatim (no `quoteIdentifier`). +- Dangerous-pattern rejection reuses or mirrors `IdentifierValidator::rejectDangerousPatterns`. +- Direction normalised to uppercase ASC/DESC; invalid values default to ASC (same as existing `orderBy`). + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/031-orderbyraw-pgsql.md b/.claude/plans/scope/031-orderbyraw-pgsql.md new file mode 100644 index 00000000..709bedef --- /dev/null +++ b/.claude/plans/scope/031-orderbyraw-pgsql.md @@ -0,0 +1,29 @@ +# Task 031: Implement `orderByRaw` on `PgSqlQueryBuilder` + +**Status**: complete +**Depends on**: 029 + +## Description +Implement `orderByRaw(string $expression, string $direction): static` on `Marko\Database\PgSql\Query\PgSqlQueryBuilder`. Sibling of task 030 — identical signature and semantics, PG-specific implementation. + +## Context +- Related files: `packages/database-pgsql/src/Query/PgSqlQueryBuilder.php` +- Patterns to follow: Mirror task 030 exactly per sibling-modules.md. + +## Requirements (Test Descriptions) +- [x] `it appends a raw expression to the order clause without quoting` +- [x] `it rejects expressions containing semicolons` +- [x] `it rejects expressions containing SQL comments (-- or /*)` +- [x] `it preserves direction asc or desc on the emitted ORDER BY` +- [x] `it composes correctly with a regular orderBy call before or after` +- [x] `it emits a single ORDER BY clause with comma-separated entries for mixed regular and raw orders` + +## Acceptance Criteria +- Method signature identical to `MySqlQueryBuilder::orderByRaw`. +- `buildOrderByClause()` rendering symmetrical with the MySQL implementation. +- Dangerous-pattern rejection identical to MySQL sibling. + +## Implementation Notes +- Task 029 had already added the full `orderByRaw` implementation to `PgSqlQueryBuilder` (method, `$rawOrders` array, and `buildOrderByClause()` integration). All 6 tests passed immediately without any code changes. +- Tests added in `packages/database-pgsql/tests/Query/PgSqlQueryBuilderOrderByRawTest.php`. +- Regular orders are rendered before raw orders in the ORDER BY clause (both arrays merged in that order). diff --git a/.claude/plans/scope/032-scoped-orderby-factory.md b/.claude/plans/scope/032-scoped-orderby-factory.md new file mode 100644 index 00000000..99f8d187 --- /dev/null +++ b/.claude/plans/scope/032-scoped-orderby-factory.md @@ -0,0 +1,27 @@ +# Task 032: `ScopedOrderByFactory` service + +**Status**: pending +**Depends on**: 016 + +## Description +A registered service that constructs `ScopedOrderBy` specs with the right dependencies wired. App code uses `$factory->create(Product::class, 'name', 'asc')` instead of dealing with constructor injection at the call site. + +This isolates the spec from container-access concerns and makes test setup trivial. + +## Context +- Related files: `packages/scope/src/Query/ScopedOrderByFactory.php` (new) +- Patterns to follow: Constructor-injected service. Registered as singleton in `marko/scope` `module.php`. Pulls `ScopeMetadataFactory`, `ScopeContext`, `ScopeSortRendererInterface` from the container exactly once at construction. + +## Requirements (Test Descriptions) +- [ ] `it constructs a ScopedOrderBy with the entity class, property, and direction` +- [ ] `it injects ScopeMetadataFactory, ScopeContext, and ScopeSortRendererInterface into the spec` +- [ ] `it defaults direction to asc when omitted` +- [ ] `it is a readonly class with constructor-injected dependencies` + +## Acceptance Criteria +- `readonly class ScopedOrderByFactory`. +- `create(string $entityClass, string $property, string $direction = 'asc'): ScopedOrderBy`. +- Registered as a singleton in `module.php` (update task 017 dependencies). + +## Implementation Notes +(Left blank — filled in during implementation.) diff --git a/.claude/plans/scope/_devils_advocate.md b/.claude/plans/scope/_devils_advocate.md new file mode 100644 index 00000000..80c2bbf8 --- /dev/null +++ b/.claude/plans/scope/_devils_advocate.md @@ -0,0 +1,181 @@ +# Devil's Advocate Review: scope + +## Critical (Must fix before building) + +### C1. Save plugin will NEVER fire on user repositories — plugin system does not walk parent class hierarchy (task 014) + +`Marko\Database\Repository\Repository` is `abstract`. User code uses concrete subclasses (e.g. `AdminUserRepository extends Repository implements AdminUserRepositoryInterface`). `PluginRegistry::getEffectiveTargetClass()` checks the resolved class, then its **interfaces only** (via `class_implements`) — it does **not** walk parent classes. So `#[Plugin(target: Repository::class)]` will never match when the container resolves `AdminUserRepository::class`. + +The right target is `Marko\Database\Repository\RepositoryInterface::class`. That interface is inherited by every concrete repository via Repository's `implements RepositoryInterface`, so `class_implements()` returns it, and the interface-wrapper interceptor strategy fires. + +But there's a secondary problem: the interface-wrapper strategy only exposes methods declared on `RepositoryInterface`. Concrete repositories typically define extra methods (`findByEmail`, custom finders) and custom interfaces. Wrapping with a `RepositoryInterface` proxy hides those — callers typed to `AdminUserRepositoryInterface` would lose `findByEmail`. Confirmed by reading `InterceptorClassGenerator::generateInterfaceWrapperCode()` — it only generates methods from the named interface. + +The cleanest fix: drop the save-plugin approach and use the **existing extender mechanism**. Make `ScopedOverrides` an `Entity` subclass with `#[Table(extends: Parent::class)]` and a `#[Column(type: 'json')] public ?array $data` property. Then `Repository::insert` (via `extractAll`) and `Repository::update` (via per-companion dirty tracking) handle persistence with zero plugin involvement. The companion is linked into `EntityMetadata::extenders` at schema-registry time. + +This requires: +- A way to discover/link the override-companion as an extender of any entity that has `#[Scoped]` properties. Either (a) require the app developer to declare a sibling override entity per parent, or (b) auto-generate one at metadata time. + +Resolution applied: rewrite task 014 to use the extender path instead of a save plugin. Add a dedicated task for auto-registering the override extender so the existing schema/save machinery picks it up. + +### C2. `RepositoryQueryBuilder` / `QueryBuilderInterface` has no `orderByRaw()` method — `ScopedOrderBy` cannot apply a COALESCE expression (tasks 015, 016, 019, 023) + +`QueryBuilderInterface::orderBy(string $column, string $direction)` and the MySQL/Pg implementations both pass `$column` through `quoteIdentifier()` (e.g. `MySqlQueryBuilder::buildOrderByClause()` line 858). There is no raw-order escape hatch. `EntityQueryBuilderInterface` is just a passthrough. The plan acknowledges this risk in task 016 ("if absent, fall back to extending its API via a small plugin") but does not actually solve it. A QuerySpecification that emits `ORDER BY COALESCE(...)` cannot be applied through the current builder. + +Two viable fixes: +1. Add `orderByRaw(string $expression, string $direction): static` to `QueryBuilderInterface`, `RepositoryQueryBuilder`, and both driver builders. This is a `marko/database` cross-cutting change and brings security obligations (must reject `;`, `--`, `/*`). +2. Add a narrower `orderByScoped(ScopeSortExpression $expr): static` to `EntityQueryBuilderInterface` and have `RepositoryQueryBuilder` collaborate with the driver renderer. Less general but lower blast radius. + +Either way, this is a marko/database change, not just a marko/scope addition. The plan needs explicit tasks for it and the matching MySQL/Pg driver implementations. + +Resolution applied: added new tasks for `orderByRaw()` on `QueryBuilderInterface` plus matching MySQL/Pg/RepositoryQueryBuilder updates; updated task 016 to depend on them. + +### C3. `EntityHydrator::hydrate` does not include unmapped columns in the row passed forward — but the After-plugin signature receives the original `$row` as an argument, so this is OK (task 013) + +After re-reading `PluginInterception::runAfterPlugins`: the after method receives `($result, ...$arguments)` where `$arguments` is the ORIGINAL invocation arguments. So the plugin gets `(Entity $entity, string $entityClass, array $row, EntityMetadata $metadata)`. `$row` contains the raw `scopes` JSON because the row dictionary is passed through unfiltered — `EntityHydrator::hydrate` just iterates known properties from metadata. Confirmed not a blocker for the hydrate side. + +However, task 013 must explicitly require the plugin method signature to match the After-plugin contract and return `Entity`. Currently the task only says "named `hydrate` with `#[After]`" which is ambiguous about return type and argument list. + +Resolution applied: clarified task 013's plugin method signature and return-type requirement. + +### C4. Override-extender (replacement for the save plugin) needs metadata wiring — the schema/extender link normally requires `#[Table(extends: Parent::class)]` plus a manual call to `linkExtenders()` from a discovery mechanism (new task) + +Looking at `SchemaRegistry.php:177` and `EntityMetadataFactory::linkExtenders`, extenders are linked at schema-registry build time, not auto-discovered by entity reflection. The marko/scope package must hook into this discovery to register the auto-generated `ScopedOverrides` companion as an extender of the parent entity. + +Options: +1. Require app developers to write a `ProductScopedOverrides extends ScopedOverridesBase` companion class per scoped entity. Verbose but explicit. +2. Synthesize the extender class at runtime via `eval()`-style class generation (like the plugin interceptor does). Less verbose but more magic. +3. Add a new attribute `#[ScopedEntity]` on the parent that signals the extender needs to be auto-attached, and have the `marko/scope` module participate in schema-registry init. + +The cleanest is option 1: each scoped entity has a matching `Foo` + `FooOverrides` pair. The base class `ScopedOverridesEntity` (in marko/scope) provides the shared `scopes` column and the `ScopedOverrides`-style accessor surface; the user's subclass per entity adds the `#[Table(extends: ...)]`. This is exactly the existing extender pattern. + +Resolution applied: dropped the save plugin task; added a base `ScopedOverridesEntity` class task and reorganized 010/013/014 around the extender pattern. App developers declare a one-line `{Entity}ScopedOverrides extends ScopedOverridesEntity` companion per scoped entity, similar to how `TimestampsExtender` already works. + +### C5. SQL identifier validation in renderers will reject axis colon-path JSON keys (tasks 019, 023) + +The renderer task acceptance criteria require "Identifier validation per code-standards.md § SQL Identifier Validation," which validates against `^[a-zA-Z_][a-zA-Z0-9_]*$`. But the renderer needs to embed `"geo:eu.de"` style keys in JSON path expressions (MySQL `$."geo:eu.de"` or PG `->'geo:eu.de'`). Colons and dots are not allowed by that pattern. + +Fix: validate the **axis name** and **path segments** separately against the safe pattern, then compose the JSON key as `$axis . ':' . $path` after validation. The fallback **column** name and the property name must still pass `IdentifierValidator::isValidIdentifier()`. + +Resolution applied: clarified validation strategy in tasks 019 and 023, plus added the same clarification to task 015's interface contract. + +### C6. The cross-package JSON shape contract is implicit — three tasks (009, 010, 011, plus renderers) each manipulate the override map without a shared, formal definition (tasks 009, 010, 011, 015) + +The plan's Architecture Notes show `{"axis:path": {"property": value}}` but the task files do not pin this down: +- Task 009: "deserializes valid JSON into a flat array structure" — what shape? +- Task 010: "stores an override keyed by property and scope" — `[$prop][$scopeKey]` or `[$scopeKey][$prop]`? +- Task 011: walks axes — needs the inverted shape to look up overrides by scope key fast. +- Renderers: emit `JSON_EXTRACT(scopes, '$."axis:path".prop')` or `scopes->'axis:path'->>'prop'`. + +If task 010 stores `[$property][$scopeKey] = $value` for dirty tracking convenience but task 011 expects `[$scopeKey][$property]`, the walker breaks silently. + +Resolution applied: added an explicit data-shape contract to tasks 009, 010, 011, 013, 015 referencing the canonical `{scopeKey: {property: value}}` shape from `_plan.md`. Tasks 010 and 011 now require lookups by scope key to be O(1) (so internal storage is keyed by scope key first, property second). + +### C7. `ScopedOverrides` companion conflicts with `Entity::companions()` extractAll path — non-entity companions break extractAll (task 010) + +`Entity::attachCompanion()` accepts any `Entity`, and `EntityHydrator::extractAll()` calls `$this->metadataFactory->parse($companion::class)` on every companion. If `ScopedOverrides` extends `Entity` but lacks the required `#[Table]` attribute, `EntityMetadataFactory::validateEntity()` throws `EntityException::missingTableAttribute`. That happens on every `Repository::insert()` for any entity with a `ScopedOverrides` companion attached. + +This is moot if we adopt the extender approach (C1/C4), because then `ScopedOverrides` IS a proper entity with `#[Table(extends: ...)]`. If the original plugin-based design is kept, `ScopedOverrides` must not extend Entity (use a different storage mechanism, e.g. a WeakMap keyed by entity). + +Resolution applied: task 010 now extends `Entity` with a proper `#[Table(extends: ...)]` (extender approach), aligning with C1 fix. + +## Important (Should fix before building) + +### I1. Plugin discovery: `marko/scope` and the driver packages have no `discovery` hook — plugins aren't auto-registered (tasks 013, 017) + +`PluginDiscovery::discoverInModule` scans `$manifest->path . '/src'` for files containing `#[Plugin`. So as long as plugin classes live under `packages/scope/src/`, they're discovered automatically. Good. But `module.php` must mark the module as enabled (it is — `extra.marko.module: true`). Task 017's tests don't verify discoverability. + +Resolution applied: added a discoverability check to task 013's acceptance criteria. + +### I2. `ScopeContext` is mutable but task 017 registers it as a singleton — request-scoped mutable singleton in a long-lived process is a footgun (task 007, 017) + +In CLI / queue worker / long-running PHP-FPM contexts, a mutable shared singleton can leak state across requests. The plan calls it "request-scoped" but the container itself doesn't have a notion of request scope. + +This is a documentation/known-limitation concern more than a build blocker. Should be called out in the README (task 026) and the ScopeContext class docblock. + +Resolution applied: added a requirement to task 007 to document the lifecycle expectation, and to task 026 README to surface the caveat. + +### I3. `marko/scope` requires `marko/config` (per task 006) but composer.json requires only `marko/core` (task 001) + +Task 001 says "it requires PHP ^8.5 and marko/core in composer.json" but task 006 says `PhpScopeRegistry` loads from `ConfigRepositoryInterface`. Either the dependency is missing or registry-construction must be optional. Workers building task 001 will set the dependency wrong. + +Resolution applied: task 001 now requires `marko/core` and `marko/config`. + +### I4. Multi-axis "declared-priority" semantics with mixed found/notfound is under-specified (task 011) + +The plan says "first axis with any matching override (current scope or ancestor) wins." But what if axis A has no override anywhere in its walk, and axis B has an explicit `null`? Task 011 says "preserves an explicit null override and does not fall through it" — this is one axis. What about across axes: does axis A's lack-of-override fall through to axis B, or does an explicit null in B count as "found"? + +The walker should: for each axis in declared order, walk its hierarchy; if ANY scope key in this axis's walk has the property set (even to null), return that — explicit null is a real value. Only if no scope key in the axis's entire walk has the property defined do we fall to the next axis. + +Resolution applied: clarified task 011's requirements to include this cross-axis behavior with explicit acceptance test for the case. + +### I5. Setting an override for an entity with no metadata yet — `setOverride` ordering (task 012) + +Task 012: "sets an override via setOverride attaching a ScopedOverrides companion if missing." But if `ScopedOverrides` is now an extender entity (C4), simply `new`ing it requires knowing the parent entity's PK column to mirror — which doesn't exist before `Repository::save`. The extender pattern handles this via `RegisterOriginalValues` on insert. ScopeResolver must be resilient to attaching the companion before the parent is persisted. + +Resolution applied: added requirement to task 012 covering attach-before-save semantics, and a test case for "set override on a new (unsaved) entity, then save once → both rows reflect the override." + +### I6. README dependency-chain for task 026 lists tasks 001-017 explicitly — too rigid (task 026) + +Task 026 depends on `001..017`. If task ordering shifts (which it will, per C1 and C2), the listed dependencies need updating. Better to express as "all marko/scope src tasks before README." + +Resolution applied: updated dependency lists to reflect the new task set after C1/C2 restructuring. + +### I7. Sibling-module type-name divergence: `'json'` (MySQL) vs `'jsonb'` (PG) in migration helpers (tasks 020, 024) + +`Marko\Database\Schema\Column::type` is a string. The PG generator's `TYPE_MAP` aliases `'json' → 'JSONB'`. The MySQL generator does similar (need to verify). The plan uses `'json'` for MySQL and `'jsonb'` for PG — which makes the two helpers diverge for no benefit. Either: +- Both use `'json'` (let driver-specific generator pick the storage), OR +- Both use a brand-new constant like `Column::TYPE_JSON_DOCUMENT`. + +This is small enough to be a Minor — keeping it as Important because sibling-modules.md explicitly warns against this kind of divergence. + +Resolution applied: tasks 020 and 024 now both use `'json'` (relying on the PG generator's existing alias to JSONB) for consistency. + +### I8. `Repository::matching()` re-creates `RepositoryQueryBuilder` on each call; `ScopedOrderBy` needs access to the renderer, metadata factory, and scope context — wiring through a constructorless spec is messy (task 016) + +Task 016 says: "resolves renderer + metadata + context via container access provided by the spec's constructor params." That sentence is a thinly-veiled service locator. The clean pattern is: spec constructor takes `ScopeSortRendererInterface`, `ScopeMetadataFactory`, `ScopeContext` explicitly; app code constructs the spec via the container. + +Most Marko query-specifications get instantiated via `new` at the call site. Forcing container resolution here is awkward. Alternatives: +- Pass a `ScopedOrderByFactory` (registered as a service) and have app code call `$factory->create('name', 'asc')`. +- Have the spec defer renderer resolution until `apply()` is called, pulling from the EntityQueryBuilderInterface (which doesn't have container access either). + +The factory pattern is cleanest. + +Resolution applied: split task 016 into two — one for the value-object spec (`ScopedOrderBy`) which takes its deps via constructor explicitly, one for a `ScopedOrderByFactory` service. Plan task table updated. + +## Minor (Nice to address) + +### M1. Terminology collision: `marko/config` already uses "scope" for tenant cascade + +The plan explicitly calls this out as Out of Scope. Worth a one-liner in the marko/scope README to disambiguate: "Note: `marko/scope` is separate from the `scope` parameter on `ConfigRepositoryInterface`, which handles tenant-cascade config lookup." + +### M2. Plugin sortOrder is not set on `#[After]` (task 013) + +Defaults to 0. If another plugin also targets `EntityHydrator::hydrate` with sortOrder 0, `PluginRegistry::register` throws `conflictingSortOrder`. Not a likely real-world conflict but worth a defensive `sortOrder: 100` to leave room for app plugins. + +### M3. Test-shape consistency: tasks 009 and 010 should share fixture builders + +Both tasks construct override maps from scratch in tests. A small shared fixture helper would prevent shape drift. + +### M4. `ScopedOrderBy` "no axes active in context" fallback to plain `orderBy` quietly degrades — should it instead error loudly? + +Per loud-error philosophy, a `ScopedOrderBy('name')` in a context where no axes are set arguably should fail rather than silently return rows in default-column order. Counter-argument: it's reasonable that "no override context → use defaults." + +## Questions for the Team + +### Q1. Are app developers expected to declare a `FooScopedOverrides` companion class per scoped entity, or do we auto-generate one? + +With the extender-based approach (post-fix), the cleanest user experience is declaring a one-line companion: `class ProductScopedOverrides extends ScopedOverridesEntity { /* #[Table(extends: Product::class)] */ }`. The alternative is runtime class generation (more magic, harder to debug, fights `loud errors / explicit over implicit`). + +Recommend explicit declaration; the README example should show the pattern. + +### Q2. Should the `scopes` column be added to the parent entity's table OR a separate table? + +The plan and the C4 fix both put it on the parent table (extender pattern adds columns to the same table). Pros: no JOIN on read, simpler. Cons: tables for "narrow" entities (e.g. lookup tables) get fatter. The plan is consistent with Marko's existing `TimestampsExtender` pattern — likely the right call, but worth confirming. + +### Q3. How do scope overrides interact with `Repository::insertBatch()`? + +`Repository::insertBatch` rejects entities with companions (`BatchInsertException::companionsNotSupported`). After the C4 refactor, every scoped entity automatically has the `ScopedOverrides` companion — batch insert breaks for them. Is that acceptable for v1, or do we need a follow-up to thread overrides through batch insert? + +### Q4. Should `marko/scope-mysql` work on MariaDB? MariaDB's JSON support differs slightly (JSON_VALUE, no JSON_TABLE etc.) + +The MySQL renderer uses `JSON_UNQUOTE(JSON_EXTRACT(col, '$."key"'))` or the `->>` shortcut. Both are supported by MariaDB 10.3+. Worth one acceptance test against MariaDB or a documented minimum version. diff --git a/.claude/plans/scope/_plan.md b/.claude/plans/scope/_plan.md new file mode 100644 index 00000000..b1e20278 --- /dev/null +++ b/.claude/plans/scope/_plan.md @@ -0,0 +1,195 @@ +# Plan: Scoped Entity Attributes (marko/scope + drivers) + +## Created +2026-05-13 + +## Status +completed + +## Objective +Introduce a three-package family (`marko/scope`, `marko/scope-mysql`, `marko/scope-pgsql`) that lets entities declare scoped attributes via `#[Scoped]`. Default values stay in their typed columns; overrides live in a JSON `scopes` column. Multi-axis support (declared-axis-priority precedence) is first-class. Repositories support ORDER BY on resolved scoped values. Axes and hierarchies are configured via `config/scope.php`, with `ScopeRegistryInterface` as the extension point for a future DB-driven definition store. + +## Related Issues +none + +## Discovery Notes + +**Codebase state:** Greenfield for scopes. No existing scope mechanism on entities. `marko/config` has a flat single-axis tenant cascade — out of scope for this plan but conceptually aligned (uses `default + scopes` shape). + +**Integration points:** +- `Marko\Database\Entity\Entity` (`packages/database/src/Entity/Entity.php`) — abstract class with a `companions()` mechanism. Scoped overrides attach as a `ScopedOverridesEntity` extender companion via the existing extender mechanism (`#[Table(extends: Parent::class)]`). +- `Marko\Database\Entity\EntityHydrator` — `hydrate()` already attaches extender companions automatically when extender columns are present in the row (lines 70-115). `extractAll()` already iterates companions for INSERT. No plugin or modification required. +- `Marko\Database\Repository\Repository` — `update()` already dirty-tracks companion columns and merges them into a single UPDATE (lines 632-700). No plugin or modification required. (Plugin-targeting `Repository::save` would not work — `Repository` is abstract and the plugin system does not walk parent class hierarchies.) +- `Marko\Database\Repository\Repository::matching(QuerySpecification ...)` — a `ScopedOrderBy` spec applies scope-aware ORDER BY via a driver-injected `ScopeSortRendererInterface`. Requires a new `orderByRaw()` method on `QueryBuilderInterface` (see tasks 029-031). +- `Marko\Database\Schema\Column` — string `type` field accepts `'json'`; PG's `PgSqlGenerator::TYPE_MAP` aliases `'json' → 'JSONB'`. Both driver helpers use `type='json'` for sibling-module symmetry. + +**Naming convention confirmed:** `marko/scope-pgsql` (matches `marko/database-pgsql`), classes prefixed `MySql*`/`PgSql*` per `.claude/sibling-modules.md`. + +**Key design decisions (resolved during clarification):** +- **Storage:** Default value lives in the entity's normal typed column. Only overrides live in a `scopes` JSON/JSONB column. Adding `#[Scoped]` to an existing entity is non-disruptive (no data migration; existing column unchanged). The column lives on the same table as the parent entity (extender pattern, mirroring `TimestampsExtender`). +- **JSON shape:** `{"axis:path": {"property": value, ...}, ...}` — keyed by scope key first, property second. No `default` key (defaults are real columns). Empty overrides = column `NULL`. +- **Multi-axis precedence:** Declared-axis-priority. `#[Scoped(axes: ['geo', 'locale'])]` walks axes in order; first axis with any matching override (including explicit `null`) at the current scope or any ancestor wins. Within an axis, deeper paths beat shallower. +- **Persistence:** `ScopedOverridesEntity` extender entity per parent — one-line subclass with `#[Table(extends: Parent::class)]`. Hydration / insert / update flow through the existing extender pipeline — no plugins. +- **API:** Plain reads via `$product->name` (typed column). Resolved reads via `ScopeResolver` service: `$resolver->resolved($product, 'name')` (current context) or `$resolver->resolvedAt($product, 'name', $scope)`. Mutations via `$resolver->setOverride()` / `clearOverride()`. No magic methods, no Entity base class changes. +- **Sort:** `ScopedOrderBy` QuerySpecification emits `ORDER BY COALESCE(scopes->'axis:path'->>'prop', ..., column)` via driver-specific renderer. Inline COALESCE — no generated columns in v1. Requires `orderByRaw()` on `QueryBuilderInterface` (added in this plan as a cross-cutting change to `marko/database`). +- **Axis config:** `config/scope.php` returning `['axes' => [...]]`. Modules contribute, resolver merges. Validation at boot. +- **Extension point:** `ScopeRegistryInterface` with `PhpScopeRegistry` as default impl. Future DB-driven impl is a swap of binding — interface unchanged. + +## Scope + +### In Scope +- `marko/scope` interface package with: `ScopeAxis`, `Scope`, `ScopeHierarchy`, `ScopeRegistryInterface`, `PhpScopeRegistry`, `ScopeContext`, `#[Scoped]` attribute, `ScopeMetadata`/`Factory`, `ScopedOverridesEntity` extender base class, `ScopedDataSerializer`, `ScopeWalker`, `ScopeResolver`, `ScopedEntityValidator`, `ScopeSortExpression` + `ScopeSortRendererInterface`, `ScopedOrderBy` QuerySpecification, `ScopedOrderByFactory`, loud-error exception family, module bindings. +- `marko/database` cross-cutting: `orderByRaw()` on `QueryBuilderInterface` and `RepositoryQueryBuilder`. +- `marko/database-mysql`, `marko/database-pgsql`: `orderByRaw()` implementations on the respective query builders. +- `marko/scope-mysql`: `MySqlScopeSortRenderer`, MySQL auto-migration integration test (locking in that `MigrationGenerator` emits the `scopes JSON` column via the existing extender pipeline), module bindings. +- `marko/scope-pgsql`: `PgSqlScopeSortRenderer`, PostgreSQL auto-migration integration test (locking in that `MigrationGenerator` emits the `scopes JSONB` column via the existing extender pipeline + `PgSqlGenerator::TYPE_MAP`), module bindings. +- READMEs for all three new packages per code-standards.md Package README Standards. +- TDD coverage with Pest, ≥80% line coverage. + +### Out of Scope +- Filtering by resolved scoped value (planned follow-up). +- Scope-level uniqueness constraints. +- Generated columns / indexed scoped queries. +- Custom (runtime-defined) attributes — future `marko/eav` plan. +- `marko/config` integration (separate plan; existing flat scope cascade keeps working). +- `marko/scope-locale` or other sugar axes. +- Admin UI / scope hierarchy management UI. +- Cross-cutting documentation updates in `architecture.md` Package Inventory (the doc-updater pipeline agent handles this post-implementation). + +## Success Criteria +- [ ] All three packages installable independently via Composer; drivers pull `marko/scope` transitively. +- [ ] An entity can declare `#[Scoped(axes: ['geo'])] public string $name;`, save it, reload it, and resolve it via `ScopeResolver` with the current `ScopeContext`. +- [ ] Multi-axis attributes resolve via declared-axis-priority across `ScopeContext`. +- [ ] Reading an entity with no `scopes` JSON returns the column value untouched (zero-overhead default path). +- [ ] `ScopedOrderBy` QuerySpecification produces correct `COALESCE(...)` SQL on both MySQL and PostgreSQL. +- [ ] `ScopeRegistryInterface` is swappable; replacing the binding with an alternative impl works without changes to the rest of the package. +- [ ] Unknown axes, unknown scope paths, and config-shape errors raise loud, actionable exceptions. +- [ ] All tests passing; ≥80% line coverage on every new package. +- [ ] Linter (`./vendor/bin/phpcs`) clean. +- [ ] Each package has a README per project standards. + +## Task Overview +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Bootstrap `marko/scope` package skeleton | - | completed | +| 002 | `Scope` + `ScopeAxis` value objects | 001 | completed | +| 003 | `ScopeHierarchy` (per-axis tree, walkUp/exists/isAncestor) | 001 | completed | +| 004 | Scope exception family | 001 | completed | +| 005 | `#[Scoped]` attribute | 001 | completed | +| 006 | `ScopeRegistryInterface` + `PhpScopeRegistry` | 002, 003, 004 | completed | +| 007 | `ScopeContext` (request-scoped singleton, mutable) | 002, 004, 006 | completed | +| 008 | `ScopeMetadata` + `ScopeMetadataFactory` (reflection on `#[Scoped]`) | 005, 006 | completed | +| 009 | `ScopedDataSerializer` (JSON shape + type conversion) | 002, 004 | completed | +| 010 | `ScopedOverridesEntity` base class (entity extender) | 002, 009 | completed | +| 011 | `ScopeWalker` (multi-axis declared-priority resolution) | 003, 006, 007, 010 | completed | +| 012 | `ScopeResolver` service (resolved/setOverride/clearOverride) | 008, 010, 011 | completed | +| 013 | `ScopedEntityValidator` (boot-time integrity check) | 008, 010 | completed | +| 014 | Save-path integration tests for `ScopedOverridesEntity` | 010, 012, 013 | completed | +| 015 | `ScopeSortExpression` + `ScopeSortRendererInterface` | 002, 008 | completed | +| 016 | `ScopedOrderBy` QuerySpecification | 008, 015, 029 | completed | +| 017 | `marko/scope` `module.php` bindings + singletons | 006, 007, 015, 032 | completed | +| 018 | Bootstrap `marko/scope-mysql` package skeleton | 001 | completed | +| 019 | `MySqlScopeSortRenderer` (JSON_UNQUOTE/JSON_EXTRACT COALESCE) | 015, 018 | completed | +| 020 | MySQL auto-migration integration test for `ScopedOverridesEntity` | 010, 018 | completed | +| 021 | `marko/scope-mysql` `module.php` | 019, 030 | completed | +| 022 | Bootstrap `marko/scope-pgsql` package skeleton | 001 | completed | +| 023 | `PgSqlScopeSortRenderer` (jsonb path COALESCE) | 015, 022 | completed | +| 024 | PostgreSQL auto-migration integration test for `ScopedOverridesEntity` | 010, 022 | completed | +| 025 | `marko/scope-pgsql` `module.php` | 023, 031 | completed | +| 026 | `marko/scope` README | 001..017, 032 | completed | +| 027 | `marko/scope-mysql` README | 018..021, 030 | completed | +| 028 | `marko/scope-pgsql` README | 022..025, 031 | completed | +| 029 | Add `orderByRaw` to `QueryBuilderInterface` and `RepositoryQueryBuilder` (marko/database) | - | completed | +| 030 | Implement `orderByRaw` on `MySqlQueryBuilder` (marko/database-mysql) | 029 | completed | +| 031 | Implement `orderByRaw` on `PgSqlQueryBuilder` (marko/database-pgsql) | 029 | completed | +| 032 | `ScopedOrderByFactory` service | 016 | completed | + +## Architecture Notes + +### Storage shape +```sql +CREATE TABLE products ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, -- default value, typed, indexable, FK-able + scopes JSONB -- overrides only; NULL when none. Lives on the same table as the parent — column added by ProductScopedOverrides extender. +); + +-- A row with a German override +id=2, name='Shirt', + scopes = {"geo:eu.de": {"name": "Hemd"}} +``` + +The `scopes` column is contributed by the `ScopedOverridesEntity` subclass via `#[Table(extends: Product::class)]` and `#[Column(name: 'scopes', type: 'json', nullable: true)]`. The `SchemaRegistry` merges the extender's columns into the parent's table at schema-build time (see `packages/database/src/Schema/SchemaRegistry.php`). + +### JSON shape (overrides only — no `default` key) +```json +{ + "geo:eu": {"name": "Shirt-EU"}, + "geo:eu.de": {"name": "Hemd"}, + "locale:de-DE": {"name_label": "Hallo"} +} +``` + +### Multi-axis precedence (declared-axis-priority) +```php +#[Scoped(axes: ['geo', 'locale'])] +public string $name; +``` +Walker order at runtime with `ScopeContext::in('geo', 'eu.de')->in('locale', 'de-DE')`: +1. Axis `geo` — walk `eu.de → eu → (root)`. If any has `name`, return it. +2. Axis `locale` — walk `de-DE → de → (root)`. If any has `name`, return it. +3. No override anywhere → return the column value (default). + +### API surface +```php +// One-time setup per scoped entity (in user code) +#[Table(extends: Product::class)] +class ProductScopedOverrides extends ScopedOverridesEntity {} + +// Read +$product->name; // column value (typed string) +$resolver->resolved($product, 'name'); // current ScopeContext +$resolver->resolvedAt($product, 'name', $scope); // explicit scope + +// Write +$product->name = 'New Default'; // sets column +$resolver->setOverride($product, 'name', 'Hemd', $scope); +$resolver->clearOverride($product, 'name', $scope); // inherit again +$productRepo->save($product); // persists overrides via extender pipeline + +// Query (factory pattern — ScopedOrderBy needs renderer + metadata + context) +$repo->matching($scopedOrderByFactory->create(Product::class, 'name', 'asc')); +``` + +### Extension point for DB-driven scopes +`ScopeRegistryInterface` is the only contract callers depend on. `PhpScopeRegistry` (loaded from `config/scope.php`) is the default binding. A future `DatabaseScopeRegistry` lives in a separate package, requires `marko/scope`, and re-binds the interface — no changes elsewhere. + +### Persistence strategy (extender-based, no plugins) +- The override container is implemented as a normal `Entity` extender (`ScopedOverridesEntity`). App developers declare one companion class per scoped parent entity, e.g.: + ```php + #[Table(extends: Product::class)] + class ProductScopedOverrides extends ScopedOverridesEntity {} + ``` +- Hydration: `EntityHydrator::hydrate` already attaches extender companions when their columns are present in the row (`packages/database/src/Entity/EntityHydrator.php` lines 70-115). No hydrate plugin needed. +- Insert: `Repository::insert` calls `EntityHydrator::extractAll` which already iterates companions. No insert plugin needed. +- Update: `Repository::update` already dirty-tracks companion columns and merges them into a single UPDATE (`Repository.php` lines 632-700). No update plugin needed. +- The `marko/database` cross-cutting changes are limited to adding `orderByRaw()` so `ScopedOrderBy` can emit a `COALESCE(...)` expression in the ORDER BY clause. + +Rationale: the previously-planned `Repository::save` plugin would not have fired — the `marko/core` plugin system does not walk parent class hierarchies. `Repository` is `abstract` and user repositories are concrete subclasses, so `PluginRegistry::getEffectiveTargetClass` would have ignored the plugin's target. Routing through `RepositoryInterface` was rejected because the interface-wrapper interceptor strategy hides custom-repository methods (`findByEmail`, etc.). + +### Sibling-module conformance +Both `marko/scope-mysql` and `marko/scope-pgsql` follow `.claude/sibling-modules.md`: +- Class prefix: `MySql*` / `PgSql*`. +- Identical public method names across drivers. +- Anonymous-class test pattern for connection-dependent code where applicable. +- `MySqlScopeSortRenderer` and `PgSqlScopeSortRenderer` are concrete; preferable to readonly when stateless. + +## Risks & Mitigations + +- **Dirty tracking of JSON column** — Handled by `EntityHydrator::getDirtyProperties` for free since `ScopedOverridesEntity::$scopes` is a normal `#[Column(type: 'json')]` property. No custom dirty flag needed. +- **JSON column missing on tables that need it** — `ScopedEntityValidator` (task 013) catches missing extenders at boot time with a loud `ScopeConfigurationException`. Migration-helper tasks (020/024) make adding the column trivial. +- **Multi-axis declared-priority subtleties** — developers may expect "deepest wins across axes." Mitigation: documented prominently in README with a worked example showing the resolution path. Sort emitters produce SQL that matches PHP walker behavior, asserted by parallel walker/SQL tests. +- **Cross-driver SQL drift between MySQL and PG renderers** — same expression must produce identical resolution. Mitigation: feature tests assert byte-identical resolution semantics by walking the same JSON in both renderers' output via integration tests where feasible; otherwise share a renderer-contract test suite. +- **Future filter/uniqueness work fights the v1 design** — the sort-only API may not generalize. Mitigation: `ScopeSortExpression` is a value object decoupled from the renderer; the filter equivalent (`ScopeFilterExpression`) can land alongside it later without retrofitting. +- **`Repository::insertBatch` rejects entities with companions** — scoped entities will always have a companion attached, so batch insert breaks. Acceptable for v1; a follow-up plan can either add batch-insert support for companions in `marko/database` or document the limitation. +- **`ScopeContext` is a mutable singleton** — in long-running PHP processes (FPM worker reuse, queue daemons), the bootstrap layer MUST call `clearAll()` between requests/jobs. Documented in the class docblock and README. Apps are responsible for the reset; failing to do so leaks scope state across requests. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 41ce17e4..2c44ae6d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -110,6 +110,9 @@ body: - rate-limiting - routing - scheduler + - scope + - scope-mysql + - scope-pgsql - search - security - session diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 2950b022..fdabc895 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -98,6 +98,9 @@ body: - rate-limiting - routing - scheduler + - scope + - scope-mysql + - scope-pgsql - search - security - session diff --git a/composer.json b/composer.json index ead0ab43..7e198d13 100644 --- a/composer.json +++ b/composer.json @@ -264,6 +264,18 @@ "type": "path", "url": "packages/scheduler" }, + { + "type": "path", + "url": "packages/scope" + }, + { + "type": "path", + "url": "packages/scope-mysql" + }, + { + "type": "path", + "url": "packages/scope-pgsql" + }, { "type": "path", "url": "packages/search" @@ -395,6 +407,9 @@ "marko/rate-limiting": "self.version", "marko/routing": "self.version", "marko/scheduler": "self.version", + "marko/scope": "self.version", + "marko/scope-mysql": "self.version", + "marko/scope-pgsql": "self.version", "marko/search": "self.version", "marko/security": "self.version", "marko/session": "self.version", @@ -511,6 +526,9 @@ "Marko\\RateLimiting\\Tests\\": "packages/rate-limiting/tests/", "Marko\\Routing\\Tests\\": "packages/routing/tests/", "Marko\\Scheduler\\Tests\\": "packages/scheduler/tests/", + "Marko\\Scope\\Tests\\": "packages/scope/tests/", + "Marko\\Scope\\MySql\\Tests\\": "packages/scope-mysql/tests/", + "Marko\\Scope\\PgSql\\Tests\\": "packages/scope-pgsql/tests/", "Marko\\Search\\Tests\\": "packages/search/tests/", "Marko\\Security\\Tests\\": "packages/security/tests/", "Marko\\Session\\Tests\\": "packages/session/tests/", diff --git a/docs/src/content/docs/packages/database.md b/docs/src/content/docs/packages/database.md index ec28c824..d3fa2a77 100644 --- a/docs/src/content/docs/packages/database.md +++ b/docs/src/content/docs/packages/database.md @@ -355,7 +355,9 @@ Use `getEntities()` / `firstEntity()` for typed domain objects. Drop to `get()` #### Available filters -`where`, `whereIn`, `whereNull`, `whereNotNull`, `orWhere`, `join`, `leftJoin`, `rightJoin`, `orderBy`, `limit`, `offset`, `select`. All return `static` for chaining. The escape hatch is `raw(string $sql, array $bindings = [])` for queries the builder can't express. +`where`, `whereIn`, `whereNull`, `whereNotNull`, `orWhere`, `join`, `leftJoin`, `rightJoin`, `orderBy`, `orderByRaw`, `limit`, `offset`, `select`. All return `static` for chaining. The escape hatch is `raw(string $sql, array $bindings = [])` for queries the builder can't express. + +`orderByRaw(string $expression, string $direction = 'ASC')` accepts a raw SQL expression for cases where a simple column name isn't enough --- for example a `COALESCE` expression or a `CASE` statement. The expression must not contain semicolons, SQL comment markers, or backticks. Never interpolate user-supplied values directly into the expression; use `?` placeholders and pass values through a subsequent `raw()` call instead. #### Aggregate functions diff --git a/docs/src/content/docs/packages/scope-mysql.md b/docs/src/content/docs/packages/scope-mysql.md new file mode 100644 index 00000000..e92f1b09 --- /dev/null +++ b/docs/src/content/docs/packages/scope-mysql.md @@ -0,0 +1,119 @@ +--- +title: marko/scope-mysql +description: MySQL and MariaDB driver for marko/scope — scoped ORDER BY and automatic scopes column migration. +--- + +MySQL and MariaDB driver for `marko/scope` --- enables scoped `ORDER BY` queries and automatic `scopes` column migration via the existing entity extender pipeline. The package provides `MySqlScopeSortRenderer`, which emits `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` expressions for scope-aware sorting. The `scopes` JSON column is added to the parent entity's table automatically when a `ScopedOverridesEntity` extender is registered --- no separate migration helper is needed. Requires MariaDB 10.3+ or MySQL 8.0+. + +## Installation + +```bash +composer require marko/scope-mysql +``` + +This automatically installs `marko/scope` as a transitive dependency. + +## Usage + +### Declaring the companion class + +Declare a `ScopedOverridesEntity` subclass with `#[Table(extends:)]` pointing at your entity. When `db:migrate` runs, the `scopes` JSON column is merged into the parent table automatically: + +```php title="app/catalog/Entity/ProductScopedOverrides.php" +scopeContext->in('locale', 'de-DE'); + + return $this->matching( + $this->scopedOrderByFactory->create(Product::class, 'name'), + ); + } +} +``` + +Emitted SQL: + +```sql +ORDER BY COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$."locale:de-DE".name')), + JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$."locale:de".name')), + `name` +) ASC +``` + +## Customization + +To replace the renderer with a custom implementation, bind your class to `ScopeSortRendererInterface` in your module: + +```php title="app/catalog/module.php" + [ + ScopeSortRendererInterface::class => CustomScopeSortRenderer::class, + ], +]; +``` + +## API Reference + +### `MySqlScopeSortRenderer` + +| Method | Description | +|--------|-------------| +| `render(ScopeSortExpression $expression): string` | Renders a `ScopeSortExpression` as a MySQL `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` fragment. Throws `InvalidColumnException` if any identifier in the expression is invalid. | + +**Compatibility:** Requires MySQL 8.0+ or MariaDB 10.3+ for `JSON_EXTRACT` and `JSON_UNQUOTE`. + +## Related Packages + +- [marko/scope](/docs/packages/scope/) --- Core scoped attributes package +- [marko/scope-pgsql](/docs/packages/scope-pgsql/) --- PostgreSQL driver +- [marko/database-mysql](/docs/packages/database-mysql/) --- MySQL database driver diff --git a/docs/src/content/docs/packages/scope-pgsql.md b/docs/src/content/docs/packages/scope-pgsql.md new file mode 100644 index 00000000..21aa16ca --- /dev/null +++ b/docs/src/content/docs/packages/scope-pgsql.md @@ -0,0 +1,98 @@ +--- +title: marko/scope-pgsql +description: PostgreSQL driver for marko/scope — jsonb column support and scoped ORDER BY. +--- + +PostgreSQL driver for `marko/scope` --- adds `jsonb` column support and scoped `ORDER BY` for PostgreSQL-backed applications. When a `ScopedOverridesEntity` extender is registered, `db:migrate` automatically adds the `scopes` column to the entity's table. The `json` column type materialises as `JSONB` in PostgreSQL via the driver's type map, giving full indexed JSON support without any extra configuration. + +## Installation + +```bash +composer require marko/scope-pgsql +``` + +This automatically installs `marko/scope` as a transitive dependency. + +## Usage + +### Declaring the companion class + +Declare a `ScopedOverridesEntity` subclass with `#[Table(extends:)]` pointing at your entity. When `db:migrate` runs, the `scopes` JSONB column is merged into the parent table automatically: + +```php title="app/catalog/Entity/ProductScopedOverrides.php" +scopeContext->in('locale', 'de-DE'); + + return $this->matching( + $this->scopedOrderByFactory->create(Product::class, 'name'), + ); + } +} +``` + +Emitted SQL: + +```sql +ORDER BY COALESCE( + "scopes"->'locale:de-DE'->>'name', + "scopes"->'locale:de'->>'name', + "name" +) ASC +``` + +## API Reference + +### `PgSqlScopeSortRenderer` + +| Method | Description | +|--------|-------------| +| `render(ScopeSortExpression $expression): string` | Renders a `ScopeSortExpression` as a PostgreSQL `COALESCE("scopes"->'key'->>'property', column)` fragment. Throws `InvalidColumnException` if any identifier in the expression is invalid. | + +## Related Packages + +- [marko/scope](/docs/packages/scope/) --- Core scoped attributes package +- [marko/scope-mysql](/docs/packages/scope-mysql/) --- MySQL/MariaDB driver +- [marko/database-pgsql](/docs/packages/database-pgsql/) --- PostgreSQL database driver diff --git a/docs/src/content/docs/packages/scope.md b/docs/src/content/docs/packages/scope.md new file mode 100644 index 00000000..fa28d2da --- /dev/null +++ b/docs/src/content/docs/packages/scope.md @@ -0,0 +1,349 @@ +--- +title: marko/scope +description: Scoped entity attributes with multi-axis hierarchical fallback. +--- + +Scoped attributes for entities with multi-axis hierarchical fallback. `marko/scope` defines the contracts and core logic for attaching per-scope override values to entity properties. Each property marked `#[Scoped]` can carry different values across multiple independent axes (e.g. `locale`, `market`, `channel`), with automatic walk-up through the declared hierarchy when no exact match exists. The package ships the `#[Scoped]` attribute, `ScopeContext`, `ScopeResolver`, and the `ScopedOrderBy` query specification --- but no database driver. Applications must install `marko/scope-mysql` or `marko/scope-pgsql` to persist and query overrides. + +## Installation + +```bash +composer require marko/scope +``` + +No sort renderer is bound by default. You must also install a driver package: + +```bash +composer require marko/scope-mysql +# or +composer require marko/scope-pgsql +``` + +## Configuration + +Declare axes and their path hierarchies in `config/scope.php`: + +```php title="config/scope.php" + [ + 'locale' => [ + 'hierarchy' => [ + 'en', + 'de', + 'de-DE', + 'de-AT', + 'fr', + 'fr-FR', + 'fr-BE', + ], + ], + 'market' => [ + 'hierarchy' => [ + 'eu', + 'eu.de', + 'eu.fr', + 'eu.at', + 'us', + 'us.east', + 'us.west', + ], + ], + ], +]; +``` + +Paths use dot notation. `walkUp('eu.de')` yields `['eu.de', 'eu']`, so a value set at `eu` is inherited by `eu.de` when no `eu.de`-specific override exists. + +## Usage + +### Marking a property as scoped + +Add `#[Scoped(axes: [...])]` to any entity property that should carry per-scope override values: + +```php title="app/catalog/Entity/Product.php" +in('locale', 'de-DE'); +$context->in('market', 'eu.fr'); +``` + +### Writing overrides + +Use `ScopeResolver::setOverride()` to attach a scoped value to an entity before persisting: + +```php +name = 'Widget'; +$product->price = 100.00; + +// Set a German locale override for the name +$scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); + +// Set market overrides for price at different hierarchy levels +$scopeResolver->setOverride($product, 'price', 89.99, new Scope('market', 'eu')); +$scopeResolver->setOverride($product, 'price', 79.99, new Scope('market', 'eu.de')); + +$productRepository->save($product); +``` + +### Reading resolved values + +`$product->name` returns the raw column value. Use `ScopeResolver::resolved()` to walk the active context hierarchy and return the most specific override: + +```php +in('locale', 'de-DE'); +$context->in('market', 'eu.fr'); + +// Raw column value — no scope resolution +$raw = $product->name; // 'Widget' + +// resolved() walks de-DE → de → column value +$localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' (de override) + +// eu.fr has no override; walks up to eu +$marketPrice = $scopeResolver->resolved($product, 'price'); // 89.99 (eu override) +``` + +### Resolving at a specific scope + +Use `resolvedAt()` to resolve a value at a particular scope regardless of the active context: + +```php +resolvedAt($product, 'price', new Scope('market', 'eu.de')); // 79.99 +``` + +### Ordered queries + +Use `ScopedOrderByFactory::create()` to build a `ScopedOrderBy` `QuerySpecification` that sorts by the resolved value for the active context: + +```php +in('locale', 'de-DE'); + +$products = $productRepository->matching( + $scopedOrderByFactory->create(Product::class, 'name', 'asc'), +); +``` + +The driver package emits a `COALESCE` expression that mirrors the PHP resolution order: + +```sql +ORDER BY COALESCE( + JSON_UNQUOTE(JSON_EXTRACT(scopes, '$."locale:de-DE".name')), + JSON_UNQUOTE(JSON_EXTRACT(scopes, '$."locale:de".name')), + name +) ASC +``` + +When no scope path is active the specification falls back to a plain `ORDER BY name ASC`. + +### Clearing overrides + +```php +clearOverride($product, 'price', new Scope('market', 'eu.de')); +$productRepository->save($product); +``` + +## Customization + +### DB-driven scope registry + +By default, axes are loaded from `config/scope.php` via `PhpScopeRegistry`. To drive axes from a database table so they can be managed at runtime, implement `ScopeRegistryInterface` and bind it in your module: + +```php title="app/catalog/Registry/DatabaseScopeRegistry.php" + [ + ScopeRegistryInterface::class => DatabaseScopeRegistry::class, + ], +]; +``` + +## API Reference + +| Class / Interface | Description | +|---|---| +| `Marko\Scope\Attributes\Scoped` | Property attribute declaring which axes scope a value | +| `Marko\Scope\Context\ScopeContext` | Mutable singleton holding the active path per axis for the current request | +| `Marko\Scope\Resolver\ScopeResolver` | Resolves scoped values by walking the active context hierarchy; also writes and clears overrides | +| `Marko\Scope\Storage\ScopedOverridesEntity` | Abstract companion entity holding the JSON `scopes` column | +| `Marko\Scope\Query\ScopedOrderBy` | `QuerySpecification` that orders by resolved scope value | +| `Marko\Scope\Query\ScopedOrderByFactory` | Factory for building `ScopedOrderBy` specifications | +| `Marko\Scope\Query\ScopeSortRendererInterface` | Interface implemented by driver packages to emit DB-specific `COALESCE` expressions | +| `Marko\Scope\Registry\ScopeRegistryInterface` | Interface for scope axis/hierarchy providers | +| `Marko\Scope\Scope` | Value object representing a single axis+path pair (`axis:path`) | +| `Marko\Scope\Hierarchy\ScopeHierarchy` | Ordered list of declared paths; provides `walkUp()` for fallback traversal | + +### `ScopeContext` + +| Method | Description | +|--------|-------------| +| `in(string $axis, string $path): static` | Set the active path for an axis. Throws `UnknownAxisException` or `ScopeContextException` if the axis or path is invalid. | +| `get(string $axis): ?string` | Return the active path for an axis, or `null` if not set. | +| `clear(string $axis): void` | Remove the active path for an axis. | +| `clearAll(): void` | Remove all active paths. Call between requests in long-running processes. | +| `activeAxes(): list` | Return the names of all axes that have an active path. | + +### `ScopeResolver` + +| Method | Description | +|--------|-------------| +| `resolved(Entity $entity, string $property): mixed` | Walk the active context hierarchy and return the most specific override, falling back to the column value. | +| `resolvedAt(Entity $entity, string $property, Scope $scope): mixed` | Resolve at a specific scope regardless of the active context. | +| `setOverride(Entity $entity, string $property, mixed $value, Scope $scope): void` | Attach a scoped value to the entity's companion. Creates the companion if one doesn't exist yet. | +| `clearOverride(Entity $entity, string $property, Scope $scope): void` | Remove a scoped override from the entity's companion. | + +### `ScopedOrderByFactory` + +| Method | Description | +|--------|-------------| +| `create(string $entityClass, string $property, string $direction = 'asc'): ScopedOrderBy` | Build a `QuerySpecification` that orders by the resolved scope value for the active context. | + +### `ScopeHierarchy` + +| Method | Description | +|--------|-------------| +| `fromPaths(list $paths): self` | Build a hierarchy from a flat list of dotted paths. | +| `paths(): list` | Return all declared paths in declaration order. | +| `exists(string $path): bool` | Check whether a path is declared. | +| `isAncestor(string $ancestor, string $descendant): bool` | Return true if `$ancestor` is a strict ancestor of `$descendant`. | +| `walkUp(string $path): list` | Return the path and all ancestors in deepest-first order. | + +## Caveats + +**`ScopeContext` is a mutable singleton.** It holds active paths for the entire PHP process lifetime. In long-running processes (FPM workers, queue daemons, ReactPHP servers), the bootstrap layer must call `$scopeContext->clearAll()` between requests or jobs to prevent cross-request scope leakage. + +**`Repository::insertBatch` does not support scoped entities.** Batch inserts bypass the companion lifecycle and cannot attach per-row overrides. Use individual `save()` calls for entities with `#[Scoped]` properties. + +**Terminology overlap with `marko/config`.** The `marko/config` package uses the term "tenant scope" as a configuration parameter name. This is unrelated to `marko/scope`'s axis/path concept --- the two systems are independent. + +## Related Packages + +- [marko/scope-mysql](/docs/packages/scope-mysql/) --- MySQL/MariaDB driver +- [marko/scope-pgsql](/docs/packages/scope-pgsql/) --- PostgreSQL driver +- [marko/database](/docs/packages/database/) --- Entity system and `QuerySpecification` interface diff --git a/packages/database-mysql/src/Query/MySqlQueryBuilder.php b/packages/database-mysql/src/Query/MySqlQueryBuilder.php index 2b72bb0f..fec466b9 100644 --- a/packages/database-mysql/src/Query/MySqlQueryBuilder.php +++ b/packages/database-mysql/src/Query/MySqlQueryBuilder.php @@ -66,7 +66,7 @@ class MySqlQueryBuilder implements QueryBuilderInterface private ?array $havingClause = null; /** - * @var array + * @var array */ private array $orders = []; @@ -113,6 +113,9 @@ public function distinct(): static return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function union( QueryBuilderInterface $other, ): static { @@ -128,6 +131,9 @@ public function union( return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function unionAll( QueryBuilderInterface $other, ): static { @@ -252,11 +258,17 @@ public function orWhere( return $this; } + /** + * @throws InvalidColumnException When a column name is invalid + */ public function groupBy( string ...$columns, ): static { foreach ($columns as $column) { - if (!IdentifierValidator::isValidIdentifier($column) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + if (!IdentifierValidator::isValidIdentifier($column) && !preg_match( + '/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', + $column + )) { throw InvalidColumnException::invalidColumn($column); } } @@ -266,6 +278,9 @@ public function groupBy( return $this; } + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ public function having( string $expression, array $bindings = [], @@ -350,6 +365,38 @@ public function orderBy( $this->orders[] = [ 'column' => $column, 'direction' => $direction, + 'raw' => false, + ]; + + return $this; + } + + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + if ( + str_contains($expression, ';') + || str_contains($expression, '--') + || str_contains($expression, '/*') + || str_contains($expression, '*/') + || str_contains($expression, '`') + ) { + throw InvalidColumnException::invalidColumn($expression); + } + + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + $direction = 'ASC'; + } + + $this->orders[] = [ + 'column' => $expression, + 'direction' => $direction, + 'raw' => true, ]; return $this; @@ -458,6 +505,9 @@ public function count(?string $column = null): int return (int) $this->runAggregate($expr); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function min(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -467,6 +517,9 @@ public function min(string $column): int|float|null return $this->runAggregate('MIN(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function max(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -476,6 +529,9 @@ public function max(string $column): int|float|null return $this->runAggregate('MAX(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function sum(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -485,6 +541,9 @@ public function sum(string $column): int|float|null return $this->runAggregate('SUM(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function avg(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -852,16 +911,14 @@ private function buildOrderByClause(): string return ''; } - $orders = array_map( - fn ($order) => sprintf( - '%s %s', - $this->quoteIdentifier($order['column']), - $order['direction'], - ), + $parts = array_map( + fn ($order) => $order['raw'] + ? sprintf('%s %s', $order['column'], $order['direction']) + : sprintf('%s %s', $this->quoteIdentifier($order['column']), $order['direction']), $this->orders, ); - return ' ORDER BY ' . implode(', ', $orders); + return ' ORDER BY ' . implode(', ', $parts); } private function buildLimitOffsetClause(): string diff --git a/packages/database-mysql/tests/Query/MySqlQueryBuilderOrderByRawTest.php b/packages/database-mysql/tests/Query/MySqlQueryBuilderOrderByRawTest.php new file mode 100644 index 00000000..f8621338 --- /dev/null +++ b/packages/database-mysql/tests/Query/MySqlQueryBuilderOrderByRawTest.php @@ -0,0 +1,200 @@ +lastSql = $sql; + $this->lastBindings = $bindings; + + return []; + } + + public function execute(string $sql, array $bindings = []): int + { + return 0; + } + + public function lastInsertId(): int + { + return 0; + } + + public function beginTransaction(): void {} + + public function commit(): void {} + + public function rollback(): void {} + }; +} + +describe('MySqlQueryBuilder orderByRaw', function (): void { + it('appends a raw expression to the order clause without quoting', function (): void { + $sql = ''; + $bindings = []; + $conn = makeOrderByRawRecordingConnection($sql, $bindings); + + (new MySqlQueryBuilder($conn)) + ->table('products') + ->select('id') + ->orderByRaw('FIELD(status, "active", "inactive")') + ->get(); + + expect($sql)->toBe( + 'SELECT `id` FROM `products` ORDER BY FIELD(status, "active", "inactive") ASC', + ); + }); + + it('rejects expressions containing semicolons', function (): void { + $sql = ''; + $bindings = []; + $conn = makeOrderByRawRecordingConnection($sql, $bindings); + + expect( + fn () => (new MySqlQueryBuilder($conn)) + ->table('products') + ->select('id') + ->orderByRaw('id; DROP TABLE products--'), + )->toThrow(InvalidColumnException::class); + }); + + it('rejects expressions containing SQL comments (-- or /*)', function (): void { + $sql = ''; + $bindings = []; + $conn = makeOrderByRawRecordingConnection($sql, $bindings); + + expect( + fn () => (new MySqlQueryBuilder($conn)) + ->table('products') + ->select('id') + ->orderByRaw('id -- comment'), + )->toThrow(InvalidColumnException::class); + + expect( + fn () => (new MySqlQueryBuilder($conn)) + ->table('products') + ->select('id') + ->orderByRaw('id /* comment */'), + )->toThrow(InvalidColumnException::class); + }); + + it('composes correctly with a regular orderBy call before or after', function (): void { + // orderBy before orderByRaw + $sqlBefore = ''; + $bindingsBefore = []; + $connBefore = makeOrderByRawRecordingConnection($sqlBefore, $bindingsBefore); + + (new MySqlQueryBuilder($connBefore)) + ->table('products') + ->select('id') + ->orderBy('name', 'ASC') + ->orderByRaw('FIELD(status, "active", "inactive")') + ->get(); + + expect($sqlBefore)->toBe( + 'SELECT `id` FROM `products` ORDER BY `name` ASC, FIELD(status, "active", "inactive") ASC', + ); + + // orderByRaw before orderBy + $sqlAfter = ''; + $bindingsAfter = []; + $connAfter = makeOrderByRawRecordingConnection($sqlAfter, $bindingsAfter); + + (new MySqlQueryBuilder($connAfter)) + ->table('products') + ->select('id') + ->orderByRaw('FIELD(status, "active", "inactive")') + ->orderBy('name', 'ASC') + ->get(); + + expect($sqlAfter)->toBe( + 'SELECT `id` FROM `products` ORDER BY FIELD(status, "active", "inactive") ASC, `name` ASC', + ); + }); + + it('emits a single ORDER BY clause with comma-separated entries for mixed regular and raw orders', function (): void { + $sql = ''; + $bindings = []; + $conn = makeOrderByRawRecordingConnection($sql, $bindings); + + (new MySqlQueryBuilder($conn)) + ->table('products') + ->select('id') + ->orderBy('category', 'ASC') + ->orderByRaw('FIELD(status, "active", "inactive")') + ->orderBy('name', 'DESC') + ->get(); + + expect($sql)->toBe( + 'SELECT `id` FROM `products` ORDER BY `category` ASC, FIELD(status, "active", "inactive") ASC, `name` DESC', + ); + }); + + it('preserves direction asc or desc on the emitted ORDER BY', function (): void { + $sqlAsc = ''; + $bindingsAsc = []; + $connAsc = makeOrderByRawRecordingConnection($sqlAsc, $bindingsAsc); + + (new MySqlQueryBuilder($connAsc)) + ->table('products') + ->select('id') + ->orderByRaw('FIELD(status, "active", "inactive")', 'asc') + ->get(); + + expect($sqlAsc)->toBe( + 'SELECT `id` FROM `products` ORDER BY FIELD(status, "active", "inactive") ASC', + ); + + $sqlDesc = ''; + $bindingsDesc = []; + $connDesc = makeOrderByRawRecordingConnection($sqlDesc, $bindingsDesc); + + (new MySqlQueryBuilder($connDesc)) + ->table('products') + ->select('id') + ->orderByRaw('FIELD(status, "active", "inactive")', 'desc') + ->get(); + + expect($sqlDesc)->toBe( + 'SELECT `id` FROM `products` ORDER BY FIELD(status, "active", "inactive") DESC', + ); + + $sqlInvalid = ''; + $bindingsInvalid = []; + $connInvalid = makeOrderByRawRecordingConnection($sqlInvalid, $bindingsInvalid); + + (new MySqlQueryBuilder($connInvalid)) + ->table('products') + ->select('id') + ->orderByRaw('FIELD(status, "active", "inactive")', 'INVALID') + ->get(); + + expect($sqlInvalid)->toBe( + 'SELECT `id` FROM `products` ORDER BY FIELD(status, "active", "inactive") ASC', + ); + }); +}); diff --git a/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php b/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php index 6758d1bb..011ce4fc 100644 --- a/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php +++ b/packages/database-pgsql/src/Query/PgSqlQueryBuilder.php @@ -70,6 +70,11 @@ class PgSqlQueryBuilder implements QueryBuilderInterface */ private array $orders = []; + /** + * @var array + */ + private array $rawOrders = []; + private bool $distinct = false; /** @@ -113,6 +118,9 @@ public function distinct(): static return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function union( QueryBuilderInterface $other, ): static { @@ -128,6 +136,9 @@ public function union( return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function unionAll( QueryBuilderInterface $other, ): static { @@ -251,11 +262,17 @@ public function orWhere( return $this; } + /** + * @throws InvalidColumnException When a column name is invalid + */ public function groupBy( string ...$columns, ): static { foreach ($columns as $column) { - if (!IdentifierValidator::isValidIdentifier($column) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', $column)) { + if (!IdentifierValidator::isValidIdentifier($column) && !preg_match( + '/^[a-zA-Z_][a-zA-Z0-9_]*\.[a-zA-Z_][a-zA-Z0-9_]*$/', + $column + )) { throw InvalidColumnException::invalidColumn($column); } } @@ -265,6 +282,9 @@ public function groupBy( return $this; } + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ public function having( string $expression, array $bindings = [], @@ -354,6 +374,36 @@ public function orderBy( return $this; } + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + if ( + str_contains($expression, ';') + || str_contains($expression, '--') + || str_contains($expression, '/*') + || str_contains($expression, '*/') + || str_contains($expression, '`') + ) { + throw InvalidColumnException::invalidColumn($expression); + } + + $direction = strtoupper($direction); + if (!in_array($direction, ['ASC', 'DESC'], true)) { + $direction = 'ASC'; + } + + $this->rawOrders[] = [ + 'expression' => $expression, + 'direction' => $direction, + ]; + + return $this; + } + public function limit( int $limit, ): static { @@ -468,6 +518,9 @@ public function count(?string $column = null): int return (int) $this->runAggregate($expr); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function min(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -477,6 +530,9 @@ public function min(string $column): int|float|null return $this->runAggregate('MIN(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function max(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -486,6 +542,9 @@ public function max(string $column): int|float|null return $this->runAggregate('MAX(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function sum(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -495,6 +554,9 @@ public function sum(string $column): int|float|null return $this->runAggregate('SUM(' . $this->quoteIdentifier($column) . ') as aggregate'); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function avg(string $column): int|float|null { if (!IdentifierValidator::isValidIdentifier($column)) { @@ -607,7 +669,7 @@ private function compilePgJsonPath(string $expression): string $lastIndex = count($path->segments) - 1; foreach ($path->segments as $i => $segment) { - $op = ($i === $lastIndex) ? $path->operator : '->'; + $op = $i === $lastIndex ? $path->operator : '->'; $sql .= $op . "'" . $segment . "'"; } @@ -853,7 +915,7 @@ private function buildWhereClause(): string private function buildOrderByClause(): string { - if (empty($this->orders)) { + if (empty($this->orders) && empty($this->rawOrders)) { return ''; } @@ -866,7 +928,12 @@ private function buildOrderByClause(): string $this->orders, ); - return ' ORDER BY ' . implode(', ', $clauses); + $rawClauses = array_map( + fn (array $order): string => sprintf('%s %s', $order['expression'], $order['direction']), + $this->rawOrders, + ); + + return ' ORDER BY ' . implode(', ', array_merge($clauses, $rawClauses)); } private function buildLimitOffsetClause(): string diff --git a/packages/database-pgsql/tests/Query/PgSqlQueryBuilderOrderByRawTest.php b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderOrderByRawTest.php new file mode 100644 index 00000000..b7fa1d76 --- /dev/null +++ b/packages/database-pgsql/tests/Query/PgSqlQueryBuilderOrderByRawTest.php @@ -0,0 +1,108 @@ +table('products') + ->orderByRaw('LOWER(name)') + ->get(); + + expect($conn->lastQuerySql)->toBe('SELECT * FROM "products" ORDER BY LOWER(name) ASC'); + }); + + it('rejects expressions containing semicolons', function (): void { + $conn = new MockConnection(); + + expect( + fn () => (new PgSqlQueryBuilder($conn)) + ->table('products') + ->orderByRaw('name; DROP TABLE products--'), + )->toThrow(InvalidColumnException::class); + }); + + it('rejects expressions containing SQL comments (-- or /*)', function (): void { + $conn = new MockConnection(); + + expect( + fn () => (new PgSqlQueryBuilder($conn)) + ->table('products') + ->orderByRaw('name -- comment'), + )->toThrow(InvalidColumnException::class); + + expect( + fn () => (new PgSqlQueryBuilder($conn)) + ->table('products') + ->orderByRaw('name /* comment */'), + )->toThrow(InvalidColumnException::class); + }); + + it('preserves direction asc or desc on the emitted ORDER BY', function (): void { + $connAsc = new MockConnection(); + + (new PgSqlQueryBuilder($connAsc)) + ->table('products') + ->orderByRaw('LOWER(name)', 'asc') + ->get(); + + expect($connAsc->lastQuerySql)->toBe('SELECT * FROM "products" ORDER BY LOWER(name) ASC'); + + $connDesc = new MockConnection(); + + (new PgSqlQueryBuilder($connDesc)) + ->table('products') + ->orderByRaw('LOWER(name)', 'desc') + ->get(); + + expect($connDesc->lastQuerySql)->toBe('SELECT * FROM "products" ORDER BY LOWER(name) DESC'); + }); + + it('composes correctly with a regular orderBy call before or after', function (): void { + $connRawFirst = new MockConnection(); + + (new PgSqlQueryBuilder($connRawFirst)) + ->table('products') + ->orderByRaw('LOWER(name)') + ->orderBy('price', 'DESC') + ->get(); + + expect($connRawFirst->lastQuerySql)->toBe( + 'SELECT * FROM "products" ORDER BY "price" DESC, LOWER(name) ASC', + ); + + $connRawAfter = new MockConnection(); + + (new PgSqlQueryBuilder($connRawAfter)) + ->table('products') + ->orderBy('price', 'DESC') + ->orderByRaw('LOWER(name)') + ->get(); + + expect($connRawAfter->lastQuerySql)->toBe( + 'SELECT * FROM "products" ORDER BY "price" DESC, LOWER(name) ASC', + ); + }); + + it('emits a single ORDER BY clause with comma-separated entries for mixed regular and raw orders', function (): void { + $conn = new MockConnection(); + + (new PgSqlQueryBuilder($conn)) + ->table('products') + ->orderBy('category', 'ASC') + ->orderByRaw('LOWER(name)', 'ASC') + ->orderBy('price', 'DESC') + ->get(); + + expect($conn->lastQuerySql)->toBe( + 'SELECT * FROM "products" ORDER BY "category" ASC, "price" DESC, LOWER(name) ASC', + ); + }); +}); diff --git a/packages/database/src/Query/QueryBuilderInterface.php b/packages/database/src/Query/QueryBuilderInterface.php index 1ece559b..31ae298a 100644 --- a/packages/database/src/Query/QueryBuilderInterface.php +++ b/packages/database/src/Query/QueryBuilderInterface.php @@ -204,7 +204,10 @@ public function groupBy(string ...$columns): static; * @return static For fluent chaining * @throws InvalidColumnException When the expression contains dangerous patterns */ - public function having(string $expression, array $bindings = []): static; + public function having( + string $expression, + array $bindings = [], + ): static; /** * Add an ORDER BY clause. @@ -218,6 +221,25 @@ public function orderBy( string $direction = 'ASC', ): static; + /** + * Add an ORDER BY clause with a raw SQL expression. + * + * Security: $expression must not contain semicolons, SQL comment markers, or backticks. + * Never interpolate user-supplied values directly into the expression — use ? placeholders + * and pass values via bindings at the query level instead. + * + * The caller is responsible for ensuring the expression is safe. + * + * @param string $expression Raw SQL expression (e.g. "COALESCE(priority, 999)") + * @param string $direction The sort direction (ASC or DESC) + * @return static For fluent chaining + * @throws InvalidColumnException When the expression contains dangerous patterns + */ + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static; + /** * Set the maximum number of rows to return. * diff --git a/packages/database/src/Repository/RepositoryQueryBuilder.php b/packages/database/src/Repository/RepositoryQueryBuilder.php index deb04d22..da81c07a 100644 --- a/packages/database/src/Repository/RepositoryQueryBuilder.php +++ b/packages/database/src/Repository/RepositoryQueryBuilder.php @@ -9,7 +9,10 @@ use Marko\Database\Entity\EntityHydrator; use Marko\Database\Entity\EntityMetadata; use Marko\Database\Entity\RelationshipLoader; +use Marko\Database\Exceptions\InvalidColumnException; +use Marko\Database\Exceptions\InvalidJsonPathException; use Marko\Database\Exceptions\RepositoryException; +use Marko\Database\Exceptions\UnionShapeMismatchException; use Marko\Database\Query\EntityQueryBuilderInterface; use Marko\Database\Query\QueryBuilderInterface; use Marko\Database\Query\QuerySpecification; @@ -89,13 +92,22 @@ public function whereNotNull( return $this; } - public function whereJsonContains(string $path, mixed $value): static + /** + * @throws InvalidJsonPathException When the path expression is invalid + */ + public function whereJsonContains( + string $path, + mixed $value, + ): static { $this->queryBuilder->whereJsonContains($path, $value); return $this; } + /** + * @throws InvalidJsonPathException When the path expression is invalid + */ public function whereJsonExists(string $path): static { $this->queryBuilder->whereJsonExists($path); @@ -103,6 +115,9 @@ public function whereJsonExists(string $path): static return $this; } + /** + * @throws InvalidJsonPathException When the path expression is invalid + */ public function whereJsonMissing(string $path): static { $this->queryBuilder->whereJsonMissing($path); @@ -160,6 +175,9 @@ public function distinct(): static return $this; } + /** + * @throws InvalidColumnException When a column name is invalid + */ public function groupBy(string ...$columns): static { $this->queryBuilder->groupBy(...$columns); @@ -167,13 +185,22 @@ public function groupBy(string ...$columns): static return $this; } - public function having(string $expression, array $bindings = []): static + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ + public function having( + string $expression, + array $bindings = [], + ): static { $this->queryBuilder->having($expression, $bindings); return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function union( QueryBuilderInterface $other, ): static { @@ -182,6 +209,9 @@ public function union( return $this; } + /** + * @throws UnionShapeMismatchException When column counts differ + */ public function unionAll( QueryBuilderInterface $other, ): static { @@ -199,6 +229,18 @@ public function orderBy( return $this; } + /** + * @throws InvalidColumnException When the expression contains dangerous patterns + */ + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + $this->queryBuilder->orderByRaw($expression, $direction); + + return $this; + } + public function limit( int $limit, ): static { @@ -247,21 +289,33 @@ public function count(?string $column = null): int return $this->queryBuilder->count($column); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function min(string $column): int|float|null { return $this->queryBuilder->min($column); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function max(string $column): int|float|null { return $this->queryBuilder->max($column); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function sum(string $column): int|float|null { return $this->queryBuilder->sum($column); } + /** + * @throws InvalidColumnException When the column name is invalid + */ public function avg(string $column): int|float|null { return $this->queryBuilder->avg($column); diff --git a/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php b/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php index e8e0e5bb..82a7ca3d 100644 --- a/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderBelongsToManyTest.php @@ -280,6 +280,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -535,6 +542,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -794,7 +808,7 @@ function makeBtmLoader(QueryBuilderFactoryInterface $factory, EntityMetadataFact $loader = makeBtmLoader($qbFactory, $metadataFactory); $loader->load([$post], $relationship, $postMeta); - expect($post->tags)->toBe([]); + expect($post->tags)->toBeEmpty(); }); it('resolves through pivot table using two queries', function (): void { @@ -1127,5 +1141,5 @@ function makeBtmLoader(QueryBuilderFactoryInterface $factory, EntityMetadataFact $loader->load([$post1, $post2], $relationship, $postMeta); expect($post1->tags)->toHaveCount(1) - ->and($post2->tags)->toBe([]); + ->and($post2->tags)->toBeEmpty(); }); diff --git a/packages/database/tests/Entity/RelationshipLoaderNestedTest.php b/packages/database/tests/Entity/RelationshipLoaderNestedTest.php index 95a02cbc..1997642c 100644 --- a/packages/database/tests/Entity/RelationshipLoaderNestedTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderNestedTest.php @@ -440,6 +440,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -924,5 +931,5 @@ public function create(): QueryBuilderInterface $tree = ['comments' => ['author' => []]]; $loader->loadNested([$post], $tree, $postMeta); - expect($post->comments)->toBe([]); + expect($post->comments)->toBeEmpty(); }); diff --git a/packages/database/tests/Entity/RelationshipLoaderTest.php b/packages/database/tests/Entity/RelationshipLoaderTest.php index 126c476a..6fa330bf 100644 --- a/packages/database/tests/Entity/RelationshipLoaderTest.php +++ b/packages/database/tests/Entity/RelationshipLoaderTest.php @@ -348,6 +348,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -581,6 +588,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -1059,7 +1073,7 @@ public function parse(string $entityClass): EntityMetadata $loader = makeLoader($qbFactory, $factory); $loader->load([$user], $relationship, $userMeta); - expect($user->posts)->toBe([]); + expect($user->posts)->toBeEmpty(); }); it('groups HasMany results by foreign key value', function (): void { @@ -1099,7 +1113,7 @@ public function parse(string $entityClass): EntityMetadata expect($user1->posts)->toHaveCount(3) ->and($user2->posts)->toHaveCount(1) - ->and($user3->posts)->toBe([]); + ->and($user3->posts)->toBeEmpty(); }); // ── Batch Query Optimization ─────────────────────────────────────────────────── diff --git a/packages/database/tests/Entity/RelationshipValidationTest.php b/packages/database/tests/Entity/RelationshipValidationTest.php index 58233a20..364c16eb 100644 --- a/packages/database/tests/Entity/RelationshipValidationTest.php +++ b/packages/database/tests/Entity/RelationshipValidationTest.php @@ -140,6 +140,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/database/tests/Query/QueryBuilderInterfaceTest.php b/packages/database/tests/Query/QueryBuilderInterfaceTest.php index 74374315..884ae21c 100644 --- a/packages/database/tests/Query/QueryBuilderInterfaceTest.php +++ b/packages/database/tests/Query/QueryBuilderInterfaceTest.php @@ -278,6 +278,26 @@ expect($returnType?->getName())->toBe('array'); }); + it('adds orderByRaw to QueryBuilderInterface with expression and direction parameters', function (): void { + $reflection = new ReflectionClass(QueryBuilderInterface::class); + + expect($reflection->hasMethod('orderByRaw'))->toBeTrue(); + + $method = $reflection->getMethod('orderByRaw'); + $params = $method->getParameters(); + + expect($params)->toHaveCount(2) + ->and($params[0]->getName())->toBe('expression') + ->and($params[0]->getType()?->getName())->toBe('string') + ->and($params[1]->getName())->toBe('direction') + ->and($params[1]->getType()?->getName())->toBe('string') + ->and($params[1]->isDefaultValueAvailable())->toBeTrue() + ->and($params[1]->getDefaultValue())->toBe('ASC'); + + $returnType = $method->getReturnType(); + expect($returnType?->getName())->toBe('static'); + }); + it('defines min(), max(), sum(), avg() aggregate methods returning int|float|null', function (): void { $reflection = new ReflectionClass(QueryBuilderInterface::class); diff --git a/packages/database/tests/Query/QuerySpecificationTest.php b/packages/database/tests/Query/QuerySpecificationTest.php index 16fac6a7..7e4d86f6 100644 --- a/packages/database/tests/Query/QuerySpecificationTest.php +++ b/packages/database/tests/Query/QuerySpecificationTest.php @@ -132,6 +132,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -350,6 +357,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/database/tests/Query/SpecEagerLoadCompositionTest.php b/packages/database/tests/Query/SpecEagerLoadCompositionTest.php index 27c7d362..621028e4 100644 --- a/packages/database/tests/Query/SpecEagerLoadCompositionTest.php +++ b/packages/database/tests/Query/SpecEagerLoadCompositionTest.php @@ -188,6 +188,11 @@ public function orderBy(string $column, string $direction = 'ASC'): static return $this; } + public function orderByRaw(string $expression, string $direction = 'ASC'): static + { + return $this; + } + public function limit(int $limit): static { return $this; @@ -373,6 +378,11 @@ public function orderBy(string $column, string $direction = 'ASC'): static return $this; } + public function orderByRaw(string $expression, string $direction = 'ASC'): static + { + return $this; + } + public function limit(int $limit): static { return $this; @@ -648,6 +658,11 @@ public function orderBy(string $column, string $direction = 'ASC'): static return $this; } + public function orderByRaw(string $expression, string $direction = 'ASC'): static + { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/database/tests/Repository/RepositoryMatchingTest.php b/packages/database/tests/Repository/RepositoryMatchingTest.php index 3bb9c1da..63a03b26 100644 --- a/packages/database/tests/Repository/RepositoryMatchingTest.php +++ b/packages/database/tests/Repository/RepositoryMatchingTest.php @@ -148,6 +148,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php b/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php index 714447c1..06780c36 100644 --- a/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php +++ b/packages/database/tests/Repository/RepositoryQueryBuilderEnhancedTest.php @@ -83,6 +83,9 @@ function makeRqbStubBuilder(array $rows = []): QueryBuilderInterface /** @var array */ public array $orderByCalled = []; + /** @var array */ + public array $orderByRawCalled = []; + public function __construct(private readonly array $rows) {} public function table(string $table): static @@ -181,6 +184,15 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + $this->orderByRawCalled[] = "$expression $direction"; + + return $this; + } + public function limit(int $limit): static { return $this; @@ -500,3 +512,31 @@ public function apply(QueryBuilderInterface $builder): void expect($result)->toBeInstanceOf(EntityCollection::class) ->and($result->isEmpty())->toBeTrue(); }); + +it('adds orderByRaw to RepositoryQueryBuilder delegating to the wrapped builder', function (): void { + $stub = makeRqbStubBuilder([]); + $rqb = makeRqb($stub); + + $rqb->orderByRaw('COALESCE(priority, 999)', 'ASC'); + + expect($stub->orderByRawCalled)->toBe(['COALESCE(priority, 999) ASC']); +}); + +it('returns static for chaining', function (): void { + $stub = makeRqbStubBuilder([]); + $rqb = makeRqb($stub); + + $result = $rqb->orderByRaw('COALESCE(priority, 999)'); + + expect($result)->toBeInstanceOf(RepositoryQueryBuilder::class) + ->and($result)->toBe($rqb); +}); + +it('preserves expression text passing through to driver implementations', function (): void { + $stub = makeRqbStubBuilder([]); + $rqb = makeRqb($stub); + + $rqb->orderByRaw('COALESCE(priority, 999)', 'DESC'); + + expect($stub->orderByRawCalled)->toBe(['COALESCE(priority, 999) DESC']); +}); diff --git a/packages/database/tests/Repository/RepositoryTest.php b/packages/database/tests/Repository/RepositoryTest.php index 917d0c94..8e5f51bd 100644 --- a/packages/database/tests/Repository/RepositoryTest.php +++ b/packages/database/tests/Repository/RepositoryTest.php @@ -1200,6 +1200,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit( int $limit, ): static { diff --git a/packages/database/tests/Repository/RepositoryWithTest.php b/packages/database/tests/Repository/RepositoryWithTest.php index 48be297e..21bdb878 100644 --- a/packages/database/tests/Repository/RepositoryWithTest.php +++ b/packages/database/tests/Repository/RepositoryWithTest.php @@ -197,6 +197,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -651,6 +658,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -897,6 +911,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; @@ -1136,6 +1157,13 @@ public function orderBy( return $this; } + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/database/tests/Repository/StringPrimaryKeyTest.php b/packages/database/tests/Repository/StringPrimaryKeyTest.php index faf30c33..ef11fbd7 100644 --- a/packages/database/tests/Repository/StringPrimaryKeyTest.php +++ b/packages/database/tests/Repository/StringPrimaryKeyTest.php @@ -229,6 +229,11 @@ public function orderBy(string $column, string $direction = 'ASC'): static return $this; } + public function orderByRaw(string $expression, string $direction = 'ASC'): static + { + return $this; + } + public function limit(int $limit): static { return $this; diff --git a/packages/scope-mysql/.gitattributes b/packages/scope-mysql/.gitattributes new file mode 100644 index 00000000..c8df2f0b --- /dev/null +++ b/packages/scope-mysql/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore + diff --git a/packages/scope-mysql/LICENSE b/packages/scope-mysql/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/scope-mysql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/scope-mysql/README.md b/packages/scope-mysql/README.md new file mode 100644 index 00000000..e809ee62 --- /dev/null +++ b/packages/scope-mysql/README.md @@ -0,0 +1,36 @@ +# marko/scope-mysql + +MySQL and MariaDB driver for `marko/scope` — scoped `ORDER BY` and automatic `scopes` column migration. + +## Installation + +```bash +composer require marko/scope-mysql +``` + +Installs `marko/scope` automatically as a transitive dependency. + +## Quick Example + +```php +use App\Catalog\Entity\Product; +use Marko\Scope\Context\ScopeContext; +use Marko\Scope\Query\ScopedOrderByFactory; + +$scopeContext->in('locale', 'de-DE'); + +$products = $productRepository->matching( + $scopedOrderByFactory->create(Product::class, 'name'), +); + +// Emitted SQL: +// ORDER BY COALESCE( +// JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$."locale:de-DE".name')), +// JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$."locale:de".name')), +// `name` +// ) ASC +``` + +## Documentation + +Full usage, API reference, and examples: [marko/scope-mysql](https://marko.build/docs/packages/scope-mysql/) diff --git a/packages/scope-mysql/composer.json b/packages/scope-mysql/composer.json new file mode 100644 index 00000000..7eb06e94 --- /dev/null +++ b/packages/scope-mysql/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marko/scope-mysql", + "description": "MySQL driver for Marko Framework Scope Module", + "license": "MIT", + "type": "library", + "require": { + "php": "^8.5", + "marko/core": "self.version", + "marko/scope": "self.version", + "marko/database-mysql": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Marko\\Scope\\MySql\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Scope\\MySql\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/scope-mysql/module.php b/packages/scope-mysql/module.php new file mode 100644 index 00000000..bd80aeef --- /dev/null +++ b/packages/scope-mysql/module.php @@ -0,0 +1,15 @@ + [ + ScopeSortRendererInterface::class => MySqlScopeSortRenderer::class, + ], +]; diff --git a/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php b/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php new file mode 100644 index 00000000..9d3f76d9 --- /dev/null +++ b/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php @@ -0,0 +1,78 @@ +validate($expression); + + if ($expression->paths === []) { + return sprintf( + '`%s` %s', + $expression->column, + strtoupper($expression->direction), + ); + } + + $jsonParts = []; + foreach ($expression->paths as $pathEntry) { + $jsonKey = $pathEntry['axis'] . ':' . $pathEntry['path']; + $jsonParts[] = sprintf( + 'JSON_UNQUOTE(JSON_EXTRACT(`%s`, \'$."%s".%s\'))', + $expression->jsonColumn, + $jsonKey, + $expression->property, + ); + } + + $jsonParts[] = sprintf('`%s`', $expression->column); + + return sprintf( + 'COALESCE(%s) %s', + implode(', ', $jsonParts), + strtoupper($expression->direction), + ); + } + + /** + * @throws InvalidColumnException + */ + private function validate(ScopeSortExpression $expression): void + { + if (!IdentifierValidator::isValidIdentifier($expression->column)) { + throw InvalidColumnException::invalidColumn($expression->column); + } + + if (!IdentifierValidator::isValidIdentifier($expression->property)) { + throw InvalidColumnException::invalidColumn($expression->property); + } + + if (!IdentifierValidator::isValidIdentifier($expression->jsonColumn)) { + throw InvalidColumnException::invalidColumn($expression->jsonColumn); + } + + foreach ($expression->paths as $pathEntry) { + if (!IdentifierValidator::isValidIdentifier($pathEntry['axis'])) { + throw InvalidColumnException::invalidColumn($pathEntry['axis']); + } + + foreach (explode('.', $pathEntry['path']) as $segment) { + if ($segment !== '' && !IdentifierValidator::isValidIdentifier($segment)) { + throw InvalidColumnException::invalidColumn($segment); + } + } + } + } +} diff --git a/packages/scope-mysql/tests/Feature/AutoMigrationTest.php b/packages/scope-mysql/tests/Feature/AutoMigrationTest.php new file mode 100644 index 00000000..a1996bae --- /dev/null +++ b/packages/scope-mysql/tests/Feature/AutoMigrationTest.php @@ -0,0 +1,186 @@ +registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + expect($registry->hasTable('products'))->toBeTrue() + ->and($registry->getTables())->toHaveCount(1) + ->and($registry->getEntityClass('products'))->toBe(AutoMigrationProduct::class); +}); + +it('merges the scopes column into the parent products table at schema-build time', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + $table = $registry->getTable('products'); + $columnNames = array_map(fn ($c) => $c->name, $table->columns); + + expect($columnNames)->toContain('scopes') + ->and($table->columns)->toHaveCount(3); +}); + +it('emits ALTER TABLE products ADD COLUMN scopes JSON NULL when diffing against an empty schema', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + $existingProductsTable = new SchemaTable( + name: 'products', + columns: [ + new SchemaColumn(name: 'id', type: 'integer', primaryKey: true, autoIncrement: true), + new SchemaColumn(name: 'name', type: 'varchar', length: 255), + ], + indexes: [], + ); + + $diff = (new DiffCalculator())->calculate( + $registry->getTables(), + ['products' => $existingProductsTable], + ); + + $statements = (new MySqlGenerator())->generateUp($diff); + + expect($statements)->toHaveCount(1) + ->and($statements[0])->toContain('ALTER TABLE `products`') + ->and($statements[0])->toContain('ADD COLUMN') + ->and($statements[0])->toContain('`scopes`') + ->and($statements[0])->toContain('JSON') + ->and($statements[0])->toContain('NULL'); +}); + +it('does not emit a separate scopes_overrides table or treat the extender as a standalone table', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + $diff = (new DiffCalculator())->calculate( + $registry->getTables(), + [], + ); + + $statements = (new MySqlGenerator())->generateUp($diff); + $tableNames = $registry->getTableNames(); + + expect($tableNames)->toHaveCount(1) + ->and($tableNames)->toContain('products') + ->and($tableNames)->not->toContain('auto_migration_product_scoped_overrides') + ->and($statements)->toHaveCount(1) + ->and($statements[0])->toContain('CREATE TABLE `products`'); + foreach ($statements as $statement) { + expect($statement)->not->toContain('scopes_overrides'); + } +}); + +it('preserves the parent entity columns in the merged schema', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + $table = $registry->getTable('products'); + $columnNames = array_map(fn ($c) => $c->name, $table->columns); + + expect($columnNames)->toContain('id') + ->and($columnNames)->toContain('name') + ->and($columnNames)->toContain('scopes'); + + $idColumn = array_values(array_filter($table->columns, fn ($c) => $c->name === 'id'))[0]; + expect($idColumn->primaryKey)->toBeTrue() + ->and($idColumn->autoIncrement)->toBeTrue(); +}); + +it('does not emit any ALTER TABLE statement when the scopes column already exists', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([ + AutoMigrationProduct::class, + AutoMigrationProductScopedOverrides::class, + ]); + + $existingProductsTable = new SchemaTable( + name: 'products', + columns: [ + new SchemaColumn(name: 'id', type: 'integer', primaryKey: true, autoIncrement: true), + new SchemaColumn(name: 'name', type: 'varchar', length: 255), + new SchemaColumn(name: 'scopes', type: 'json', nullable: true), + ], + indexes: [], + ); + + $diff = (new DiffCalculator())->calculate( + $registry->getTables(), + ['products' => $existingProductsTable], + ); + + $statements = (new MySqlGenerator())->generateUp($diff); + + expect($statements)->toBeEmpty(); +}); diff --git a/packages/scope-mysql/tests/PackageScaffoldingTest.php b/packages/scope-mysql/tests/PackageScaffoldingTest.php new file mode 100644 index 00000000..91912981 --- /dev/null +++ b/packages/scope-mysql/tests/PackageScaffoldingTest.php @@ -0,0 +1,58 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toBeNull() + ->and($composer['name'])->toBe('marko/scope-mysql') + ->and($composer['extra']['marko']['module'])->toBeTrue(); +}); + +it('requires marko/scope and marko/database-mysql in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/scope') + ->and($composer['require'])->toHaveKey('marko/database-mysql'); +}); + +it('autoloads PSR-4 namespace Marko\Scope\MySql\ from packages/scope-mysql/src/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload']['psr-4'])->toHaveKey('Marko\\Scope\\MySql\\') + ->and($composer['autoload']['psr-4']['Marko\\Scope\\MySql\\'])->toBe('src/'); +}); + +it('autoloads tests namespace Marko\Scope\MySql\Tests\ from tests/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\Scope\\MySql\\Tests\\') + ->and($composer['autoload-dev']['psr-4']['Marko\\Scope\\MySql\\Tests\\'])->toBe('tests/'); +}); + +it('has a module.php returning an array with bindings key', function (): void { + $modulePath = dirname(__DIR__) . '/module.php'; + + expect(file_exists($modulePath))->toBeTrue(); + + $module = require $modulePath; + + expect($module)->toBeArray() + ->and($module)->toHaveKey('bindings') + ->and($module['bindings'])->toBeArray(); +}); + +it('has no version field in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('version'); +}); diff --git a/packages/scope-mysql/tests/Pest.php b/packages/scope-mysql/tests/Pest.php new file mode 100644 index 00000000..c7e826c4 --- /dev/null +++ b/packages/scope-mysql/tests/Pest.php @@ -0,0 +1,21 @@ +toBeArray() + ->and($module)->toHaveKey('bindings'); +}); + +it('binds ScopeSortRendererInterface to MySqlScopeSortRenderer', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['bindings'])->toHaveKey(ScopeSortRendererInterface::class) + ->and($module['bindings'][ScopeSortRendererInterface::class])->toBe(MySqlScopeSortRenderer::class); +}); + +it('does not re-bind marko/scope interfaces', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + $scopeInterfaces = array_filter( + array_keys($module['bindings']), + fn (string $key): bool => str_starts_with( + $key, + 'Marko\\Scope\\' + ) && $key !== ScopeSortRendererInterface::class, + ); + + expect($scopeInterfaces)->toBeEmpty(); +}); diff --git a/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php b/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php new file mode 100644 index 00000000..b4290727 --- /dev/null +++ b/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php @@ -0,0 +1,152 @@ + 'store', 'path' => 'en'], + ['axis' => 'store', 'path' => ''], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe( + 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:en".price\')), JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:".price\')), `price`) ASC', + ); +}); + +it('embeds path segments containing dots correctly into MySQL JSON paths (e.g. eu.de)', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'name', + column: 'name', + paths: [ + ['axis' => 'geo', 'path' => 'eu.de'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe( + 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:eu.de".name\')), `name`) ASC', + ); +}); + +it('falls back to plain ORDER BY column when the expression has no axis paths', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe('`price` ASC'); +}); + +it('preserves direction asc or desc in the output', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + $asc = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [['axis' => 'store', 'path' => 'en']], + direction: 'asc', + ); + + $desc = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [['axis' => 'store', 'path' => 'en']], + direction: 'desc', + ); + + expect($renderer->render($asc))->toEndWith(' ASC') + ->and($renderer->render($desc))->toEndWith(' DESC'); +}); + +it('validates fallback column, property, and json column identifiers against the safe pattern', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + expect(fn () => $renderer->render(new ScopeSortExpression( + property: 'price', + column: 'bad column', + paths: [], + direction: 'asc', + )))->toThrow(InvalidColumnException::class) + ->and(fn () => $renderer->render(new ScopeSortExpression( + property: 'bad property', + column: 'price', + paths: [], + direction: 'asc', + )))->toThrow(InvalidColumnException::class) + ->and(fn () => $renderer->render(new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [], + direction: 'asc', + jsonColumn: 'bad column', + )))->toThrow(InvalidColumnException::class); +}); + +it('composes the JSON key from already-validated axis name and path segments', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'name', + column: 'name', + paths: [ + ['axis' => 'geo', 'path' => 'eu'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toContain('"geo:eu"'); +}); + +it('renders a multi-axis sort with axes in declared priority order', function (): void { + $renderer = new MySqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [ + ['axis' => 'store', 'path' => 'en'], + ['axis' => 'store', 'path' => ''], + ['axis' => 'geo', 'path' => 'de'], + ['axis' => 'geo', 'path' => ''], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe( + 'COALESCE(' + . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:en".price\')), ' + . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:".price\')), ' + . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:de".price\')), ' + . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:".price\')), ' + . '`price`) ASC', + ); +}); diff --git a/packages/scope-mysql/tests/Unit/ReadmeTest.php b/packages/scope-mysql/tests/Unit/ReadmeTest.php new file mode 100644 index 00000000..4f4b766a --- /dev/null +++ b/packages/scope-mysql/tests/Unit/ReadmeTest.php @@ -0,0 +1,44 @@ +toContain('# marko/scope-mysql') + ->and($content)->toContain('MySQL') + ->and($content)->toContain('MariaDB') + ->and($content)->toContain('marko/scope'); +}); + +it('has an Installation section with composer command', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Installation') + ->and($content)->toContain('composer require marko/scope-mysql'); +}); + +it('has a quick example showing scoped ORDER BY with ScopedOrderByFactory', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('ScopedOrderByFactory') + ->and($content)->toContain('matching'); +}); + +it('shows emitted SQL using JSON_UNQUOTE and JSON_EXTRACT', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('JSON_UNQUOTE') + ->and($content)->toContain('JSON_EXTRACT'); +}); + +it('has a Documentation link', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Documentation'); +}); diff --git a/packages/scope-pgsql/.gitattributes b/packages/scope-pgsql/.gitattributes new file mode 100644 index 00000000..e5736f06 --- /dev/null +++ b/packages/scope-pgsql/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore diff --git a/packages/scope-pgsql/LICENSE b/packages/scope-pgsql/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/scope-pgsql/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/scope-pgsql/README.md b/packages/scope-pgsql/README.md new file mode 100644 index 00000000..966f8461 --- /dev/null +++ b/packages/scope-pgsql/README.md @@ -0,0 +1,36 @@ +# marko/scope-pgsql + +PostgreSQL driver for `marko/scope` — jsonb column support and scoped `ORDER BY`. + +## Installation + +```bash +composer require marko/scope-pgsql +``` + +Installs `marko/scope` automatically as a transitive dependency. + +## Quick Example + +```php +use App\Catalog\Entity\Product; +use Marko\Scope\Context\ScopeContext; +use Marko\Scope\Query\ScopedOrderByFactory; + +$scopeContext->in('locale', 'de-DE'); + +$products = $productRepository->matching( + $scopedOrderByFactory->create(Product::class, 'name'), +); + +// Emitted SQL: +// ORDER BY COALESCE( +// "scopes"->'locale:de-DE'->>'name', +// "scopes"->'locale:de'->>'name', +// "name" +// ) ASC +``` + +## Documentation + +Full usage, API reference, and examples: [marko/scope-pgsql](https://marko.build/docs/packages/scope-pgsql/) diff --git a/packages/scope-pgsql/composer.json b/packages/scope-pgsql/composer.json new file mode 100644 index 00000000..188f20c2 --- /dev/null +++ b/packages/scope-pgsql/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marko/scope-pgsql", + "description": "PostgreSQL driver for Marko Framework Scope Module", + "license": "MIT", + "type": "marko-module", + "require": { + "php": "^8.5", + "marko/core": "self.version", + "marko/scope": "self.version", + "marko/database-pgsql": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Marko\\Scope\\PgSql\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Scope\\PgSql\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/scope-pgsql/module.php b/packages/scope-pgsql/module.php new file mode 100644 index 00000000..e8f13663 --- /dev/null +++ b/packages/scope-pgsql/module.php @@ -0,0 +1,15 @@ + [ + ScopeSortRendererInterface::class => PgSqlScopeSortRenderer::class, + ], +]; diff --git a/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php b/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php new file mode 100644 index 00000000..c7628df0 --- /dev/null +++ b/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php @@ -0,0 +1,63 @@ +validate($expression); + + $direction = strtoupper($expression->direction); + + if ($expression->paths === []) { + return "\"$expression->column\" $direction"; + } + + $parts = []; + + foreach ($expression->paths as $path) { + $jsonKey = $path['axis'] . ':' . $path['path']; + $parts[] = "\"$expression->jsonColumn\"->'$jsonKey'->>'$expression->property'"; + } + + $parts[] = "\"$expression->column\""; + $coalesce = 'COALESCE(' . implode(', ', $parts) . ')'; + + return "$coalesce $direction"; + } + + /** + * @throws InvalidColumnException + */ + private function validate(ScopeSortExpression $expression): void + { + foreach ([$expression->column, $expression->property, $expression->jsonColumn] as $identifier) { + if (!IdentifierValidator::isValidIdentifier($identifier)) { + throw InvalidColumnException::invalidColumn($identifier); + } + } + + foreach ($expression->paths as $path) { + if (!IdentifierValidator::isValidIdentifier($path['axis'])) { + throw InvalidColumnException::invalidColumn($path['axis']); + } + + foreach (explode('.', $path['path']) as $segment) { + if (!IdentifierValidator::isValidIdentifier($segment)) { + throw InvalidColumnException::invalidColumn($segment); + } + } + } + } +} diff --git a/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php b/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php new file mode 100644 index 00000000..617b81fb --- /dev/null +++ b/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php @@ -0,0 +1,177 @@ +registerEntities([Product::class, ProductScopedOverrides::class]); + + expect($registry->hasTable('products'))->toBeTrue() + ->and($registry->getEntityClass('products'))->toBe(Product::class); +}); + +it('merges the scopes column into the parent products table at schema-build time', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + $table = $registry->getTable('products'); + $columnNames = array_map(fn ($col) => $col->name, $table->columns); + + expect($columnNames)->toContain('scopes'); +}); + +it('does not emit a separate scopes_overrides table', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + expect($registry->getTables())->toHaveCount(1) + ->and($registry->hasTable('products'))->toBeTrue() + ->and($registry->hasTable('scopes_overrides'))->toBeFalse(); +}); + +it('preserves the parent entity columns in the merged schema', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + $table = $registry->getTable('products'); + $columnNames = array_map(fn ($col) => $col->name, $table->columns); + + expect($columnNames)->toContain('id') + ->and($columnNames)->toContain('name') + ->and($columnNames)->toContain('scopes'); +}); + +it('aliases json to JSONB via PgSqlGenerator TYPE_MAP rather than emitting JSON', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + $databaseSchema = [ + 'products' => new SchemaTable( + name: 'products', + columns: [ + new SchemaColumn(name: 'id', type: 'integer', primaryKey: true, autoIncrement: true), + new SchemaColumn(name: 'name', type: 'varchar', length: 255), + ], + indexes: [], + ), + ]; + + $diff = (new DiffCalculator())->calculate($registry->getTables(), $databaseSchema); + $generator = new PgSqlGenerator(); + $statements = $generator->generateUp($diff); + + expect($statements[0])->toContain('JSONB') + ->and($statements[0])->not->toContain('JSON '); +}); + +it('does not emit any ALTER TABLE statement when the scopes column already exists', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + // Database state: products table already has the scopes column + $databaseSchema = [ + 'products' => new SchemaTable( + name: 'products', + columns: [ + new SchemaColumn(name: 'id', type: 'integer', primaryKey: true, autoIncrement: true), + new SchemaColumn(name: 'name', type: 'varchar', length: 255), + new SchemaColumn(name: 'scopes', type: 'json', nullable: true), + ], + indexes: [], + ), + ]; + + $diff = (new DiffCalculator())->calculate($registry->getTables(), $databaseSchema); + $generator = new PgSqlGenerator(); + $statements = $generator->generateUp($diff); + + expect($statements)->toBeEmpty(); +}); + +it('emits ALTER TABLE products ADD COLUMN scopes JSONB when diffing against an empty schema', function (): void { + $registry = new SchemaRegistry( + metadataFactory: new EntityMetadataFactory(), + schemaBuilder: new SchemaBuilder(), + ); + + $registry->registerEntities([Product::class, ProductScopedOverrides::class]); + + // Database state: products table exists but without the scopes column + $databaseSchema = [ + 'products' => new SchemaTable( + name: 'products', + columns: [ + new SchemaColumn(name: 'id', type: 'integer', primaryKey: true, autoIncrement: true), + new SchemaColumn(name: 'name', type: 'varchar', length: 255), + ], + indexes: [], + ), + ]; + + $diff = (new DiffCalculator())->calculate($registry->getTables(), $databaseSchema); + $generator = new PgSqlGenerator(); + $statements = $generator->generateUp($diff); + + expect($statements)->toHaveCount(1) + ->and($statements[0])->toContain('ALTER TABLE "products"') + ->and($statements[0])->toContain('ADD COLUMN') + ->and($statements[0])->toContain('"scopes"') + ->and($statements[0])->toContain('JSONB'); +}); diff --git a/packages/scope-pgsql/tests/PackageScaffoldingTest.php b/packages/scope-pgsql/tests/PackageScaffoldingTest.php new file mode 100644 index 00000000..6d28773e --- /dev/null +++ b/packages/scope-pgsql/tests/PackageScaffoldingTest.php @@ -0,0 +1,58 @@ +toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toBeNull() + ->and($composer['name'])->toBe('marko/scope-pgsql') + ->and($composer['extra']['marko']['module'])->toBeTrue(); +}); + +it('requires marko/scope and marko/database-pgsql in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['require'])->toHaveKey('marko/scope') + ->and($composer['require'])->toHaveKey('marko/database-pgsql'); +}); + +it('autoloads PSR-4 namespace Marko\Scope\PgSql\ from packages/scope-pgsql/src/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload']['psr-4'])->toHaveKey('Marko\\Scope\\PgSql\\') + ->and($composer['autoload']['psr-4']['Marko\\Scope\\PgSql\\'])->toBe('src/'); +}); + +it('autoloads tests namespace Marko\Scope\PgSql\Tests\ from tests/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\Scope\\PgSql\\Tests\\') + ->and($composer['autoload-dev']['psr-4']['Marko\\Scope\\PgSql\\Tests\\'])->toBe('tests/'); +}); + +it('has a module.php returning an array with bindings key', function (): void { + $modulePath = dirname(__DIR__) . '/module.php'; + + expect(file_exists($modulePath))->toBeTrue(); + + $module = require $modulePath; + + expect($module)->toBeArray() + ->and($module)->toHaveKey('bindings') + ->and($module['bindings'])->toBeArray(); +}); + +it('has no version field in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('version'); +}); diff --git a/packages/scope-pgsql/tests/Unit/ModuleTest.php b/packages/scope-pgsql/tests/Unit/ModuleTest.php new file mode 100644 index 00000000..3d8489ef --- /dev/null +++ b/packages/scope-pgsql/tests/Unit/ModuleTest.php @@ -0,0 +1,34 @@ +toBeArray() + ->and($module)->toHaveKey('bindings'); +}); + +it('binds ScopeSortRendererInterface to PgSqlScopeSortRenderer', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['bindings'])->toHaveKey(ScopeSortRendererInterface::class) + ->and($module['bindings'][ScopeSortRendererInterface::class])->toBe(PgSqlScopeSortRenderer::class); +}); + +it('does not re-bind marko/scope interfaces', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + $scopeInterfaces = array_filter( + array_keys($module['bindings']), + fn (string $key): bool => str_starts_with( + $key, + 'Marko\\Scope\\' + ) && $key !== ScopeSortRendererInterface::class, + ); + + expect($scopeInterfaces)->toBeEmpty(); +}); diff --git a/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php b/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php new file mode 100644 index 00000000..b51cbbc8 --- /dev/null +++ b/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php @@ -0,0 +1,140 @@ + 'store', 'path' => 'store'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe('COALESCE("scopes"->\'store:store\'->>\'price\', "price") ASC'); +}); + +it('renders a multi-axis sort with axes in declared priority order', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [ + ['axis' => 'store', 'path' => 'store.en'], + ['axis' => 'store', 'path' => 'store'], + ['axis' => 'geo', 'path' => 'geo.de'], + ['axis' => 'geo', 'path' => 'geo'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe( + 'COALESCE("scopes"->\'store:store.en\'->>\'price\', "scopes"->\'store:store\'->>\'price\', "scopes"->\'geo:geo.de\'->>\'price\', "scopes"->\'geo:geo\'->>\'price\', "price") ASC', + ); +}); + +it('composes the JSON key from already-validated axis name and path segments', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'name', + column: 'name', + paths: [ + ['axis' => 'locale', 'path' => 'en'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toContain("\"scopes\"->'locale:en'->>'name'"); +}); + +it('validates fallback column, property, and json column identifiers against the safe pattern', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + expect(fn () => $renderer->render(new ScopeSortExpression( + property: 'price', + column: '0invalid', + paths: [], + direction: 'asc', + )))->toThrow(InvalidColumnException::class) + ->and(fn () => $renderer->render(new ScopeSortExpression( + property: 'pri ce', + column: 'price', + paths: [], + direction: 'asc', + )))->toThrow(InvalidColumnException::class) + ->and(fn () => $renderer->render(new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [], + direction: 'asc', + jsonColumn: 'bad-column', + )))->toThrow(InvalidColumnException::class); +}); + +it('preserves direction asc or desc in the output', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + $ascending = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [['axis' => 'store', 'path' => 'store']], + direction: 'asc', + ); + + $descending = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [['axis' => 'store', 'path' => 'store']], + direction: 'desc', + ); + + expect($renderer->render($ascending))->toEndWith(' ASC') + ->and($renderer->render($descending))->toEndWith(' DESC'); +}); + +it('falls back to plain ORDER BY column when the expression has no axis paths', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe('"price" ASC'); +}); + +it('embeds path segments containing dots correctly into PG jsonb paths (e.g. eu.de)', function (): void { + $renderer = new PgSqlScopeSortRenderer(); + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [ + ['axis' => 'geo', 'path' => 'eu.de'], + ], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBe('COALESCE("scopes"->\'geo:eu.de\'->>\'price\', "price") ASC'); +}); diff --git a/packages/scope-pgsql/tests/Unit/ReadmeTest.php b/packages/scope-pgsql/tests/Unit/ReadmeTest.php new file mode 100644 index 00000000..8a4af5ab --- /dev/null +++ b/packages/scope-pgsql/tests/Unit/ReadmeTest.php @@ -0,0 +1,45 @@ +toContain('# marko/scope-pgsql') + ->and($content)->toContain('PostgreSQL') + ->and($content)->toContain('jsonb') + ->and($content)->toContain('marko/scope'); +}); + +it('has an Installation section with composer command', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Installation') + ->and($content)->toContain('composer require marko/scope-pgsql'); +}); + +it('has a quick example showing scoped ORDER BY with ScopedOrderByFactory', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('ScopedOrderByFactory') + ->and($content)->toContain('matching'); +}); + +it('shows emitted SQL using the JSONB operator', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('jsonb') + ->and($content)->toContain("->>") + ->and($content)->toContain('COALESCE'); +}); + +it('has a Documentation link', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Documentation'); +}); diff --git a/packages/scope/.gitattributes b/packages/scope/.gitattributes new file mode 100644 index 00000000..c8df2f0b --- /dev/null +++ b/packages/scope/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.github export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore + diff --git a/packages/scope/LICENSE b/packages/scope/LICENSE new file mode 100644 index 00000000..eee3e37b --- /dev/null +++ b/packages/scope/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Devtomic LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/scope/README.md b/packages/scope/README.md new file mode 100644 index 00000000..a5d56c8a --- /dev/null +++ b/packages/scope/README.md @@ -0,0 +1,50 @@ +# marko/scope + +Scoped attributes for entities with multi-axis hierarchical fallback. + +## Installation + +```bash +composer require marko/scope +``` + +A driver package is also required for sort rendering: + +```bash +composer require marko/scope-mysql +# or +composer require marko/scope-pgsql +``` + +## Quick Example + +```php +use Marko\Database\Attributes\Column; +use Marko\Database\Attributes\Table; +use Marko\Database\Entity\Entity; +use Marko\Scope\Attributes\Scoped; +use Marko\Scope\Resolver\ScopeResolver; +use Marko\Scope\Scope; + +#[Table('products')] +class Product extends Entity +{ + #[Column(primaryKey: true, autoIncrement: true)] + public int $id; + + #[Column(length: 255)] + #[Scoped(axes: ['locale'])] + public string $name = ''; +} + +// Write a scoped override +$scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); + +// Read with hierarchy fallback (de-DE walks up to de) +$scopeContext->in('locale', 'de-DE'); +$localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' +``` + +## Documentation + +Full usage, API reference, and examples: [marko/scope](https://marko.build/docs/packages/scope/) diff --git a/packages/scope/composer.json b/packages/scope/composer.json new file mode 100644 index 00000000..207a1105 --- /dev/null +++ b/packages/scope/composer.json @@ -0,0 +1,35 @@ +{ + "name": "marko/scope", + "description": "Scope module for Marko Framework", + "license": "MIT", + "type": "marko-module", + "require": { + "php": "^8.5", + "marko/core": "self.version", + "marko/config": "self.version", + "marko/database": "self.version" + }, + "require-dev": { + "pestphp/pest": "^4.0" + }, + "autoload": { + "psr-4": { + "Marko\\Scope\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Marko\\Scope\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "extra": { + "marko": { + "module": true + } + } +} diff --git a/packages/scope/module.php b/packages/scope/module.php new file mode 100644 index 00000000..5f132c6e --- /dev/null +++ b/packages/scope/module.php @@ -0,0 +1,28 @@ + [ + ScopeRegistryInterface::class => function (ContainerInterface $container): PhpScopeRegistry { + return new PhpScopeRegistry($container->get(ConfigRepositoryInterface::class)); + }, + ], + 'singletons' => [ + ScopeContext::class, + ScopeMetadataFactory::class, + ScopeResolver::class, + ScopedOrderByFactory::class, + ScopeWalker::class, + ], +]; diff --git a/packages/scope/src/Attributes/Scoped.php b/packages/scope/src/Attributes/Scoped.php new file mode 100644 index 00000000..8de9f3a1 --- /dev/null +++ b/packages/scope/src/Attributes/Scoped.php @@ -0,0 +1,18 @@ + $axes + */ + public function __construct( + public array $axes = [], + ) {} +} diff --git a/packages/scope/src/Axis/ScopeAxis.php b/packages/scope/src/Axis/ScopeAxis.php new file mode 100644 index 00000000..33e1fbc2 --- /dev/null +++ b/packages/scope/src/Axis/ScopeAxis.php @@ -0,0 +1,15 @@ + */ + private array $state = []; + + public function __construct( + private readonly ScopeRegistryInterface $registry, + ) {} + + /** + * @throws UnknownAxisException|ScopeContextException + */ + public function in( + string $axis, + string $path, + ): static + { + if (!$this->registry->hasAxis($axis)) { + throw UnknownAxisException::forAxis($axis); + } + + $hierarchy = $this->registry->getHierarchy($axis); + + if (!$hierarchy->exists($path)) { + throw ScopeContextException::invalidPath($axis, $path); + } + + $this->state[$axis] = $path; + + return $this; + } + + public function get(string $axis): ?string + { + return $this->state[$axis] ?? null; + } + + public function clear(string $axis): void + { + unset($this->state[$axis]); + } + + public function clearAll(): void + { + $this->state = []; + } + + /** + * @return list + */ + public function activeAxes(): array + { + return array_keys($this->state); + } + + public function registry(): ScopeRegistryInterface + { + return $this->registry; + } +} diff --git a/packages/scope/src/Exceptions/NoDriverException.php b/packages/scope/src/Exceptions/NoDriverException.php new file mode 100644 index 00000000..397c4aec --- /dev/null +++ b/packages/scope/src/Exceptions/NoDriverException.php @@ -0,0 +1,28 @@ + "- `composer require $pkg`", + self::DRIVER_PACKAGES, + )); + + return new self( + message: 'No scope sort renderer driver installed.', + context: 'Attempted to resolve ScopeSortRendererInterface but no implementation is bound.', + suggestion: "Install a scope driver:\n$packageList", + ); + } +} diff --git a/packages/scope/src/Exceptions/ScopeConfigurationException.php b/packages/scope/src/Exceptions/ScopeConfigurationException.php new file mode 100644 index 00000000..a0727581 --- /dev/null +++ b/packages/scope/src/Exceptions/ScopeConfigurationException.php @@ -0,0 +1,81 @@ +> $scopedProperties Map of property name => list of axes + */ + public static function missingOverridesExtender( + string $parentClass, + array $scopedProperties, + ): self + { + $propertyLines = []; + foreach ($scopedProperties as $property => $axes) { + $axesStr = count($axes) > 0 ? implode(', ', $axes) : 'none'; + $propertyLines[] = " - $property (axes: $axesStr)"; + } + $propertiesContext = implode("\n", $propertyLines); + + $shortName = class_exists($parentClass) ? (new ReflectionClass($parentClass))->getShortName() : $parentClass; + $suggestion = "#[Table(extends: $parentClass::class)]\nclass {$shortName}Overrides extends ScopedOverridesEntity {}"; + + return new self( + message: "Entity '$parentClass' declares Scoped properties but has no ScopedOverridesEntity extender registered.", + context: "Validating scoped entity '$parentClass'.\nScoped properties:\n$propertiesContext", + suggestion: $suggestion, + ); + } + + public static function wrongOverridesExtenderBase( + string $parentClass, + string $extenderClass, + ): self + { + return new self( + message: "Extender '$extenderClass' for '$parentClass' does not extend ScopedOverridesEntity.", + context: "Validating scoped entity '$parentClass': found extender '$extenderClass' but it does not extend ScopedOverridesEntity.", + suggestion: "Make '$extenderClass' extend ScopedOverridesEntity instead of Entity directly.", + ); + } +} diff --git a/packages/scope/src/Exceptions/ScopeContextException.php b/packages/scope/src/Exceptions/ScopeContextException.php new file mode 100644 index 00000000..a198320a --- /dev/null +++ b/packages/scope/src/Exceptions/ScopeContextException.php @@ -0,0 +1,58 @@ + */ + private array $pathMap; + + /** @var list */ + private array $paths; + + /** + * @param list $paths + * @throws ScopeConfigurationException + */ + public function __construct(array $paths = []) + { + $map = []; + foreach ($paths as $path) { + if (isset($map[$path])) { + throw ScopeConfigurationException::duplicatePath($path); + } + $map[$path] = true; + } + $this->pathMap = $map; + $this->paths = $paths; + } + + /** + * Build a ScopeHierarchy from a flat list of dotted paths. + * + * @param list $paths + * @throws ScopeConfigurationException + */ + public static function fromPaths(array $paths): self + { + return new self($paths); + } + + /** + * Return all declared paths in declaration order. + * + * @return list + */ + public function paths(): array + { + return $this->paths; + } + + /** + * Determine whether $ancestor is an ancestor of $descendant. + * A path is not an ancestor of itself. + */ + public function isAncestor( + string $ancestor, + string $descendant, + ): bool + { + return str_starts_with($descendant, $ancestor . '.'); + } + + /** + * Check whether a path is declared in this hierarchy. + */ + public function exists(string $path): bool + { + return isset($this->pathMap[$path]); + } + + /** + * Walk up from a path to the root, returning the path and all ancestors. + * + * @return list + * @throws UnknownScopeException + */ + public function walkUp(string $path): array + { + if (!$this->exists($path)) { + throw UnknownScopeException::forAxisAndPath('', $path); + } + + $result = [$path]; + $current = $path; + + while (($dotPos = strrpos($current, '.')) !== false) { + $current = substr($current, 0, $dotPos); + $result[] = $current; + } + + return $result; + } +} diff --git a/packages/scope/src/Metadata/ScopeMetadata.php b/packages/scope/src/Metadata/ScopeMetadata.php new file mode 100644 index 00000000..e34d63e6 --- /dev/null +++ b/packages/scope/src/Metadata/ScopeMetadata.php @@ -0,0 +1,44 @@ +> $scopedProperties Map of property name => list of axes + */ + public function __construct( + private array $scopedProperties = [], + ) {} + + public function hasScopedProperties(): bool + { + return $this->scopedProperties !== []; + } + + public function isScoped(string $property): bool + { + return isset($this->scopedProperties[$property]); + } + + /** + * @return list + */ + public function scopedProperties(): array + { + return array_keys($this->scopedProperties); + } + + /** + * @return list + */ + public function axesForProperty(string $property): array + { + return $this->scopedProperties[$property] ?? []; + } +} diff --git a/packages/scope/src/Metadata/ScopeMetadataFactory.php b/packages/scope/src/Metadata/ScopeMetadataFactory.php new file mode 100644 index 00000000..03c6e270 --- /dev/null +++ b/packages/scope/src/Metadata/ScopeMetadataFactory.php @@ -0,0 +1,66 @@ + + */ + private array $cache = []; + + public function __construct( + private readonly ScopeRegistryInterface $registry, + ) {} + + /** + * Build (or return cached) ScopeMetadata for the given class. + * + * @param class-string $entityClass + * + * @throws UnknownAxisException + */ + public function for(string $entityClass): ScopeMetadata + { + if (isset($this->cache[$entityClass])) { + return $this->cache[$entityClass]; + } + + $reflection = new ReflectionClass($entityClass); + $scopedProperties = []; + + foreach ($reflection->getProperties() as $property) { + $attributes = $property->getAttributes(Scoped::class); + + if (count($attributes) === 0) { + continue; + } + + $scoped = $attributes[0]->newInstance(); + $axes = $scoped->axes; + + foreach ($axes as $axis) { + if (!$this->registry->hasAxis($axis)) { + throw UnknownAxisException::forAxis($axis); + } + } + + $scopedProperties[$property->getName()] = $axes; + } + + $metadata = new ScopeMetadata($scopedProperties); + $this->cache[$entityClass] = $metadata; + + return $metadata; + } +} diff --git a/packages/scope/src/Query/ScopeSortExpression.php b/packages/scope/src/Query/ScopeSortExpression.php new file mode 100644 index 00000000..28a8cd43 --- /dev/null +++ b/packages/scope/src/Query/ScopeSortExpression.php @@ -0,0 +1,30 @@ + $paths + * @throws ScopeConfigurationException + */ + public function __construct( + public string $property, + public string $column, + public array $paths, + public string $direction, + public string $jsonColumn = 'scopes', + ) { + if (!in_array($this->direction, ['asc', 'desc'], true)) { + throw new ScopeConfigurationException( + message: "Invalid sort direction '$this->direction': must be 'asc' or 'desc'", + context: "Building ScopeSortExpression for property '$this->property'", + suggestion: "Use 'asc' for ascending or 'desc' for descending sort direction", + ); + } + } +} diff --git a/packages/scope/src/Query/ScopeSortRendererInterface.php b/packages/scope/src/Query/ScopeSortRendererInterface.php new file mode 100644 index 00000000..557d0ef6 --- /dev/null +++ b/packages/scope/src/Query/ScopeSortRendererInterface.php @@ -0,0 +1,31 @@ +paths in the order they are + * declared (declared-axis-priority + deepest-first walk), wrapping each + * JSON-path lookup in the outermost COALESCE, with the plain column as the + * final fallback. + * + * Example output: + * COALESCE( + * JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$.store.en.price')), + * JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$.store.price')), + * `price` + * ) + */ +interface ScopeSortRendererInterface +{ + /** + * @throws InvalidColumnException When an identifier in the expression is invalid + */ + public function render(ScopeSortExpression $expression): string; +} diff --git a/packages/scope/src/Query/ScopedOrderBy.php b/packages/scope/src/Query/ScopedOrderBy.php new file mode 100644 index 00000000..354adc66 --- /dev/null +++ b/packages/scope/src/Query/ScopedOrderBy.php @@ -0,0 +1,75 @@ +scopeMetadataFactory->for($this->entityClass); + + if (!$metadata->isScoped($this->property)) { + throw ScopeContextException::propertyNotScoped($this->property, $this->entityClass); + } + + $axes = $metadata->axesForProperty($this->property); + $paths = []; + + foreach ($axes as $axis) { + $currentPath = $this->scopeContext->get($axis); + + if ($currentPath === null) { + continue; + } + + $hierarchy = $this->scopeContext->registry()->getHierarchy($axis); + $walkedPaths = $hierarchy->walkUp($currentPath); + + foreach ($walkedPaths as $path) { + $paths[] = ['axis' => $axis, 'path' => $path]; + } + } + + if ($paths === []) { + $builder->orderBy($this->property, strtoupper($this->direction)); + + return; + } + + $expression = new ScopeSortExpression( + property: $this->property, + column: $this->property, + paths: $paths, + direction: $this->direction, + ); + + $sql = $this->scopeSortRenderer->render($expression); + $builder->orderByRaw($sql, strtoupper($this->direction)); + } +} diff --git a/packages/scope/src/Query/ScopedOrderByFactory.php b/packages/scope/src/Query/ScopedOrderByFactory.php new file mode 100644 index 00000000..89a103ed --- /dev/null +++ b/packages/scope/src/Query/ScopedOrderByFactory.php @@ -0,0 +1,33 @@ +scopeMetadataFactory, + scopeContext: $this->scopeContext, + scopeSortRenderer: $this->scopeSortRenderer, + entityClass: $entityClass, + direction: $direction, + ); + } +} diff --git a/packages/scope/src/Registry/PhpScopeRegistry.php b/packages/scope/src/Registry/PhpScopeRegistry.php new file mode 100644 index 00000000..f9ac269b --- /dev/null +++ b/packages/scope/src/Registry/PhpScopeRegistry.php @@ -0,0 +1,93 @@ + */ + private array $axes; + + /** + * @throws ConfigNotFoundException|ScopeConfigurationException + */ + public function __construct(ConfigRepositoryInterface $config) + { + $rawAxes = $config->getArray('scope.axes'); + $this->axes = $this->buildAxes($rawAxes); + } + + public function hasAxis(string $name): bool + { + return isset($this->axes[$name]); + } + + /** + * @throws UnknownAxisException + */ + public function getAxis(string $name): ScopeAxis + { + if (!isset($this->axes[$name])) { + throw UnknownAxisException::forAxis($name); + } + + return $this->axes[$name]; + } + + /** + * @return list + */ + public function listAxes(): array + { + return array_keys($this->axes); + } + + /** + * @throws UnknownAxisException + */ + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->getAxis($axisName)->hierarchy; + } + + /** + * @param array $rawAxes + * @return array + * @throws ScopeConfigurationException + */ + private function buildAxes(array $rawAxes): array + { + $axes = []; + + foreach ($rawAxes as $name => $definition) { + if (!is_array($definition)) { + throw ScopeConfigurationException::malformedConfig( + (string) $name, + 'axis definition must be an array', + ); + } + + $paths = $definition['hierarchy'] ?? []; + + if (!is_array($paths)) { + throw ScopeConfigurationException::malformedConfig( + (string) $name, + "'hierarchy' must be an array of paths", + ); + } + + $hierarchy = new ScopeHierarchy($paths); + $axes[(string) $name] = new ScopeAxis(name: (string) $name, hierarchy: $hierarchy); + } + + return $axes; + } +} diff --git a/packages/scope/src/Registry/ScopeRegistryInterface.php b/packages/scope/src/Registry/ScopeRegistryInterface.php new file mode 100644 index 00000000..9c239540 --- /dev/null +++ b/packages/scope/src/Registry/ScopeRegistryInterface.php @@ -0,0 +1,29 @@ + + */ + public function listAxes(): array; + + /** + * @throws UnknownAxisException + */ + public function getHierarchy(string $axisName): ScopeHierarchy; +} diff --git a/packages/scope/src/Resolution/ScopeWalkResult.php b/packages/scope/src/Resolution/ScopeWalkResult.php new file mode 100644 index 00000000..bdca3c3e --- /dev/null +++ b/packages/scope/src/Resolution/ScopeWalkResult.php @@ -0,0 +1,33 @@ +found; + } + + public function value(): mixed + { + return $this->val; + } +} diff --git a/packages/scope/src/Resolution/ScopeWalker.php b/packages/scope/src/Resolution/ScopeWalker.php new file mode 100644 index 00000000..a02dfc81 --- /dev/null +++ b/packages/scope/src/Resolution/ScopeWalker.php @@ -0,0 +1,81 @@ + $axes + * @throws UnknownAxisException|UnknownScopeException + */ + public function walk( + ScopedOverridesEntity $overrides, + string $property, + array $axes, + ScopeContext $context, + ScopeRegistryInterface $registry, + ): ScopeWalkResult { + foreach ($axes as $axis) { + $path = $context->get($axis); + + if ($path === null) { + continue; + } + + $hierarchy = $registry->getHierarchy($axis); + $walked = $hierarchy->walkUp($path); + + foreach ($walked as $scope) { + $scopeKey = $axis . ':' . $scope; + + if ($overrides->hasOverride($scopeKey, $property)) { + return ScopeWalkResult::found($overrides->getOverride($scopeKey, $property)); + } + } + } + + return ScopeWalkResult::notFound(); + } + + /** + * Walk overrides for a single explicit scope, ignoring any ambient ScopeContext. + * + * @param list $axes + * @throws UnknownAxisException|UnknownScopeException + */ + public function walkAt( + ScopedOverridesEntity $overrides, + string $property, + array $axes, + Scope $scope, + ScopeRegistryInterface $registry, + ): ScopeWalkResult { + $axis = $scope->axisName; + + if (!in_array($axis, $axes, true)) { + return ScopeWalkResult::notFound(); + } + + $hierarchy = $registry->getHierarchy($axis); + $walked = $hierarchy->walkUp($scope->path); + + foreach ($walked as $scopePath) { + $scopeKey = $axis . ':' . $scopePath; + + if ($overrides->hasOverride($scopeKey, $property)) { + return ScopeWalkResult::found($overrides->getOverride($scopeKey, $property)); + } + } + + return ScopeWalkResult::notFound(); + } +} diff --git a/packages/scope/src/Resolver/ScopeResolver.php b/packages/scope/src/Resolver/ScopeResolver.php new file mode 100644 index 00000000..4dfd3704 --- /dev/null +++ b/packages/scope/src/Resolver/ScopeResolver.php @@ -0,0 +1,184 @@ +scopeMetadataFactory->for($entityClass); + $axes = $scopeMetadata->axesForProperty($property); + $registry = $this->getRegistry(); + + $companion = $this->findCompanion($entity); + + if ($companion !== null) { + $result = $this->scopeWalker->walk($companion, $property, $axes, $this->scopeContext, $registry); + + if ($result->isFound()) { + return $result->value(); + } + } + + return $entity->{$property}; + } + + /** + * @throws UnknownAxisException + */ + public function resolvedAt( + Entity $entity, + string $property, + Scope $scope, + ): mixed + { + $entityClass = get_class($entity); + $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); + $axes = $scopeMetadata->axesForProperty($property); + $registry = $this->getRegistry(); + + $companion = $this->findCompanion($entity); + + if ($companion !== null) { + $result = $this->scopeWalker->walkAt($companion, $property, $axes, $scope, $registry); + + if ($result->isFound()) { + return $result->value(); + } + } + + return $entity->{$property}; + } + + /** + * @throws ScopeContextException + * @throws UnknownAxisException + * @throws EntityException + * @throws MissingPrimaryKeyException + */ + public function setOverride( + Entity $entity, + string $property, + mixed $value, + Scope $scope, + ): void + { + $entityClass = get_class($entity); + $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); + + if (!$scopeMetadata->isScoped($property)) { + throw ScopeContextException::propertyNotScoped($property, $entityClass); + } + + $companion = $this->findCompanion($entity); + + if ($companion === null) { + $companion = $this->createCompanion($entityClass); + $entity->attachCompanion($companion); + } + + $scopeKey = $scope->axisName . ':' . $scope->path; + $companion->setOverride($scopeKey, $property, $value); + } + + /** + * @throws ScopeContextException + * @throws UnknownAxisException + */ + public function clearOverride( + Entity $entity, + string $property, + Scope $scope, + ): void + { + $entityClass = get_class($entity); + $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); + + if (!$scopeMetadata->isScoped($property)) { + throw ScopeContextException::propertyNotScoped($property, $entityClass); + } + + $companion = $this->findCompanion($entity); + + if ($companion === null) { + return; + } + + $scopeKey = $scope->axisName . ':' . $scope->path; + $companion->clearOverride($scopeKey, $property); + } + + /** + * @param class-string $entityClass + * @throws ScopeContextException + * @throws EntityException + * @throws MissingPrimaryKeyException + */ + private function createCompanion(string $entityClass): ScopedOverridesEntity + { + $entityMetadata = $this->entityMetadataFactory->parse($entityClass); + + foreach ($entityMetadata->extenders as $extender) { + if (is_subclass_of($extender, ScopedOverridesEntity::class)) { + return new $extender(); + } + } + + throw new ScopeContextException( + message: "No ScopedOverridesEntity subclass found for '$entityClass'", + context: "Creating override companion for '$entityClass'", + suggestion: "Register a class extending ScopedOverridesEntity with #[Table(extends: $entityClass::class)]", + ); + } + + private function findCompanion(Entity $entity): ?ScopedOverridesEntity + { + foreach ($entity->companions() as $companion) { + if ($companion instanceof ScopedOverridesEntity) { + return $companion; + } + } + + return null; + } + + private function getRegistry(): ScopeRegistryInterface + { + return $this->scopeContext->registry(); + } +} diff --git a/packages/scope/src/Scope.php b/packages/scope/src/Scope.php new file mode 100644 index 00000000..3b1c872d --- /dev/null +++ b/packages/scope/src/Scope.php @@ -0,0 +1,40 @@ +axisName . ':' . $this->path; + } + + public function equals(self $other): bool + { + return $this->axisName === $other->axisName + && $this->path === $other->path; + } + + /** + * @throws ScopeConfigurationException + */ + public static function fromString(string $scope): self + { + $parts = explode(':', $scope, 2); + + if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') { + throw ScopeConfigurationException::malformedString($scope); + } + + return new self(axisName: $parts[0], path: $parts[1]); + } +} diff --git a/packages/scope/src/Storage/ScopedDataSerializer.php b/packages/scope/src/Storage/ScopedDataSerializer.php new file mode 100644 index 00000000..9a45f418 --- /dev/null +++ b/packages/scope/src/Storage/ScopedDataSerializer.php @@ -0,0 +1,75 @@ +> $overrides + * @throws JsonException + */ + public function serialize(array $overrides): ?string + { + if ($overrides === []) { + return null; + } + + $converted = []; + foreach ($overrides as $scopeKey => $properties) { + $converted[$scopeKey] = []; + foreach ($properties as $property => $value) { + $converted[$scopeKey][$property] = $this->convertToJsonValue($value); + } + } + + return json_encode($converted, JSON_THROW_ON_ERROR); + } + + private function convertToJsonValue(mixed $value): mixed + { + if ($value instanceof BackedEnum) { + return $value->value; + } + + if ($value instanceof DateTimeImmutable) { + return $value->format('Y-m-d H:i:s'); + } + + return $value; + } + + /** + * Deserialize a JSON string back to an overrides map. + * + * @return array> + * @throws ScopeConfigurationException + */ + public function deserialize(?string $json): array + { + if ($json === null || $json === '') { + return []; + } + + try { + /** @var array> $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ScopeConfigurationException( + message: "Malformed scopes JSON: {$e->getMessage()}", + context: 'Deserializing scopes column JSON', + suggestion: 'Ensure the scopes column contains valid JSON matching the {"axis:path": {"property": value}} shape', + ); + } + + return $decoded; + } +} diff --git a/packages/scope/src/Storage/ScopedOverridesEntity.php b/packages/scope/src/Storage/ScopedOverridesEntity.php new file mode 100644 index 00000000..69063115 --- /dev/null +++ b/packages/scope/src/Storage/ScopedOverridesEntity.php @@ -0,0 +1,72 @@ +scopes ?? []; + $scopes[$scopeKey][$property] = $value; + ksort($scopes[$scopeKey]); + ksort($scopes); + $this->scopes = $scopes; + } + + public function getOverride( + string $scopeKey, + string $property, + ): mixed + { + return $this->scopes[$scopeKey][$property] ?? null; + } + + /** + * @return array> + */ + public function allOverrides(): array + { + return $this->scopes ?? []; + } + + public function hasOverride( + string $scopeKey, + string $property, + ): bool + { + return array_key_exists($scopeKey, $this->scopes ?? []) + && array_key_exists($property, $this->scopes[$scopeKey]); + } + + public function clearOverride( + string $scopeKey, + string $property, + ): void + { + if (!isset($this->scopes[$scopeKey])) { + return; + } + + $scopes = $this->scopes; + unset($scopes[$scopeKey][$property]); + + if ($scopes[$scopeKey] === []) { + unset($scopes[$scopeKey]); + } + + ksort($scopes); + $this->scopes = $scopes ?: null; + } +} diff --git a/packages/scope/src/Validation/ScopedEntityValidator.php b/packages/scope/src/Validation/ScopedEntityValidator.php new file mode 100644 index 00000000..c5c3c447 --- /dev/null +++ b/packages/scope/src/Validation/ScopedEntityValidator.php @@ -0,0 +1,65 @@ +scopeMetadataFactory->for($entityClass); + + if (!$scopeMetadata->hasScopedProperties()) { + return; + } + + $entityMetadata = $this->entityMetadataFactory->parse($entityClass); + $extenders = $entityMetadata->extenders; + + $scopedProperties = []; + foreach ($scopeMetadata->scopedProperties() as $property) { + $scopedProperties[$property] = $scopeMetadata->axesForProperty($property); + } + + $hasValidExtender = false; + foreach ($extenders as $extender) { + if (is_subclass_of($extender, ScopedOverridesEntity::class)) { + $hasValidExtender = true; + break; + } + } + + if (count($extenders) === 0 || !$hasValidExtender) { + if (count($extenders) > 0) { + throw ScopeConfigurationException::wrongOverridesExtenderBase($entityClass, $extenders[0]); + } + + throw ScopeConfigurationException::missingOverridesExtender($entityClass, $scopedProperties); + } + } +} diff --git a/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php new file mode 100644 index 00000000..c0ca23d7 --- /dev/null +++ b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php @@ -0,0 +1,119 @@ +sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; + + return 1; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return 1; + } + }; + + $metadataFactory = new EntityMetadataFactory(); + $hydrator = new EntityHydrator($metadataFactory); + $repository = new DirtyTrackingProductRepository($connection, $metadataFactory, $hydrator); + + $product = new DirtyTrackingProduct(); + $product->name = 'Shirt'; + + $overrides = new DirtyTrackingProductOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $product->attachCompanion($overrides); + + // INSERT — includes scopes column + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('INSERT INTO products') + ->and($sqlLog[0]['sql'])->toContain('scopes'); + + // After INSERT, overrides has originalValues registered — mutate and re-save + $sqlLog = []; + $overrides->setOverride('locale:de', 'name', 'Hallo'); + $repository->save($product); + + // UPDATE should include the scopes column (it is dirty) + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('UPDATE products') + ->and($sqlLog[0]['sql'])->toContain('scopes'); + + // No further mutation — re-save must NOT issue SQL + $sqlLog = []; + $repository->save($product); + + expect($sqlLog)->toBeEmpty(); +}); diff --git a/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php new file mode 100644 index 00000000..70978984 --- /dev/null +++ b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php @@ -0,0 +1,313 @@ +}> $sqlLog + */ +function makeLoggingConnection(array &$sqlLog): ConnectionInterface +{ + return new class ($sqlLog) implements ConnectionInterface + { + private int $lastId = 0; + + public function __construct( + /** @noinspection PhpPropertyOnlyWrittenInspection - Reference property modifies external variable */ + private array &$sqlLog, + ) {} + + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return true; + } + + public function query( + string $sql, + array $bindings = [], + ): array + { + return []; + } + + public function execute( + string $sql, + array $bindings = [], + ): int + { + $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; + $this->lastId++; + + return 1; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return $this->lastId; + } + }; +} + +/** + * Build a fresh repository with a new logging connection. + * + * @param array}> $sqlLog + */ +function makeRepository(array &$sqlLog): ProductOverridesRepository +{ + $connection = makeLoggingConnection($sqlLog); + $metadataFactory = new EntityMetadataFactory(); + $metadataFactory->linkExtenders(Product::class, [ProductScopedOverrides::class]); + $hydrator = new EntityHydrator($metadataFactory); + + return new ProductOverridesRepository($connection, $metadataFactory, $hydrator); +} + +it('dirty-tracks the override companion via Repository::update without a custom plugin', function (): void { + $sqlLog = []; + $repository = makeRepository($sqlLog); + + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $product->attachCompanion($overrides); + + // INSERT + $repository->save($product); + + // Mutate only overrides (no plugin involved — companion path) + $sqlLog = []; + $overrides->setOverride('locale:de', 'name', 'Hallo'); + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('UPDATE products') + ->and($sqlLog[0]['sql'])->toContain('scopes'); + + // No further mutation — re-save must produce no SQL + $sqlLog = []; + $repository->save($product); + + expect($sqlLog)->toBeEmpty(); +}); + +it('round-trips overrides via save then re-hydrate via find', function (): void { + $insertedScopes = null; + $lastId = 0; + + $connection = new class ($insertedScopes, $lastId) implements ConnectionInterface + { + public function __construct( + private mixed &$insertedScopes, + private int &$lastId, + ) {} + + public function connect(): void {} + + public function disconnect(): void {} + + public function isConnected(): bool + { + return true; + } + + public function query( + string $sql, + array $bindings = [], + ): array + { + // Simulate SELECT * FROM products WHERE id = ? + if (str_contains($sql, 'WHERE id = ?')) { + return [[ + 'id' => $this->lastId, + 'name' => 'Shirt', + 'scopes' => $this->insertedScopes, + ]]; + } + + return []; + } + + public function execute( + string $sql, + array $bindings = [], + ): int + { + if (str_starts_with($sql, 'INSERT')) { + $this->lastId++; + $this->insertedScopes = end($bindings); + } + + return 1; + } + + public function prepare(string $sql): StatementInterface + { + throw new RuntimeException('Not implemented'); + } + + public function lastInsertId(): int + { + return $this->lastId; + } + }; + + $metadataFactory = new EntityMetadataFactory(); + $metadataFactory->linkExtenders(Product::class, [ProductScopedOverrides::class]); + $hydrator = new EntityHydrator($metadataFactory); + $repository = new ProductOverridesRepository($connection, $metadataFactory, $hydrator); + + // Build and save a product with overrides + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $product->attachCompanion($overrides); + + $repository->save($product); + + // Re-hydrate via find + /** @var Product $found */ + $found = $repository->find($product->id); + + expect($found)->not->toBeNull(); + + /** @var ProductScopedOverrides $foundOverrides */ + $foundOverrides = $found->companion(ProductScopedOverrides::class); + + expect($foundOverrides)->not->toBeNull() + ->and($foundOverrides->getOverride('geo:eu.de', 'name'))->toBe('Hemd') + ->and($foundOverrides->allOverrides())->toBe(['geo:eu.de' => ['name' => 'Hemd']]); +}); + +it('writes null into the scopes column when all overrides are cleared', function (): void { + $sqlLog = []; + $repository = makeRepository($sqlLog); + + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $product->attachCompanion($overrides); + + // INSERT with overrides + $repository->save($product); + + // Clear all overrides + $sqlLog = []; + $overrides->clearOverride('geo:eu.de', 'name'); + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('UPDATE products') + ->and($sqlLog[0]['sql'])->toContain('scopes') + ->and($sqlLog[0]['bindings'])->toContain(null); +}); + +it('updates only the scopes column when only overrides change', function (): void { + $sqlLog = []; + $repository = makeRepository($sqlLog); + + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $product->attachCompanion($overrides); + + // INSERT + $repository->save($product); + + // Now mutate only the overrides + $sqlLog = []; + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('UPDATE products') + ->and($sqlLog[0]['sql'])->toContain('scopes') + ->and($sqlLog[0]['sql'])->not->toContain('name ='); +}); + +it('saves an entity with overrides serializing them into the scopes column', function (): void { + $sqlLog = []; + $repository = makeRepository($sqlLog); + + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $product->attachCompanion($overrides); + + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('INSERT INTO products') + ->and($sqlLog[0]['sql'])->toContain('scopes') + ->and($sqlLog[0]['bindings'])->toContain('{"geo:eu.de":{"name":"Hemd"}}'); +}); + +it('saves an entity with no overrides leaving the scopes column null', function (): void { + $sqlLog = []; + $repository = makeRepository($sqlLog); + + $product = new Product(); + $product->name = 'Shirt'; + + $overrides = new ProductScopedOverrides(); + $product->attachCompanion($overrides); + + $repository->save($product); + + expect($sqlLog)->toHaveCount(1) + ->and($sqlLog[0]['sql'])->toContain('INSERT INTO products') + ->and($sqlLog[0]['sql'])->toContain('scopes') + ->and($sqlLog[0]['bindings'])->toContain(null); +}); diff --git a/packages/scope/tests/PackageScaffoldingTest.php b/packages/scope/tests/PackageScaffoldingTest.php new file mode 100644 index 00000000..ee6945c2 --- /dev/null +++ b/packages/scope/tests/PackageScaffoldingTest.php @@ -0,0 +1,61 @@ +toHaveKey('php') + ->and($composer['require']['php'])->toBe('^8.5') + ->and($composer['require'])->toHaveKey('marko/core') + ->and($composer['require'])->toHaveKey('marko/config') + ->and($composer['require'])->toHaveKey('marko/database'); +}); + +it('has no version field in composer.json', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toHaveKey('version'); +}); + +it('has a module.php returning array with bindings', function (): void { + $modulePath = dirname(__DIR__) . '/module.php'; + + expect(file_exists($modulePath))->toBeTrue(); + + $module = require $modulePath; + + expect($module)->toBeArray() + ->and($module)->toHaveKey('bindings') + ->and($module['bindings'])->toBeArray(); +}); + +it('autoloads PSR-4 test namespace Marko\Scope\Tests\ from packages/scope/tests/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload-dev']['psr-4'])->toHaveKey('Marko\\Scope\\Tests\\') + ->and($composer['autoload-dev']['psr-4']['Marko\\Scope\\Tests\\'])->toBe('tests/'); +}); + +it('autoloads PSR-4 namespace Marko\Scope\ from packages/scope/src/', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer['autoload']['psr-4'])->toHaveKey('Marko\\Scope\\') + ->and($composer['autoload']['psr-4']['Marko\\Scope\\'])->toBe('src/'); +}); + +it('has a valid composer.json with name marko/scope and extra.marko.module set to true', function (): void { + $composerPath = dirname(__DIR__) . '/composer.json'; + + expect(file_exists($composerPath))->toBeTrue(); + + $composer = json_decode(file_get_contents($composerPath), true); + + expect($composer)->not->toBeNull() + ->and($composer['name'])->toBe('marko/scope') + ->and($composer['extra']['marko']['module'])->toBeTrue(); +}); diff --git a/packages/scope/tests/Pest.php b/packages/scope/tests/Pest.php new file mode 100644 index 00000000..c7e826c4 --- /dev/null +++ b/packages/scope/tests/Pest.php @@ -0,0 +1,21 @@ +axes)->toBe([]); +}); + +it('preserves axes order as declared', function (): void { + $scoped = new Scoped(axes: ['website', 'store', 'customer_group']); + + expect($scoped->axes)->toBe(['website', 'store', 'customer_group']) + ->and(array_keys($scoped->axes))->toBe([0, 1, 2]); +}); + +it('accepts an axes array in the constructor', function (): void { + $scoped = new Scoped(axes: ['store', 'website']); + + expect($scoped->axes)->toBe(['store', 'website']); +}); + +it('is reflectable on a property and round-trips via getAttributes', function (): void { + $entity = new class () + { + #[Scoped(axes: ['store', 'website'])] + public string $value = 'test'; + }; + + $reflection = new ReflectionObject($entity); + $property = $reflection->getProperty('value'); + $attributes = $property->getAttributes(Scoped::class); + $scoped = $attributes[0]->newInstance(); + + expect($attributes)->toHaveCount(1) + ->and($scoped)->toBeInstanceOf(Scoped::class) + ->and($scoped->axes)->toBe(['store', 'website']); +}); + +it('is a readonly class targeting properties only', function (): void { + $reflection = new ReflectionClass(Scoped::class); + $attributes = $reflection->getAttributes(Attribute::class); + $attribute = $attributes[0]->newInstance(); + + expect($reflection->isReadOnly())->toBeTrue() + ->and($attributes)->toHaveCount(1) + ->and($attribute->flags)->toBe(Attribute::TARGET_PROPERTY); +}); diff --git a/packages/scope/tests/Unit/Context/ScopeContextTest.php b/packages/scope/tests/Unit/Context/ScopeContextTest.php new file mode 100644 index 00000000..b631e96d --- /dev/null +++ b/packages/scope/tests/Unit/Context/ScopeContextTest.php @@ -0,0 +1,121 @@ + */ + private array $builtAxes; + + public function __construct(private readonly array $axes) + { + $this->builtAxes = []; + foreach ($axes as $name => $paths) { + $hierarchy = new ScopeHierarchy($paths); + $this->builtAxes[$name] = new ScopeAxis(name: $name, hierarchy: $hierarchy); + } + } + + public function hasAxis(string $name): bool + { + return isset($this->builtAxes[$name]); + } + + public function getAxis(string $name): ScopeAxis + { + if (!isset($this->builtAxes[$name])) { + throw UnknownAxisException::forAxis($name); + } + + return $this->builtAxes[$name]; + } + + /** @return list */ + public function listAxes(): array + { + return array_keys($this->builtAxes); + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->getAxis($axisName)->hierarchy; + } + }; +} + +it('accepts a current scope for an axis via in and is fluent', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us']]); + $context = new ScopeContext($registry); + + $result = $context->in('geo', 'eu.de'); + + expect($result)->toBe($context); +}); + +it('returns the current scope path for a set axis via get', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + expect($context->get('geo'))->toBe('eu.de'); +}); + +it('returns null from get for an unset axis', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us']]); + $context = new ScopeContext($registry); + + expect($context->get('geo'))->toBeNull(); +}); + +it('throws UnknownAxisException when in is called with an unknown axis', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us']]); + $context = new ScopeContext($registry); + + expect(fn () => $context->in('locale', 'en'))->toThrow(UnknownAxisException::class); +}); + +it('throws ScopeContextException when in is called with a path not in the axis hierarchy', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us']]); + $context = new ScopeContext($registry); + + expect(fn () => $context->in('geo', 'eu.fr'))->toThrow(ScopeContextException::class); +}); + +it('clears a single axis via clear and all axes via clearAll', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us'], 'locale' => ['en', 'fr']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de')->in('locale', 'en'); + + $context->clear('geo'); + + expect($context->get('geo'))->toBeNull() + ->and($context->get('locale'))->toBe('en'); + + $context->clearAll(); + + expect($context->get('locale'))->toBeNull(); +}); + +it('lists all axes currently set via activeAxes', function (): void { + $registry = makeScopeRegistry(['geo' => ['eu', 'eu.de', 'us'], 'locale' => ['en', 'fr'], 'channel' => ['web']]); + $context = new ScopeContext($registry); + + expect($context->activeAxes())->toBe([]); + + $context->in('geo', 'eu')->in('locale', 'en'); + + expect($context->activeAxes())->toBe(['geo', 'locale']); + + $context->clear('geo'); + + expect($context->activeAxes())->toBe(['locale']); +}); diff --git a/packages/scope/tests/Unit/Exceptions/ScopeExceptionsTest.php b/packages/scope/tests/Unit/Exceptions/ScopeExceptionsTest.php new file mode 100644 index 00000000..89dc3ee8 --- /dev/null +++ b/packages/scope/tests/Unit/Exceptions/ScopeExceptionsTest.php @@ -0,0 +1,66 @@ +toBeInstanceOf(UnknownAxisException::class) + ->and($exception->getMessage())->toContain('store') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->toContain('register'); +}); + +it('provides UnknownScopeException with axis and path and suggestion', function (): void { + $exception = UnknownScopeException::forAxisAndPath('store', 'default/en'); + + expect($exception)->toBeInstanceOf(UnknownScopeException::class) + ->and($exception->getMessage())->toContain('store') + ->and($exception->getMessage())->toContain('default/en') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); +}); + +it('provides ScopeConfigurationException for malformed scope config', function (): void { + $exception = ScopeConfigurationException::malformedConfig('store', 'missing label key'); + + expect($exception)->toBeInstanceOf(ScopeConfigurationException::class) + ->and($exception->getMessage())->toContain('store') + ->and($exception->getMessage())->toContain('missing label key') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); +}); + +it('provides ScopeContextException when reading context for an unset axis or invalid path', function (): void { + $exception = ScopeContextException::axisNotSet('currency'); + + expect($exception)->toBeInstanceOf(ScopeContextException::class) + ->and($exception->getMessage())->toContain('currency') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); +}); + +it('provides ScopeStorageException when the scopes column is missing on save', function (): void { + $exception = ScopeStorageException::missingColumn('scopes', 'products'); + + expect($exception)->toBeInstanceOf(ScopeStorageException::class) + ->and($exception->getMessage())->toContain('scopes') + ->and($exception->getMessage())->toContain('products') + ->and($exception->getContext())->not->toBeEmpty() + ->and($exception->getSuggestion())->not->toBeEmpty(); +}); + +it('extends MarkoException for all scope exceptions', function (): void { + expect(UnknownAxisException::forAxis('store'))->toBeInstanceOf(MarkoException::class) + ->and(UnknownScopeException::forAxisAndPath('store', 'default'))->toBeInstanceOf(MarkoException::class) + ->and(ScopeConfigurationException::malformedConfig('store', 'reason'))->toBeInstanceOf(MarkoException::class) + ->and(ScopeContextException::axisNotSet('currency'))->toBeInstanceOf(MarkoException::class) + ->and(ScopeStorageException::missingColumn('scopes', 'products'))->toBeInstanceOf(MarkoException::class); +}); diff --git a/packages/scope/tests/Unit/Hierarchy/ScopeHierarchyTest.php b/packages/scope/tests/Unit/Hierarchy/ScopeHierarchyTest.php new file mode 100644 index 00000000..c0a8af4f --- /dev/null +++ b/packages/scope/tests/Unit/Hierarchy/ScopeHierarchyTest.php @@ -0,0 +1,57 @@ +toBeInstanceOf(ScopeHierarchy::class); +}); + +it('walks up from a deep path to root returning the path and all ancestors in order', function (): void { + $hierarchy = ScopeHierarchy::fromPaths(['eu', 'eu.de', 'eu.de.berlin']); + + expect($hierarchy->walkUp('eu.de.berlin'))->toBe(['eu.de.berlin', 'eu.de', 'eu']); +}); + +it('returns true from exists for known paths and false for unknown', function (): void { + $hierarchy = ScopeHierarchy::fromPaths(['eu', 'eu.de', 'us']); + + expect($hierarchy->exists('eu'))->toBeTrue() + ->and($hierarchy->exists('eu.de'))->toBeTrue() + ->and($hierarchy->exists('us'))->toBeTrue() + ->and($hierarchy->exists('eu.fr'))->toBeFalse() + ->and($hierarchy->exists('unknown'))->toBeFalse(); +}); + +it('identifies parent-child relationships via isAncestor', function (): void { + $hierarchy = ScopeHierarchy::fromPaths(['eu', 'eu.de', 'eu.de.berlin', 'us']); + + expect($hierarchy->isAncestor(ancestor: 'eu', descendant: 'eu.de'))->toBeTrue() + ->and($hierarchy->isAncestor(ancestor: 'eu', descendant: 'eu.de.berlin'))->toBeTrue() + ->and($hierarchy->isAncestor(ancestor: 'eu.de', descendant: 'eu.de.berlin'))->toBeTrue() + ->and($hierarchy->isAncestor(ancestor: 'eu.de', descendant: 'eu'))->toBeFalse() + ->and($hierarchy->isAncestor(ancestor: 'eu', descendant: 'us'))->toBeFalse() + ->and($hierarchy->isAncestor(ancestor: 'eu', descendant: 'eu'))->toBeFalse(); +}); + +it('throws UnknownScopeException when walking from an unknown path', function (): void { + $hierarchy = ScopeHierarchy::fromPaths(['eu', 'eu.de']); + + expect(fn () => $hierarchy->walkUp('eu.fr'))->toThrow(UnknownScopeException::class); +}); + +it('rejects duplicate path declarations at construction time', function (): void { + expect(fn () => ScopeHierarchy::fromPaths(['eu', 'eu.de', 'eu']))->toThrow(ScopeConfigurationException::class); +}); + +it('lists all paths in declaration order', function (): void { + $paths = ['us', 'eu', 'eu.de', 'eu.fr', 'us.ny']; + $hierarchy = ScopeHierarchy::fromPaths($paths); + + expect($hierarchy->paths())->toBe($paths); +}); diff --git a/packages/scope/tests/Unit/Metadata/ScopeMetadataFactoryTest.php b/packages/scope/tests/Unit/Metadata/ScopeMetadataFactoryTest.php new file mode 100644 index 00000000..cba7fb64 --- /dev/null +++ b/packages/scope/tests/Unit/Metadata/ScopeMetadataFactoryTest.php @@ -0,0 +1,129 @@ +knownAxes, true); + } + + public function getAxis(string $name): ScopeAxis + { + throw new RuntimeException('Not implemented'); + } + + public function listAxes(): array + { + return $this->knownAxes; + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + throw new RuntimeException('Not implemented'); + } + }; +} + +it('returns empty metadata for entity classes with no Scoped properties', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $metadata = $factory->for(EntityWithNoScopedProperties::class); + + expect($metadata)->toBeInstanceOf(ScopeMetadata::class) + ->and($metadata->hasScopedProperties())->toBeFalse(); +}); + +it('discovers Scoped properties on an entity class via reflection', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $metadata = $factory->for(EntityWithScopedProperties::class); + + expect($metadata->hasScopedProperties())->toBeTrue(); +}); + +it('returns declared axes for a scoped property in declaration order', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $metadata = $factory->for(EntityWithScopedProperties::class); + + expect($metadata->axesForProperty('name'))->toBe(['store', 'website']) + ->and($metadata->axesForProperty('description'))->toBe(['website']); +}); + +it('caches metadata per class within the factory', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $first = $factory->for(EntityWithScopedProperties::class); + $second = $factory->for(EntityWithScopedProperties::class); + + expect($first)->toBe($second); +}); + +it('throws UnknownAxisException when a Scoped property declares an unknown axis', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + expect(fn () => $factory->for(EntityWithUnknownAxis::class)) + ->toThrow(UnknownAxisException::class); +}); + +it('reports whether a property is scoped via isScoped', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $metadata = $factory->for(EntityWithScopedProperties::class); + + expect($metadata->isScoped('name'))->toBeTrue() + ->and($metadata->isScoped('price'))->toBeFalse(); +}); + +it('lists all scoped property names via scopedProperties', function (): void { + $registry = makeMockRegistry(['store', 'website']); + $factory = new ScopeMetadataFactory($registry); + + $metadata = $factory->for(EntityWithScopedProperties::class); + + expect($metadata->scopedProperties())->toBe(['name', 'description']); +}); diff --git a/packages/scope/tests/Unit/ModulePhpTest.php b/packages/scope/tests/Unit/ModulePhpTest.php new file mode 100644 index 00000000..43847f79 --- /dev/null +++ b/packages/scope/tests/Unit/ModulePhpTest.php @@ -0,0 +1,80 @@ +toHaveKey(ScopeRegistryInterface::class); +}); + +it('registers ScopeContext as a singleton', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module)->toHaveKey('singletons') + ->and($module['singletons'])->toContain(ScopeContext::class); +}); + +it('registers ScopeMetadataFactory as a singleton', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['singletons'])->toContain(ScopeMetadataFactory::class); +}); + +it('registers ScopeResolver as a singleton', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['singletons'])->toContain(ScopeResolver::class); +}); + +it('registers ScopedOrderByFactory as a singleton', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['singletons'])->toContain(ScopedOrderByFactory::class); +}); + +it('does not bind ScopeSortRendererInterface', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + + expect($module['bindings'])->not->toHaveKey(ScopeSortRendererInterface::class); +}); + +it('throws a loud error if a ScopedOrderBy is used while no ScopeSortRendererInterface is bound', function (): void { + $container = new Container(); + + expect(fn () => $container->get(ScopeSortRendererInterface::class)) + ->toThrow(NoDriverException::class); +}); + +it('constructs PhpScopeRegistry from injected config repository', function (): void { + $module = require dirname(__DIR__, 2) . '/module.php'; + $factory = $module['bindings'][ScopeRegistryInterface::class]; + + $config = $this->createMock(ConfigRepositoryInterface::class); + $config->expects($this->once()) + ->method('getArray') + ->with('scope.axes') + ->willReturn([]); + + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once()) + ->method('get') + ->with(ConfigRepositoryInterface::class) + ->willReturn($config); + + $result = $factory($container); + + expect($result)->toBeInstanceOf(PhpScopeRegistry::class); +}); diff --git a/packages/scope/tests/Unit/Query/ScopeSortExpressionTest.php b/packages/scope/tests/Unit/Query/ScopeSortExpressionTest.php new file mode 100644 index 00000000..a1bde16c --- /dev/null +++ b/packages/scope/tests/Unit/Query/ScopeSortExpressionTest.php @@ -0,0 +1,42 @@ +isReadOnly())->toBeTrue(); +}); + +it('validates direction is asc or desc', function (): void { + expect(fn () => new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [], + direction: 'invalid', + ))->toThrow(ScopeConfigurationException::class); +}); + +it('creates a ScopeSortExpression with property, column, axis walk paths in order, and direction', function (): void { + $paths = [ + ['axis' => 'store', 'path' => 'store/en'], + ['axis' => 'store', 'path' => 'store'], + ['axis' => 'geo', 'path' => 'geo/de'], + ['axis' => 'geo', 'path' => 'geo'], + ]; + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: $paths, + direction: 'asc', + ); + + expect($expression->property)->toBe('price') + ->and($expression->column)->toBe('price') + ->and($expression->paths)->toBe($paths) + ->and($expression->direction)->toBe('asc'); +}); diff --git a/packages/scope/tests/Unit/Query/ScopeSortRendererInterfaceTest.php b/packages/scope/tests/Unit/Query/ScopeSortRendererInterfaceTest.php new file mode 100644 index 00000000..584838bf --- /dev/null +++ b/packages/scope/tests/Unit/Query/ScopeSortRendererInterfaceTest.php @@ -0,0 +1,37 @@ +getDocComment(); + + expect($docComment)->toBeString() + ->and($docComment)->toContain('COALESCE') + ->and($docComment)->toContain('walker'); +}); + +it('defines ScopeSortRendererInterface with render(ScopeSortExpression) returning a SQL fragment', function (): void { + $renderer = new class () implements ScopeSortRendererInterface + { + public function render(ScopeSortExpression $expression): string + { + return 'COALESCE(json_extract(scopes, "$.store.price"), price) ASC'; + } + }; + + $expression = new ScopeSortExpression( + property: 'price', + column: 'price', + paths: [['axis' => 'store', 'path' => 'store']], + direction: 'asc', + ); + + $sql = $renderer->render($expression); + + expect($sql)->toBeString() + ->and($sql)->not->toBeEmpty(); +}); diff --git a/packages/scope/tests/Unit/Query/ScopedOrderByFactoryTest.php b/packages/scope/tests/Unit/Query/ScopedOrderByFactoryTest.php new file mode 100644 index 00000000..c4c17923 --- /dev/null +++ b/packages/scope/tests/Unit/Query/ScopedOrderByFactoryTest.php @@ -0,0 +1,115 @@ +builtAxes = ['store' => new ScopeAxis(name: 'store', hierarchy: $hierarchy)]; + } + + public function hasAxis(string $name): bool + { + return isset($this->builtAxes[$name]); + } + + public function getAxis(string $name): ScopeAxis + { + return $this->builtAxes[$name]; + } + + public function listAxes(): array + { + return array_keys($this->builtAxes); + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->builtAxes[$axisName]->hierarchy; + } + }; +} + +function makeFactoryRenderer(): ScopeSortRendererInterface +{ + return new class () implements ScopeSortRendererInterface + { + public function render(ScopeSortExpression $expression): string + { + return 'COALESCE(expr)'; + } + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +it('constructs a ScopedOrderBy with the entity class, property, and direction', function (): void { + $registry = makeFactoryRegistry(); + $metadataFactory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeFactoryRenderer(); + + $factory = new ScopedOrderByFactory($metadataFactory, $context, $renderer); + $spec = $factory->create(ScopedOrderByFactoryProduct::class, 'name', 'desc'); + + expect($spec)->toBeInstanceOf(ScopedOrderBy::class) + ->and($spec->property)->toBe('name') + ->and($spec->direction)->toBe('desc'); +}); + +it('injects ScopeMetadataFactory, ScopeContext, and ScopeSortRendererInterface into the spec', function (): void { + $registry = makeFactoryRegistry(); + $metadataFactory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeFactoryRenderer(); + + $factory = new ScopedOrderByFactory($metadataFactory, $context, $renderer); + $spec = $factory->create(ScopedOrderByFactoryProduct::class, 'name', 'asc'); + + expect($spec)->toBeInstanceOf(ScopedOrderBy::class); +}); + +it('defaults direction to asc when omitted', function (): void { + $registry = makeFactoryRegistry(); + $metadataFactory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeFactoryRenderer(); + + $factory = new ScopedOrderByFactory($metadataFactory, $context, $renderer); + $spec = $factory->create(ScopedOrderByFactoryProduct::class, 'name'); + + expect($spec->direction)->toBe('asc'); +}); + +it('is a readonly class with constructor-injected dependencies', function (): void { + $reflection = new ReflectionClass(ScopedOrderByFactory::class); + + expect($reflection->isReadOnly())->toBeTrue(); +}); diff --git a/packages/scope/tests/Unit/Query/ScopedOrderByTest.php b/packages/scope/tests/Unit/Query/ScopedOrderByTest.php new file mode 100644 index 00000000..2b8cac17 --- /dev/null +++ b/packages/scope/tests/Unit/Query/ScopedOrderByTest.php @@ -0,0 +1,482 @@ + */ + private array $builtAxes; + + public function __construct(private readonly array $axes) + { + $this->builtAxes = []; + foreach ($axes as $name => $paths) { + $hierarchy = new ScopeHierarchy($paths); + $this->builtAxes[$name] = new ScopeAxis(name: $name, hierarchy: $hierarchy); + } + } + + public function hasAxis(string $name): bool + { + return isset($this->builtAxes[$name]); + } + + public function getAxis(string $name): ScopeAxis + { + return $this->builtAxes[$name]; + } + + public function listAxes(): array + { + return array_keys($this->builtAxes); + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->builtAxes[$axisName]->hierarchy; + } + }; +} + +function makeBuilderSpy(): EntityQueryBuilderInterface +{ + return new class () implements EntityQueryBuilderInterface + { + public array $orderByCalls = []; + + public array $orderByRawCalls = []; + + public function with(string ...$relations): static + { + return $this; + } + + public function table(string $table): static + { + return $this; + } + + public function select(string ...$columns): static + { + return $this; + } + + public function distinct(): static + { + return $this; + } + + public function where( + string $column, + string $operator, + mixed $value, + ): static + { + return $this; + } + + public function whereIn( + string $column, + array $values, + ): static + { + return $this; + } + + public function whereNull(string $column): static + { + return $this; + } + + public function whereNotNull(string $column): static + { + return $this; + } + + public function whereJsonContains( + string $path, + mixed $value, + ): static + { + return $this; + } + + public function whereJsonExists(string $path): static + { + return $this; + } + + public function whereJsonMissing(string $path): static + { + return $this; + } + + public function orWhere( + string $column, + string $operator, + mixed $value, + ): static + { + return $this; + } + + public function join( + string $table, + string $first, + string $operator, + string $second, + ): static + { + return $this; + } + + public function leftJoin( + string $table, + string $first, + string $operator, + string $second, + ): static + { + return $this; + } + + public function rightJoin( + string $table, + string $first, + string $operator, + string $second, + ): static + { + return $this; + } + + public function groupBy(string ...$columns): static + { + return $this; + } + + public function having( + string $expression, + array $bindings = [], + ): static + { + return $this; + } + + public function union(QueryBuilderInterface $other): static + { + return $this; + } + + public function unionAll(QueryBuilderInterface $other): static + { + return $this; + } + + public function limit(int $limit): static + { + return $this; + } + + public function offset(int $offset): static + { + return $this; + } + + public function getColumnCount(): int + { + return 0; + } + + public function compileSubquery(array &$bindings): string + { + return ''; + } + + public function get(): array + { + return []; + } + + public function first(): ?array + { + return null; + } + + public function insert(array $data): int + { + return 0; + } + + public function update(array $data): int + { + return 0; + } + + public function delete(): int + { + return 0; + } + + public function count(?string $column = null): int + { + return 0; + } + + public function min(string $column): int|float|null + { + return null; + } + + public function max(string $column): int|float|null + { + return null; + } + + public function sum(string $column): int|float|null + { + return null; + } + + public function avg(string $column): int|float|null + { + return null; + } + + public function raw( + string $sql, + array $bindings = [], + ): array + { + return []; + } + + public function orderBy( + string $column, + string $direction = 'ASC', + ): static + { + $this->orderByCalls[] = ['column' => $column, 'direction' => $direction]; + + return $this; + } + + public function orderByRaw( + string $expression, + string $direction = 'ASC', + ): static + { + $this->orderByRawCalls[] = ['expression' => $expression, 'direction' => $direction]; + + return $this; + } + }; +} + +function makeRenderer(string $sql = 'COALESCE(json_extract(scopes, \'$.store.en\'), name)'): ScopeSortRendererInterface +{ + return new readonly class ($sql) implements ScopeSortRendererInterface + { + public function __construct(private string $sql) {} + + public function render(ScopeSortExpression $expression): string + { + return $this->sql; + } + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +it('accepts ScopeMetadataFactory, ScopeContext, and ScopeSortRendererInterface in constructor', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeRenderer(); + + $spec = new ScopedOrderBy( + property: 'name', + direction: 'desc', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + expect($spec->property)->toBe('name') + ->and($spec->direction)->toBe('desc'); +}); + +it('accepts a property name and optional direction defaulting to asc', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeRenderer(); + + $spec = new ScopedOrderBy( + property: 'name', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + expect($spec->property)->toBe('name') + ->and($spec->direction)->toBe('asc'); +}); + +it('calls orderByRaw with the renderer-generated COALESCE expression when scope is active', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $context->in('store', 'en.gb'); + + $sql = "COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, '$.store.en.gb')), name)"; + $renderer = makeRenderer($sql); + + $spec = new ScopedOrderBy( + property: 'name', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + $builder = makeBuilderSpy(); + $spec->apply($builder); + + expect($builder->orderByRawCalls)->toHaveCount(1) + ->and($builder->orderByRawCalls[0]['expression'])->toBe($sql) + ->and($builder->orderByCalls)->toBeEmpty(); +}); + +it('falls back to plain orderBy when no scope is active for any of the property\'s axes', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + // No scope set on context + $renderer = makeRenderer(); + + $spec = new ScopedOrderBy( + property: 'name', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + $builder = makeBuilderSpy(); + $spec->apply($builder); + + expect($builder->orderByCalls)->toHaveCount(1) + ->and($builder->orderByCalls[0]['column'])->toBe('name') + ->and($builder->orderByRawCalls)->toBeEmpty(); +}); + +it('throws ScopeContextException when the property is not Scoped', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $renderer = makeRenderer(); + + $spec = new ScopedOrderBy( + property: 'sku', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + $builder = makeBuilderSpy(); + expect(fn () => $spec->apply($builder))->toThrow(ScopeContextException::class); +}); + +it( + 'preserves direction asc or desc on the emitted ORDER BY', + function (string $direction, string $expectedDirectionUpper): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $context->in('store', 'en'); + $renderer = makeRenderer('COALESCE(expr)'); + + $spec = new ScopedOrderBy( + property: 'name', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + direction: $direction, + ); + + $builder = makeBuilderSpy(); + $spec->apply($builder); + + expect($builder->orderByRawCalls)->toHaveCount(1) + ->and($builder->orderByRawCalls[0]['direction'])->toBe($expectedDirectionUpper); + } +)->with([ + ['asc', 'ASC'], + ['desc', 'DESC'], +]); + +it('builds a ScopeSortExpression by reading ScopeMetadata for the entity class on apply', function (): void { + $registry = makeScopedOrderByRegistry(['store' => ['en', 'en.gb']]); + $factory = new ScopeMetadataFactory($registry); + $context = new ScopeContext($registry); + $context->in('store', 'en.gb'); + + $renderer = new class () implements ScopeSortRendererInterface + { + public ?ScopeSortExpression $captured = null; + + public function render(ScopeSortExpression $expression): string + { + $this->captured = $expression; + + return 'COALESCE(expr)'; + } + }; + + $spec = new ScopedOrderBy( + property: 'name', + scopeMetadataFactory: $factory, + scopeContext: $context, + scopeSortRenderer: $renderer, + entityClass: ScopedOrderByProduct::class, + ); + + $builder = makeBuilderSpy(); + $spec->apply($builder); + + expect($renderer->captured)->toBeInstanceOf(ScopeSortExpression::class) + ->and($renderer->captured->property)->toBe('name') + ->and($renderer->captured->column)->toBe('name'); +}); diff --git a/packages/scope/tests/Unit/ReadmeTest.php b/packages/scope/tests/Unit/ReadmeTest.php new file mode 100644 index 00000000..68d51268 --- /dev/null +++ b/packages/scope/tests/Unit/ReadmeTest.php @@ -0,0 +1,52 @@ +toContain('# marko/scope') + ->and($content)->toContain('Scoped attributes for entities with multi-axis hierarchical fallback'); +}); + +it('has an Installation section with composer command', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Installation') + ->and($content)->toContain('composer require marko/scope'); +}); + +it('references driver packages in the installation instructions', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('scope-mysql') + ->and($content)->toContain('scope-pgsql'); +}); + +it('has a quick example showing the Scoped attribute, setOverride, and resolved', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('#[Scoped') + ->and($content)->toContain('setOverride') + ->and($content)->toContain('resolved('); +}); + +it('has a quick example using locale scope with a Product entity', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('locale') + ->and($content)->toContain('Product') + ->and($content)->toContain('$name'); +}); + +it('has a Documentation link', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('## Documentation'); +}); diff --git a/packages/scope/tests/Unit/Registry/PhpScopeRegistryTest.php b/packages/scope/tests/Unit/Registry/PhpScopeRegistryTest.php new file mode 100644 index 00000000..c5debb3f --- /dev/null +++ b/packages/scope/tests/Unit/Registry/PhpScopeRegistryTest.php @@ -0,0 +1,147 @@ +axes; + } + + public function has( + string $key, + ?string $scope = null, + ): bool + { + return true; + } + + public function getString( + string $key, + ?string $scope = null, + ): string + { + return ''; + } + + public function getInt( + string $key, + ?string $scope = null, + ): int + { + return 0; + } + + public function getBool( + string $key, + ?string $scope = null, + ): bool + { + return false; + } + + public function getFloat( + string $key, + ?string $scope = null, + ): float + { + return 0.0; + } + + public function getArray( + string $key, + ?string $scope = null, + ): array + { + return $this->axes; + } + + public function all(?string $scope = null): array + { + return $this->axes; + } + + public function withScope(string $scope): ConfigRepositoryInterface + { + return $this; + } + }; +} + +it('loads axes from injected config into PhpScopeRegistry', function (): void { + $config = makeConfigStub([ + 'geo' => ['hierarchy' => ['eu', 'eu.de', 'us']], + 'locale' => ['hierarchy' => ['en', 'fr']], + ]); + + $registry = new PhpScopeRegistry($config); + + expect($registry)->toBeInstanceOf(ScopeRegistryInterface::class); +}); + +it('returns ScopeAxis instances from getAxis for registered names', function (): void { + $config = makeConfigStub([ + 'geo' => ['hierarchy' => ['eu', 'eu.de', 'us']], + ]); + + $registry = new PhpScopeRegistry($config); + + expect($registry->getAxis('geo'))->toBeInstanceOf(ScopeAxis::class) + ->and($registry->getAxis('geo')->name)->toBe('geo'); +}); + +it('throws UnknownAxisException when getAxis is called with unknown axis', function (): void { + $config = makeConfigStub([ + 'geo' => ['hierarchy' => ['eu', 'eu.de']], + ]); + + $registry = new PhpScopeRegistry($config); + + expect(fn () => $registry->getAxis('unknown'))->toThrow(UnknownAxisException::class); +}); + +it('throws ScopeConfigurationException when config has duplicate axis names', function (): void { + // PHP arrays cannot have duplicate keys, so we test duplicate paths in a hierarchy + // which is the only way to get duplication at axis registration level. + // We test duplicate hierarchy paths causing ScopeConfigurationException. + $config = makeConfigStub([ + 'geo' => ['hierarchy' => ['eu', 'eu', 'us']], + ]); + + expect(fn () => new PhpScopeRegistry($config))->toThrow(ScopeConfigurationException::class); +}); + +it('throws ScopeConfigurationException when config shape is malformed', function (): void { + $config = makeConfigStub([ + 'geo' => 'not-an-array', + ]); + + expect(fn () => new PhpScopeRegistry($config))->toThrow(ScopeConfigurationException::class); +}); + +it('returns the list of all registered axis names in registration order', function (): void { + $config = makeConfigStub([ + 'geo' => ['hierarchy' => ['eu', 'us']], + 'locale' => ['hierarchy' => ['en', 'fr']], + 'channel' => ['hierarchy' => ['web', 'mobile']], + ]); + + $registry = new PhpScopeRegistry($config); + + expect($registry->listAxes())->toBe(['geo', 'locale', 'channel']); +}); diff --git a/packages/scope/tests/Unit/Registry/ScopeRegistryInterfaceTest.php b/packages/scope/tests/Unit/Registry/ScopeRegistryInterfaceTest.php new file mode 100644 index 00000000..7bfc2cd6 --- /dev/null +++ b/packages/scope/tests/Unit/Registry/ScopeRegistryInterfaceTest.php @@ -0,0 +1,15 @@ +isInterface())->toBeTrue() + ->and($reflection->hasMethod('hasAxis'))->toBeTrue() + ->and($reflection->hasMethod('getAxis'))->toBeTrue() + ->and($reflection->hasMethod('listAxes'))->toBeTrue() + ->and($reflection->hasMethod('getHierarchy'))->toBeTrue(); +}); diff --git a/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php new file mode 100644 index 00000000..4c1ab363 --- /dev/null +++ b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php @@ -0,0 +1,222 @@ + */ + private array $builtAxes; + + public function __construct(private readonly array $axes) + { + $this->builtAxes = []; + foreach ($axes as $name => $paths) { + $hierarchy = new ScopeHierarchy($paths); + $this->builtAxes[$name] = new ScopeAxis(name: $name, hierarchy: $hierarchy); + } + } + + public function hasAxis(string $name): bool + { + return isset($this->builtAxes[$name]); + } + + public function getAxis(string $name): ScopeAxis + { + if (!isset($this->builtAxes[$name])) { + throw UnknownAxisException::forAxis($name); + } + + return $this->builtAxes[$name]; + } + + /** @return list */ + public function listAxes(): array + { + return array_keys($this->builtAxes); + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->getAxis($axisName)->hierarchy; + } + }; +} + +it('returns the override at the current scope when one exists', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTestOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hemd'); +}); + +it('returns an ancestor override when no override exists at the current scope', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTestOverrides(); + $overrides->setOverride('geo:eu', 'name', 'Shirt-EU'); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Shirt-EU'); +}); + +it('returns notFound when no override exists at any walked scope', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTestOverrides(); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + expect($result->isFound())->toBeFalse(); +}); + +it('walks axes in declared priority order returning the first axis match', function (): void { + $registry = makeWalkerRegistry([ + 'geo' => ['eu', 'eu.de'], + 'locale' => ['de', 'de-DE'], + ]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de')->in('locale', 'de-DE'); + + $overrides = new WalkerTestOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + $overrides->setOverride('locale:de-DE', 'name', 'Hemd-de'); + + $walker = new ScopeWalker(); + + // geo first: should return geo value + $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hemd'); + + // locale first: should return locale value + $resultLocaleFirst = $walker->walk($overrides, 'name', ['locale', 'geo'], $context, $registry); + expect($resultLocaleFirst->isFound())->toBeTrue() + ->and($resultLocaleFirst->value())->toBe('Hemd-de'); +}); + +it('skips axes that are not set in ScopeContext', function (): void { + $registry = makeWalkerRegistry([ + 'geo' => ['eu', 'eu.de'], + 'locale' => ['de', 'de-DE'], + ]); + $context = new ScopeContext($registry); + // Only geo is set in context; locale is not set + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTestOverrides(); + $overrides->setOverride('locale:de', 'name', 'Hallo'); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $walker = new ScopeWalker(); + // locale declared first but not in context — must skip and return geo match + $result = $walker->walk($overrides, 'name', ['locale', 'geo'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hemd'); +}); + +it('preserves an explicit null override and does not fall through it within an axis', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTestOverrides(); + $overrides->setOverride('geo:eu', 'name', 'Shirt-EU'); + $overrides->setOverride('geo:eu.de', 'name', null); // explicit null at current scope + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + // Should return found(null) — not fall through to the eu ancestor + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBeNull(); +}); + +it( + 'falls through an axis with no overrides for the property to the next axis (cross-axis fallthrough)', + function (): void { + $registry = makeWalkerRegistry([ + 'geo' => ['eu', 'eu.de'], + 'locale' => ['de', 'de.formal'], + ]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de')->in('locale', 'de.formal'); + + $overrides = new WalkerTestOverrides(); + // No override for 'name' under geo; only under locale ancestor + $overrides->setOverride('locale:de', 'name', 'Hallo'); + + $walker = new ScopeWalker(); + // geo first — no match for 'name' anywhere in geo; should fall through to locale + $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hallo'); + } +); + +it( + 'stops cross-axis fallthrough when an explicit null is found in an axis (null counts as a found value)', + function (): void { + $registry = makeWalkerRegistry([ + 'geo' => ['eu', 'eu.de'], + 'locale' => ['de', 'de.formal'], + ]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de')->in('locale', 'de.formal'); + + $overrides = new WalkerTestOverrides(); + // geo has an explicit null — should count as "found" and stop cross-axis fallthrough + $overrides->setOverride('geo:eu.de', 'name', null); + // locale has a real value — but should NOT be reached + $overrides->setOverride('locale:de', 'name', 'Hallo'); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBeNull(); + } +); + +it('returns notFound when no axes are declared and no overrides exist', function (): void { + $registry = makeWalkerRegistry([]); + $context = new ScopeContext($registry); + + $overrides = new WalkerTestOverrides(); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', [], $context, $registry); + + expect($result->isFound())->toBeFalse(); +}); diff --git a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php new file mode 100644 index 00000000..f5f392e7 --- /dev/null +++ b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php @@ -0,0 +1,235 @@ + ['global', 'global.us']]): ScopeRegistryInterface +{ + return new class ($axes) implements ScopeRegistryInterface + { + /** @var array */ + private array $builtAxes; + + public function __construct(private readonly array $axes) + { + $this->builtAxes = []; + foreach ($axes as $name => $paths) { + $hierarchy = new ScopeHierarchy($paths); + $this->builtAxes[$name] = new ScopeAxis(name: $name, hierarchy: $hierarchy); + } + } + + public function hasAxis(string $name): bool + { + return isset($this->builtAxes[$name]); + } + + public function getAxis(string $name): ScopeAxis + { + return $this->builtAxes[$name]; + } + + public function listAxes(): array + { + return array_keys($this->builtAxes); + } + + public function getHierarchy(string $axisName): ScopeHierarchy + { + return $this->builtAxes[$axisName]->hierarchy; + } + }; +} + +function makeResolverSetup(): array +{ + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $entityMetaFactory = new EntityMetadataFactory(); + $entityMetaFactory->linkExtenders(ResolverProduct::class, [ResolverProductOverrides::class]); + + return [$registry, $context, $scopeMetaFactory, $walker, $entityMetaFactory]; +} + +function makeResolver(): array +{ + [$registry, $context, $scopeMetaFactory, $walker, $entityMetaFactory] = makeResolverSetup(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context, $entityMetaFactory); + + return [$resolver, $context, $registry]; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +it('resolves a property value via current ScopeContext returning the walker match', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + $overrides = new ResolverProductOverrides(); + $overrides->setOverride('store:global.us', 'name', 'US Name'); + $product->attachCompanion($overrides); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('US Name'); +}); + +it('resolves at an explicit scope via resolvedAt without consulting ScopeContext', function (): void { + [$resolver, $context] = makeResolver(); + // context set to global.us, but we resolve at global explicitly + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + $overrides = new ResolverProductOverrides(); + $overrides->setOverride('store:global', 'name', 'Global Name'); + $overrides->setOverride('store:global.us', 'name', 'US Name'); + $product->attachCompanion($overrides); + + $scope = new Scope('store', 'global'); + $result = $resolver->resolvedAt($product, 'name', $scope); + + expect($result)->toBe('Global Name'); +}); + +it('sets an override via setOverride attaching a ScopedOverridesEntity companion if missing', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + // No companion attached yet + + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'US Name', $scope); + + $companion = $product->companion(ResolverProductOverrides::class); + + expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) + ->and($companion->getOverride('store:global.us', 'name'))->toBe('US Name'); +}); + +it('sets an override on a new (unsaved) entity then saves so both rows reflect the override', function (): void { + [$resolver] = makeResolver(); + + $product = new ResolverProduct(); + // Simulate "new (unsaved)" entity — no companion exists yet + + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'Fresh Name', $scope); + + $companion = $product->companion(ResolverProductOverrides::class); + + expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) + ->and($companion->getOverride('store:global.us', 'name'))->toBe('Fresh Name'); +}); + +it('clears an override via clearOverride leaving the companion otherwise intact', function (): void { + [$resolver] = makeResolver(); + + $product = new ResolverProduct(); + $overrides = new ResolverProductOverrides(); + $overrides->setOverride('store:global.us', 'name', 'US Name'); + $overrides->setOverride('store:global.us', 'sku', 'SKU-US'); + $product->attachCompanion($overrides); + + $scope = new Scope('store', 'global.us'); + $resolver->clearOverride($product, 'name', $scope); + + $companion = $product->companion(ResolverProductOverrides::class); + + expect($companion->hasOverride('store:global.us', 'name'))->toBeFalse() + ->and($companion->getOverride('store:global.us', 'sku'))->toBe('SKU-US'); +}); + +it( + 'discovers the correct ScopedOverridesEntity subclass for a given parent entity class via EntityMetadata::extenders', + function (): void { + [$resolver] = makeResolver(); + + $product = new ResolverProduct(); + // No companion exists; resolver must discover ResolverProductOverrides via extenders + + $scope = new Scope('store', 'global'); + $resolver->setOverride($product, 'name', 'Global Name', $scope); + + // The companion created should be the correct subclass + $companion = $product->companion(ResolverProductOverrides::class); + + expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) + ->and($companion->getOverride('store:global', 'name'))->toBe('Global Name'); + } +); + +it('throws ScopeContextException when setOverride targets a property without Scoped', function (): void { + [$resolver] = makeResolver(); + + $product = new ResolverProduct(); + // 'sku' is not marked with #[Scoped] + $scope = new Scope('store', 'global.us'); + + expect(fn () => $resolver->setOverride($product, 'sku', 'SKU-123', $scope)) + ->toThrow(ScopeContextException::class); +}); + +it('throws ScopeContextException when resolving an unknown property', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + + expect(fn () => $resolver->resolved($product, 'nonExistentProperty')) + ->toThrow(ScopeContextException::class); +}); + +it('falls back to the entity\'s column property value when no override is found', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + $product->name = 'base-name'; + $overrides = new ResolverProductOverrides(); + // No override set for 'name' + $product->attachCompanion($overrides); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('base-name'); +}); diff --git a/packages/scope/tests/Unit/ScopeAxisTest.php b/packages/scope/tests/Unit/ScopeAxisTest.php new file mode 100644 index 00000000..a8592380 --- /dev/null +++ b/packages/scope/tests/Unit/ScopeAxisTest.php @@ -0,0 +1,14 @@ +name)->toBe('geo') + ->and($axis->hierarchy)->toBe($hierarchy); +}); diff --git a/packages/scope/tests/Unit/ScopeTest.php b/packages/scope/tests/Unit/ScopeTest.php new file mode 100644 index 00000000..fdc2be73 --- /dev/null +++ b/packages/scope/tests/Unit/ScopeTest.php @@ -0,0 +1,38 @@ +axisName)->toBe('geo') + ->and($scope->path)->toBe('eu.de'); +}); + +it('parses a scope string "geo:eu.de" via Scope::fromString', function (): void { + $scope = Scope::fromString('geo:eu.de'); + + expect($scope->axisName)->toBe('geo') + ->and($scope->path)->toBe('eu.de'); +}); + +it('throws ScopeConfigurationException for malformed scope strings', function (): void { + expect(fn () => Scope::fromString('invalid-no-colon')) + ->toThrow(ScopeConfigurationException::class); +}); + +it('formats a Scope back to "axis:path" via Scope::toString', function (): void { + $scope = new Scope(axisName: 'geo', path: 'eu.de'); + + expect($scope->toString())->toBe('geo:eu.de'); +}); + +it('considers two Scope instances equal when axis and path match', function (): void { + $a = new Scope(axisName: 'geo', path: 'eu.de'); + $b = new Scope(axisName: 'geo', path: 'eu.de'); + + expect($a->equals($b))->toBeTrue(); +}); diff --git a/packages/scope/tests/Unit/Storage/ScopedDataSerializerTest.php b/packages/scope/tests/Unit/Storage/ScopedDataSerializerTest.php new file mode 100644 index 00000000..2d9ed80b --- /dev/null +++ b/packages/scope/tests/Unit/Storage/ScopedDataSerializerTest.php @@ -0,0 +1,85 @@ +serialize([]))->toBeNull(); +}); + +it('serializes nested overrides keyed by axis colon path', function (): void { + $serializer = new ScopedDataSerializer(); + $overrides = [ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], + ]; + + $json = $serializer->serialize($overrides); + + expect($json)->toBe('{"geo:eu.de":{"name":"Hemd","price":19.99},"locale:de":{"name":"Hallo"}}'); +}); + +it('deserializes valid JSON into a flat array structure', function (): void { + $serializer = new ScopedDataSerializer(); + $json = '{"geo:eu.de":{"name":"Hemd","price":19.99},"locale:de":{"name":"Hallo"}}'; + + $overrides = $serializer->deserialize($json); + + expect($overrides)->toBe([ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], + ]); +}); + +it('round-trips BackedEnum values via their backing scalar', function (): void { + $serializer = new ScopedDataSerializer(); + $overrides = [ + 'store:default' => ['status' => TestStatus::Active], + ]; + + $json = $serializer->serialize($overrides); + $result = $serializer->deserialize($json); + + expect($result)->toBe([ + 'store:default' => ['status' => 'active'], + ]); +}); + +it('round-trips DateTimeImmutable values via formatted string', function (): void { + $serializer = new ScopedDataSerializer(); + $date = new DateTimeImmutable('2024-03-15 10:30:00'); + $overrides = [ + 'store:default' => ['published_at' => $date], + ]; + + $json = $serializer->serialize($overrides); + $result = $serializer->deserialize($json); + + expect($result)->toBe([ + 'store:default' => ['published_at' => '2024-03-15 10:30:00'], + ]); +}); + +it('throws ScopeConfigurationException when deserialized JSON is malformed', function (): void { + $serializer = new ScopedDataSerializer(); + + expect(fn () => $serializer->deserialize('{not valid json')) + ->toThrow(ScopeConfigurationException::class); +}); + +it('deserializes null or empty string into an empty overrides map', function (): void { + $serializer = new ScopedDataSerializer(); + + expect($serializer->deserialize(null))->toBeEmpty() + ->and($serializer->deserialize(''))->toBeEmpty(); +}); diff --git a/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php b/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php new file mode 100644 index 00000000..d0c8c2ac --- /dev/null +++ b/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php @@ -0,0 +1,116 @@ +isAbstract())->toBeTrue() + ->and($reflection->getParentClass()->getName())->toBe(Entity::class) + ->and($reflection->hasProperty('scopes'))->toBeTrue(); + + $property = $reflection->getProperty('scopes'); + $attributes = $property->getAttributes(Column::class); + + expect($attributes)->toHaveCount(1); + + $column = $attributes[0]->newInstance(); + + expect($column->name)->toBe('scopes') + ->and($column->type)->toBe('json') + ->and($column->nullable)->toBeTrue(); +}); + +it('stores an override keyed by scope key and property via setOverride', function (): void { + $entity = new ConcreteOverrides(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + + expect($entity->scopes)->toBe([ + 'geo:eu.de' => ['name' => 'Hemd'], + ]); +}); + +it('returns the stored override via getOverride for the same property and scope', function (): void { + $entity = new ConcreteOverrides(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + + expect($entity->getOverride('geo:eu.de', 'name'))->toBe('Hemd'); +}); + +it('returns null from getOverride when no override exists at that scope', function (): void { + $entity = new ConcreteOverrides(); + + expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); +}); + +it('removes an override via clearOverride and getOverride returns null afterward', function (): void { + $entity = new ConcreteOverrides(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->setOverride('geo:eu.de', 'price', 19.99); + $entity->clearOverride('geo:eu.de', 'name'); + + expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() + ->and($entity->getOverride('geo:eu.de', 'price'))->toBe(19.99); +}); + +it('removes the entire scope-key sub-map when its last property is cleared', function (): void { + $entity = new ConcreteOverrides(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->clearOverride('geo:eu.de', 'name'); + + expect($entity->scopes)->toBeNull(); +}); + +it( + 'functions as a companion attached via Entity::attachCompanion and is retrievable via Entity::companion', + function (): void { + $parent = new ConcreteParentEntity(); + $overrides = new ConcreteOverrides(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $parent->attachCompanion($overrides); + + $retrieved = $parent->companion(ConcreteOverrides::class); + + expect($retrieved)->toBeInstanceOf(ConcreteOverrides::class) + ->and($retrieved)->toBe($overrides) + ->and($retrieved->getOverride('geo:eu.de', 'name'))->toBe('Hemd'); + } +); + +it('lists all overrides via allOverrides as the flat scope-key-first map', function (): void { + $entity = new ConcreteOverrides(); + $entity->setOverride('locale:de', 'name', 'Hallo'); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->setOverride('geo:eu.de', 'price', 19.99); + + expect($entity->allOverrides())->toBe([ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], + ]); +}); + +it('distinguishes an explicit null override from no override via hasOverride', function (): void { + $entity = new ConcreteOverrides(); + + expect($entity->hasOverride('geo:eu.de', 'name'))->toBeFalse(); + + $entity->setOverride('geo:eu.de', 'name', null); + + expect($entity->hasOverride('geo:eu.de', 'name'))->toBeTrue() + ->and($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); +}); diff --git a/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php new file mode 100644 index 00000000..d92507a6 --- /dev/null +++ b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php @@ -0,0 +1,186 @@ + $validator->validate(ValidatorPlainProduct::class))->not->toThrow(Throwable::class); +}); + +it( + 'passes for an entity with Scoped properties when a matching ScopedOverridesEntity extender is registered', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders(ValidatorScopedProduct::class, [ValidatorScopedProductOverrides::class]); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorScopedProduct::class))->not->toThrow(Throwable::class); + } +); + +it( + 'throws ScopeConfigurationException when an entity has Scoped properties but no extender is linked', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorScopedProduct::class)) + ->toThrow(ScopeConfigurationException::class); + } +); + +it( + 'throws ScopeConfigurationException when the linked extender does not extend ScopedOverridesEntity', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders(ValidatorOtherProduct::class, [ValidatorOtherProductOverridesWrongBase::class]); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorOtherProduct::class)) + ->toThrow(ScopeConfigurationException::class); + } +); + +it('includes the parent entity FQCN in the exception message', function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + try { + $validator->validate(ValidatorScopedProduct::class); + expect(false)->toBeTrue('Expected exception was not thrown'); + } catch (ScopeConfigurationException $e) { + expect($e->getMessage())->toContain(ValidatorScopedProduct::class); + } +}); + +it('lists each Scoped property and its declared axes in the exception context', function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + try { + $validator->validate(ValidatorScopedProduct::class); + expect(false)->toBeTrue('Expected exception was not thrown'); + } catch (ScopeConfigurationException $e) { + expect($e->getContext())->toContain('name') + ->and($e->getContext())->toContain('store') + ->and($e->getContext())->toContain('description') + ->and($e->getContext())->toContain('website'); + } +}); + +it( + 'provides a one-line class declaration including the Table extends attribute in the exception suggestion', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + try { + $validator->validate(ValidatorScopedProduct::class); + expect(false)->toBeTrue('Expected exception was not thrown'); + } catch (ScopeConfigurationException $e) { + expect($e->getSuggestion())->toContain('#[Table(extends:') + ->and($e->getSuggestion())->toContain(ValidatorScopedProduct::class . '::class') + ->and($e->getSuggestion())->toContain('ScopedOverridesEntity'); + } + } +); From 6c757b11e0125892ca215cc82667daebbe787626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 17:31:38 +0200 Subject: [PATCH 02/13] fix(database): use SchemaRegistry in DiffCommand and MigrateCommand for extender merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-pass buildEntitySchema loop called schemaBuilder->build() per entity independently, so extender columns (Table(extends:)) were never merged into the parent table schema. Commands saw extender columns as missing from the entity definition — reporting existing ones as destructive drops and never generating ADD COLUMN for new ones. Replaced the loop with SchemaRegistry::registerEntities(), which already implements the correct two-pass merge. Removed the now-unused EntityMetadataFactory and SchemaBuilder constructor dependencies from both commands; updated test helpers accordingly. Co-Authored-By: Claude Sonnet 4.6 --- packages/database/src/Command/DiffCommand.php | 17 +++++------------ .../database/src/Command/MigrateCommand.php | 17 +++++------------ packages/database/tests/Command/Helpers.php | 4 ++-- .../tests/Command/MigrateCommandTest.php | 7 +++---- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/database/src/Command/DiffCommand.php b/packages/database/src/Command/DiffCommand.php index fcbee57f..0996a3f9 100644 --- a/packages/database/src/Command/DiffCommand.php +++ b/packages/database/src/Command/DiffCommand.php @@ -13,10 +13,9 @@ use Marko\Database\Diff\SchemaDiff; use Marko\Database\Diff\TableDiff; use Marko\Database\Entity\EntityDiscovery; -use Marko\Database\Entity\EntityMetadataFactory; -use Marko\Database\Entity\SchemaBuilder; use Marko\Database\Exceptions\EntityException; use Marko\Database\Introspection\IntrospectorInterface; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table; /** @noinspection PhpUnused */ @@ -26,8 +25,7 @@ public function __construct( private EntityDiscovery $discovery, private IntrospectorInterface $introspector, - private EntityMetadataFactory $metadataFactory, - private SchemaBuilder $schemaBuilder, + private SchemaRegistry $schemaRegistry, private DiffCalculator $diffCalculator, private ProjectPaths $paths, ) {} @@ -77,15 +75,10 @@ public function execute( private function buildEntitySchema( array $entityClasses, ): array { - $schema = []; - - foreach ($entityClasses as $entityClass) { - $metadata = $this->metadataFactory->parse($entityClass); - $table = $this->schemaBuilder->build($metadata); - $schema[$table->name] = $table; - } + $this->schemaRegistry->clear(); + $this->schemaRegistry->registerEntities($entityClasses); - return $schema; + return $this->schemaRegistry->getTables(); } /** diff --git a/packages/database/src/Command/MigrateCommand.php b/packages/database/src/Command/MigrateCommand.php index d5bad656..0e47e678 100644 --- a/packages/database/src/Command/MigrateCommand.php +++ b/packages/database/src/Command/MigrateCommand.php @@ -13,14 +13,13 @@ use Marko\Database\Diff\SchemaDiff; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Entity\EntityDiscovery; -use Marko\Database\Entity\EntityMetadataFactory; -use Marko\Database\Entity\SchemaBuilder; use Marko\Database\Exceptions\EntityException; use Marko\Database\Exceptions\MigrationException; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\Migration\DataMigrator; use Marko\Database\Migration\MigrationGenerator; use Marko\Database\Migration\Migrator; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table; /** @noinspection PhpUnused */ @@ -33,8 +32,7 @@ public function __construct( private MigrationGenerator $migrationGenerator, private EntityDiscovery $entityDiscovery, private IntrospectorInterface $introspector, - private EntityMetadataFactory $metadataFactory, - private SchemaBuilder $schemaBuilder, + private SchemaRegistry $schemaRegistry, private DiffCalculator $diffCalculator, private SqlGeneratorInterface $sqlGenerator, private ProjectPaths $paths, @@ -251,15 +249,10 @@ private function calculateDiff(): SchemaDiff private function buildEntitySchema( array $entityClasses, ): array { - $schema = []; - - foreach ($entityClasses as $entityClass) { - $metadata = $this->metadataFactory->parse($entityClass); - $table = $this->schemaBuilder->build($metadata); - $schema[$table->name] = $table; - } + $this->schemaRegistry->clear(); + $this->schemaRegistry->registerEntities($entityClasses); - return $schema; + return $this->schemaRegistry->getTables(); } /** diff --git a/packages/database/tests/Command/Helpers.php b/packages/database/tests/Command/Helpers.php index 4e750fe0..5b6e9190 100644 --- a/packages/database/tests/Command/Helpers.php +++ b/packages/database/tests/Command/Helpers.php @@ -14,6 +14,7 @@ use Marko\Database\Entity\EntityDiscovery; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Entity\SchemaBuilder; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\Schema\Table; @@ -229,8 +230,7 @@ public static function createDiffCommand( return new DiffCommand( discovery: self::createStubEntityDiscovery(), introspector: self::createStubIntrospector($tables), - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: $diffCalculator ?? new DiffCalculator(), paths: new ProjectPaths('/test'), ); diff --git a/packages/database/tests/Command/MigrateCommandTest.php b/packages/database/tests/Command/MigrateCommandTest.php index 34b9c4de..2847f127 100644 --- a/packages/database/tests/Command/MigrateCommandTest.php +++ b/packages/database/tests/Command/MigrateCommandTest.php @@ -12,6 +12,7 @@ use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Entity\SchemaBuilder; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Exceptions\MigrationException; use Marko\Database\Migration\DataMigrator; use Marko\Database\Migration\MigrationGenerator; @@ -304,8 +305,7 @@ function createMigrateCommand( migrationGenerator: $generator ?? createMigrationGeneratorStub(), entityDiscovery: Helpers::createStubEntityDiscovery(), introspector: Helpers::createStubIntrospector(), - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: createMigrateDiffCalculator($diff ?? new SchemaDiff()), sqlGenerator: $sqlGenerator ?? createMigrateSqlGenerator(), paths: new ProjectPaths('/test'), @@ -684,8 +684,7 @@ function executeMigrateCommand( migrationGenerator: $generator, entityDiscovery: Helpers::createStubEntityDiscovery(), introspector: $introspector, - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: new DiffCalculator(), sqlGenerator: createMigrateSqlGenerator(), paths: new ProjectPaths('/test'), From c2e25352296254780a2dfa968b8ca0d6ec009306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 18:07:10 +0200 Subject: [PATCH 03/13] =?UTF-8?q?fix(database-pgsql):=20normalise=20jsonb?= =?UTF-8?q?=20=E2=86=92=20json=20in=20introspector=20type=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PgSqlGenerator maps entity type 'json' to JSONB in DDL, which is correct (JSONB is preferred in PostgreSQL for indexing and performance). However, the introspector returned 'jsonb' for those columns, while the entity schema still held 'json'. With no alias in Column::typeEquals(), the diff calculator reported a spurious Modify on every json column after the initial migration. Mapping 'jsonb' → 'json' in PgSqlIntrospector::TYPE_MAP normalises the round-trip so introspected JSONB columns compare equal to entity-declared json columns. The fix is intentionally scoped to the PgSQL driver — MySQL is unaffected (it stores and introspects JSON under the same name). Co-Authored-By: Claude Sonnet 4.6 --- .../src/Introspection/PgSqlIntrospector.php | 2 +- .../Introspection/PgSqlIntrospectorTest.php | 47 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php b/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php index 450a7124..493125c6 100644 --- a/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php +++ b/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php @@ -51,7 +51,7 @@ 'double precision' => 'double', 'float8' => 'double', 'json' => 'json', - 'jsonb' => 'jsonb', + 'jsonb' => 'json', 'uuid' => 'uuid', 'bytea' => 'blob', ]; diff --git a/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php b/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php index 1ca265aa..12c55c3a 100644 --- a/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php +++ b/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php @@ -6,6 +6,7 @@ use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Connection\StatementInterface; +use Marko\Database\Diff\DiffCalculator; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\PgSql\Introspection\PgSqlIntrospector; use Marko\Database\Schema\Column; @@ -152,13 +153,57 @@ function (string $sql, array $bindings) use (&$queriedSql, &$queriedBindings): a ->and($columns[11]->type)->toBe('date') ->and($columns[12]->type)->toBe('time') ->and($columns[13]->type)->toBe('json') - ->and($columns[14]->type)->toBe('jsonb') + ->and($columns[14]->type)->toBe('json') ->and($columns[15]->type)->toBe('uuid') ->and($columns[16]->type)->toBe('char') ->and($columns[16]->length)->toBe(10) ->and($columns[17]->type)->toBe('blob'); }); + it('normalises jsonb to json so introspected columns match entity-declared type', function (): void { + $connection = createTestConnection(function (string $sql): array { + if (str_contains($sql, 'information_schema.columns')) { + return [ + ['column_name' => 'data', 'data_type' => 'jsonb', 'character_maximum_length' => null, 'is_nullable' => 'NO', 'column_default' => null, 'is_identity' => 'NO', 'identity_generation' => null], + ]; + } + + return []; + }); + + $introspector = new PgSqlIntrospector($connection); + $columns = $introspector->getColumns('products'); + + expect($columns[0]->type)->toBe('json'); + }); + + it('produces no diff when entity declares json and database stores jsonb', function (): void { + $entitySchema = [ + 'products' => new Table( + name: 'products', + columns: [new Column(name: 'data', type: 'json')], + indexes: [], + ), + ]; + + $connection = createTestConnection(function (string $sql): array { + if (str_contains($sql, 'information_schema.columns')) { + return [ + ['column_name' => 'data', 'data_type' => 'jsonb', 'character_maximum_length' => null, 'is_nullable' => 'NO', 'column_default' => null, 'is_identity' => 'NO', 'identity_generation' => null], + ]; + } + + return []; + }); + + $introspector = new PgSqlIntrospector($connection); + $databaseSchema = ['products' => $introspector->getTable('products')]; + + $diff = (new DiffCalculator())->calculate($entitySchema, $databaseSchema); + + expect($diff->isEmpty())->toBeTrue(); + }); + it('detects nullable columns', function (): void { $connection = createTestConnection(function (string $sql): array { if (str_contains($sql, 'information_schema.columns')) { From dd22ae39d3378f575e09ca52a5edcb1f5a03a797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 17:31:38 +0200 Subject: [PATCH 04/13] fix(database): use SchemaRegistry in DiffCommand and MigrateCommand for extender merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-pass buildEntitySchema loop called schemaBuilder->build() per entity independently, so extender columns (Table(extends:)) were never merged into the parent table schema. Commands saw extender columns as missing from the entity definition — reporting existing ones as destructive drops and never generating ADD COLUMN for new ones. Replaced the loop with SchemaRegistry::registerEntities(), which already implements the correct two-pass merge. Removed the now-unused EntityMetadataFactory and SchemaBuilder constructor dependencies from both commands; updated test helpers accordingly. Co-Authored-By: Claude Sonnet 4.6 --- packages/database/src/Command/DiffCommand.php | 17 +++++------------ .../database/src/Command/MigrateCommand.php | 17 +++++------------ packages/database/tests/Command/Helpers.php | 4 ++-- .../tests/Command/MigrateCommandTest.php | 7 +++---- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/database/src/Command/DiffCommand.php b/packages/database/src/Command/DiffCommand.php index fcbee57f..0996a3f9 100644 --- a/packages/database/src/Command/DiffCommand.php +++ b/packages/database/src/Command/DiffCommand.php @@ -13,10 +13,9 @@ use Marko\Database\Diff\SchemaDiff; use Marko\Database\Diff\TableDiff; use Marko\Database\Entity\EntityDiscovery; -use Marko\Database\Entity\EntityMetadataFactory; -use Marko\Database\Entity\SchemaBuilder; use Marko\Database\Exceptions\EntityException; use Marko\Database\Introspection\IntrospectorInterface; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table; /** @noinspection PhpUnused */ @@ -26,8 +25,7 @@ public function __construct( private EntityDiscovery $discovery, private IntrospectorInterface $introspector, - private EntityMetadataFactory $metadataFactory, - private SchemaBuilder $schemaBuilder, + private SchemaRegistry $schemaRegistry, private DiffCalculator $diffCalculator, private ProjectPaths $paths, ) {} @@ -77,15 +75,10 @@ public function execute( private function buildEntitySchema( array $entityClasses, ): array { - $schema = []; - - foreach ($entityClasses as $entityClass) { - $metadata = $this->metadataFactory->parse($entityClass); - $table = $this->schemaBuilder->build($metadata); - $schema[$table->name] = $table; - } + $this->schemaRegistry->clear(); + $this->schemaRegistry->registerEntities($entityClasses); - return $schema; + return $this->schemaRegistry->getTables(); } /** diff --git a/packages/database/src/Command/MigrateCommand.php b/packages/database/src/Command/MigrateCommand.php index d5bad656..0e47e678 100644 --- a/packages/database/src/Command/MigrateCommand.php +++ b/packages/database/src/Command/MigrateCommand.php @@ -13,14 +13,13 @@ use Marko\Database\Diff\SchemaDiff; use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Entity\EntityDiscovery; -use Marko\Database\Entity\EntityMetadataFactory; -use Marko\Database\Entity\SchemaBuilder; use Marko\Database\Exceptions\EntityException; use Marko\Database\Exceptions\MigrationException; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\Migration\DataMigrator; use Marko\Database\Migration\MigrationGenerator; use Marko\Database\Migration\Migrator; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table; /** @noinspection PhpUnused */ @@ -33,8 +32,7 @@ public function __construct( private MigrationGenerator $migrationGenerator, private EntityDiscovery $entityDiscovery, private IntrospectorInterface $introspector, - private EntityMetadataFactory $metadataFactory, - private SchemaBuilder $schemaBuilder, + private SchemaRegistry $schemaRegistry, private DiffCalculator $diffCalculator, private SqlGeneratorInterface $sqlGenerator, private ProjectPaths $paths, @@ -251,15 +249,10 @@ private function calculateDiff(): SchemaDiff private function buildEntitySchema( array $entityClasses, ): array { - $schema = []; - - foreach ($entityClasses as $entityClass) { - $metadata = $this->metadataFactory->parse($entityClass); - $table = $this->schemaBuilder->build($metadata); - $schema[$table->name] = $table; - } + $this->schemaRegistry->clear(); + $this->schemaRegistry->registerEntities($entityClasses); - return $schema; + return $this->schemaRegistry->getTables(); } /** diff --git a/packages/database/tests/Command/Helpers.php b/packages/database/tests/Command/Helpers.php index 4e750fe0..5b6e9190 100644 --- a/packages/database/tests/Command/Helpers.php +++ b/packages/database/tests/Command/Helpers.php @@ -14,6 +14,7 @@ use Marko\Database\Entity\EntityDiscovery; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Entity\SchemaBuilder; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\Schema\Table; @@ -229,8 +230,7 @@ public static function createDiffCommand( return new DiffCommand( discovery: self::createStubEntityDiscovery(), introspector: self::createStubIntrospector($tables), - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: $diffCalculator ?? new DiffCalculator(), paths: new ProjectPaths('/test'), ); diff --git a/packages/database/tests/Command/MigrateCommandTest.php b/packages/database/tests/Command/MigrateCommandTest.php index 34b9c4de..2847f127 100644 --- a/packages/database/tests/Command/MigrateCommandTest.php +++ b/packages/database/tests/Command/MigrateCommandTest.php @@ -12,6 +12,7 @@ use Marko\Database\Diff\SqlGeneratorInterface; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Entity\SchemaBuilder; +use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Exceptions\MigrationException; use Marko\Database\Migration\DataMigrator; use Marko\Database\Migration\MigrationGenerator; @@ -304,8 +305,7 @@ function createMigrateCommand( migrationGenerator: $generator ?? createMigrationGeneratorStub(), entityDiscovery: Helpers::createStubEntityDiscovery(), introspector: Helpers::createStubIntrospector(), - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: createMigrateDiffCalculator($diff ?? new SchemaDiff()), sqlGenerator: $sqlGenerator ?? createMigrateSqlGenerator(), paths: new ProjectPaths('/test'), @@ -684,8 +684,7 @@ function executeMigrateCommand( migrationGenerator: $generator, entityDiscovery: Helpers::createStubEntityDiscovery(), introspector: $introspector, - metadataFactory: new EntityMetadataFactory(), - schemaBuilder: new SchemaBuilder(), + schemaRegistry: new SchemaRegistry(new EntityMetadataFactory(), new SchemaBuilder()), diffCalculator: new DiffCalculator(), sqlGenerator: createMigrateSqlGenerator(), paths: new ProjectPaths('/test'), From 9c619ef9278a5f171cd5a31abdde931b82bed29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 18:07:10 +0200 Subject: [PATCH 05/13] =?UTF-8?q?fix(database-pgsql):=20normalise=20jsonb?= =?UTF-8?q?=20=E2=86=92=20json=20in=20introspector=20type=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PgSqlGenerator maps entity type 'json' to JSONB in DDL, which is correct (JSONB is preferred in PostgreSQL for indexing and performance). However, the introspector returned 'jsonb' for those columns, while the entity schema still held 'json'. With no alias in Column::typeEquals(), the diff calculator reported a spurious Modify on every json column after the initial migration. Mapping 'jsonb' → 'json' in PgSqlIntrospector::TYPE_MAP normalises the round-trip so introspected JSONB columns compare equal to entity-declared json columns. The fix is intentionally scoped to the PgSQL driver — MySQL is unaffected (it stores and introspects JSON under the same name). Co-Authored-By: Claude Sonnet 4.6 --- .../src/Introspection/PgSqlIntrospector.php | 2 +- .../Introspection/PgSqlIntrospectorTest.php | 47 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php b/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php index 450a7124..493125c6 100644 --- a/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php +++ b/packages/database-pgsql/src/Introspection/PgSqlIntrospector.php @@ -51,7 +51,7 @@ 'double precision' => 'double', 'float8' => 'double', 'json' => 'json', - 'jsonb' => 'jsonb', + 'jsonb' => 'json', 'uuid' => 'uuid', 'bytea' => 'blob', ]; diff --git a/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php b/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php index 1ca265aa..12c55c3a 100644 --- a/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php +++ b/packages/database-pgsql/tests/Introspection/PgSqlIntrospectorTest.php @@ -6,6 +6,7 @@ use Marko\Database\Connection\ConnectionInterface; use Marko\Database\Connection\StatementInterface; +use Marko\Database\Diff\DiffCalculator; use Marko\Database\Introspection\IntrospectorInterface; use Marko\Database\PgSql\Introspection\PgSqlIntrospector; use Marko\Database\Schema\Column; @@ -152,13 +153,57 @@ function (string $sql, array $bindings) use (&$queriedSql, &$queriedBindings): a ->and($columns[11]->type)->toBe('date') ->and($columns[12]->type)->toBe('time') ->and($columns[13]->type)->toBe('json') - ->and($columns[14]->type)->toBe('jsonb') + ->and($columns[14]->type)->toBe('json') ->and($columns[15]->type)->toBe('uuid') ->and($columns[16]->type)->toBe('char') ->and($columns[16]->length)->toBe(10) ->and($columns[17]->type)->toBe('blob'); }); + it('normalises jsonb to json so introspected columns match entity-declared type', function (): void { + $connection = createTestConnection(function (string $sql): array { + if (str_contains($sql, 'information_schema.columns')) { + return [ + ['column_name' => 'data', 'data_type' => 'jsonb', 'character_maximum_length' => null, 'is_nullable' => 'NO', 'column_default' => null, 'is_identity' => 'NO', 'identity_generation' => null], + ]; + } + + return []; + }); + + $introspector = new PgSqlIntrospector($connection); + $columns = $introspector->getColumns('products'); + + expect($columns[0]->type)->toBe('json'); + }); + + it('produces no diff when entity declares json and database stores jsonb', function (): void { + $entitySchema = [ + 'products' => new Table( + name: 'products', + columns: [new Column(name: 'data', type: 'json')], + indexes: [], + ), + ]; + + $connection = createTestConnection(function (string $sql): array { + if (str_contains($sql, 'information_schema.columns')) { + return [ + ['column_name' => 'data', 'data_type' => 'jsonb', 'character_maximum_length' => null, 'is_nullable' => 'NO', 'column_default' => null, 'is_identity' => 'NO', 'identity_generation' => null], + ]; + } + + return []; + }); + + $introspector = new PgSqlIntrospector($connection); + $databaseSchema = ['products' => $introspector->getTable('products')]; + + $diff = (new DiffCalculator())->calculate($entitySchema, $databaseSchema); + + expect($diff->isEmpty())->toBeTrue(); + }); + it('detects nullable columns', function (): void { $connection = createTestConnection(function (string $sql): array { if (str_contains($sql, 'information_schema.columns')) { From 7dd3dacff9c2ee32028ce59e94bbcc3e72c2294c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 20:52:20 +0200 Subject: [PATCH 06/13] feat(scope): add HasScopesInterface + HasScopes trait for trait-based scope storage Introduces HasScopesInterface as the polymorphic storage contract and HasScopes trait as the primary (simpler) storage approach. ScopeWalker, ScopeResolver, and ScopedEntityValidator are updated to accept either trait-based entities or the existing ScopedOverridesEntity companion approach, with no breaking changes. Co-Authored-By: Claude Sonnet 4.6 --- .claude/plans/scope/033-has-scopes-trait.md | 51 +++++ .../plans/scope/034-scope-walker-interface.md | 39 ++++ .../scope/035-scope-resolver-trait-support.md | 47 +++++ .../036-scope-validator-trait-support.md | 43 ++++ .../scope/037-has-scopes-readme-update.md | 37 ++++ .claude/plans/scope/_plan.md | 5 + docs/src/content/docs/packages/scope-mysql.md | 40 +++- docs/src/content/docs/packages/scope-pgsql.md | 40 +++- docs/src/content/docs/packages/scope.md | 36 +++- packages/scope/README.md | 95 ++++++++- .../ScopeConfigurationException.php | 20 +- packages/scope/src/Resolution/ScopeWalker.php | 28 +-- packages/scope/src/Resolver/ScopeResolver.php | 80 ++++---- packages/scope/src/Storage/HasScopes.php | 78 ++++++++ .../scope/src/Storage/HasScopesInterface.php | 34 ++++ .../src/Storage/ScopedOverridesEntity.php | 14 +- .../src/Validation/ScopedEntityValidator.php | 27 ++- packages/scope/tests/Unit/ReadmeTest.php | 36 ++++ .../tests/Unit/Resolution/ScopeWalkerTest.php | 113 +++++++++-- .../tests/Unit/Resolver/ScopeResolverTest.php | 186 +++++++++++++++++- .../tests/Unit/Storage/HasScopesTraitTest.php | 123 ++++++++++++ .../Storage/ScopedOverridesEntityTest.php | 25 ++- .../Validation/ScopedEntityValidatorTest.php | 143 +++++++++++++- 23 files changed, 1219 insertions(+), 121 deletions(-) create mode 100644 .claude/plans/scope/033-has-scopes-trait.md create mode 100644 .claude/plans/scope/034-scope-walker-interface.md create mode 100644 .claude/plans/scope/035-scope-resolver-trait-support.md create mode 100644 .claude/plans/scope/036-scope-validator-trait-support.md create mode 100644 .claude/plans/scope/037-has-scopes-readme-update.md create mode 100644 packages/scope/src/Storage/HasScopes.php create mode 100644 packages/scope/src/Storage/HasScopesInterface.php create mode 100644 packages/scope/tests/Unit/Storage/HasScopesTraitTest.php diff --git a/.claude/plans/scope/033-has-scopes-trait.md b/.claude/plans/scope/033-has-scopes-trait.md new file mode 100644 index 00000000..0b3fff4a --- /dev/null +++ b/.claude/plans/scope/033-has-scopes-trait.md @@ -0,0 +1,51 @@ +# Task 033: `HasScopesInterface` + `HasScopes` Trait + +**Status**: completed +**Depends on**: none +**Retry count**: 0 + +## Description +Introduce `HasScopesInterface` as the contract for scope override storage, and `HasScopes` as a trait that implements it. Entities `use HasScopes` instead of declaring a separate `ScopedOverridesEntity` companion class. `ScopedOverridesEntity` is updated to `implements HasScopesInterface` so both paths remain in use without breaking existing code. + +## Context +- The existing `ScopedOverridesEntity` already has `setOverride`, `getOverride`, `hasOverride`, `clearOverride`, `allOverrides`, and `#[Column(type: 'json', nullable: true)] public ?array $scopes = null`. The trait extracts these into a reusable form. +- PHP's `ReflectionClass::getProperties()` includes trait properties, so `EntityMetadataFactory` will pick up the `#[Column]` on `$scopes` automatically — no framework change needed for schema. +- The interface cannot be declared inside the trait (`trait T implements I` is not valid PHP). Entities that `use HasScopes` must also `implements HasScopesInterface` in their class declaration. Document this in a class-level docblock on the trait. +- The trait is only useful when used on an `Marko\Database\Entity\Entity` subclass — the `#[Column]` attribute on `$scopes` is only meaningful when `EntityMetadataFactory` parses the class. Document this constraint in the trait docblock. +- `HasScopesInterface` MUST declare all five methods (`setOverride`, `getOverride`, `hasOverride`, `clearOverride`, `allOverrides`). Even though `ScopeWalker`/`ScopeResolver` do not call `allOverrides()`, keeping it on the interface preserves parity with the existing `ScopedOverridesEntity` public API and supports debug / diff / serialization helpers that work polymorphically against the interface. +- Test fixture pattern for `HasScopesTraitTest.php`: declare a small `#[Table(name: '...')]` class that `extends Entity`, declares `#[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null;`, `use HasScopes;`, and `implements HasScopesInterface`. This fixture is reused in tasks 034/035/036 tests. +- Related files: + - `packages/scope/src/Storage/ScopedOverridesEntity.php` — add `implements HasScopesInterface` + - `packages/scope/src/Storage/HasScopesInterface.php` — new file + - `packages/scope/src/Storage/HasScopes.php` — new file + - `packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php` — add interface-assertion test + - `packages/scope/tests/Unit/Storage/` — new `HasScopesTraitTest.php` + +## Requirements (Test Descriptions) +- [x] `it exposes setOverride getOverride hasOverride clearOverride allOverrides via the trait` +- [x] `it stores multiple overrides keyed by scopeKey and property` +- [x] `it returns null for unknown scopeKey or property via getOverride` +- [x] `it returns false for hasOverride when no override exists` +- [x] `it distinguishes an explicit null override from no override via hasOverride` +- [x] `it clears a single property override leaving others intact` +- [x] `it sets scopes to null when the last override is cleared` +- [x] `it declares a json nullable Column attribute on the scopes property when reflected via a consuming class` +- [x] `a class using HasScopes can satisfy the HasScopesInterface contract` +- [x] `ScopedOverridesEntity implements HasScopesInterface and its public method signatures match the interface` +- [x] `HasScopesInterface declares setOverride getOverride hasOverride clearOverride allOverrides` + +## Acceptance Criteria +- All requirements have passing tests +- `HasScopesInterface` declares all five storage methods: `setOverride`, `getOverride`, `hasOverride`, `clearOverride`, `allOverrides` +- `HasScopes` trait body is identical in behaviour to the equivalent methods already in `ScopedOverridesEntity` +- `ScopedOverridesEntity` test suite still fully passes after adding `implements HasScopesInterface` +- Trait carries a class-level docblock noting (a) consumers must declare `implements HasScopesInterface` because PHP cannot enforce it from inside a trait, and (b) the trait is intended for use on `Marko\Database\Entity\Entity` subclasses so the `#[Column]` attribute is picked up by `EntityMetadataFactory` +- Code follows project standards + +## Implementation Notes +- Created `HasScopesInterface` with all five methods (`setOverride`, `getOverride`, `hasOverride`, `clearOverride`, `allOverrides`) +- Created `HasScopes` trait with methods extracted from `ScopedOverridesEntity`; trait carries docblock noting consumers must `implements HasScopesInterface` and should extend `Entity` +- Updated `ScopedOverridesEntity` to `implements HasScopesInterface` +- Added `HasScopesTraitTest.php` with 11 tests; `TraitProduct` fixture reused in tasks 034/035/036 +- Added interface-assertion test to `ScopedOverridesEntityTest.php` +- All 141 scope tests pass; sole pre-existing `EnvLoaderTest` failure is baseline noise unrelated to this task diff --git a/.claude/plans/scope/034-scope-walker-interface.md b/.claude/plans/scope/034-scope-walker-interface.md new file mode 100644 index 00000000..9572cdfa --- /dev/null +++ b/.claude/plans/scope/034-scope-walker-interface.md @@ -0,0 +1,39 @@ +# Task 034: Update `ScopeWalker` to Accept `HasScopesInterface` + +**Status**: complete +**Depends on**: [033] +**Retry count**: 0 + +## Description +Change `ScopeWalker::walk()` and `ScopeWalker::walkAt()` to accept `HasScopesInterface` instead of the concrete `ScopedOverridesEntity`. This makes the walker work with both the companion-class approach and the new trait-based approach without any conditional logic. + +## Context +- Related files: + - `packages/scope/src/Resolution/ScopeWalker.php` — change param type on both methods; swap `use Marko\Scope\Storage\ScopedOverridesEntity;` for `use Marko\Scope\Storage\HasScopesInterface;` + - `packages/scope/tests/Unit/Resolution/` — keep existing tests (they pass `ScopedOverridesEntity` subclass instances, which now implement the interface) and add tests with a trait-based fixture (entity that `use HasScopes` + `implements HasScopesInterface`) +- The walker only calls `hasOverride()` and `getOverride()` on the storage object — both are declared on `HasScopesInterface`. The body of both methods is unchanged; only the type signature changes. +- Existing tests that pass a `ScopedOverridesEntity` continue to pass because `ScopedOverridesEntity implements HasScopesInterface` (task 033). +- Downstream callers: `ScopeResolver::resolved()` and `ScopeResolver::resolvedAt()` currently pass a `ScopedOverridesEntity` companion into `walk()/walkAt()`. After widening to `HasScopesInterface` these calls still compile because `ScopedOverridesEntity implements HasScopesInterface`. Verify the existing `ScopeResolver` test suite continues passing without modification. + +## Requirements (Test Descriptions) +- [x] `it resolves an override via walk when passed a HasScopesInterface implementor that is not ScopedOverridesEntity` +- [x] `it returns notFound via walk when the HasScopesInterface implementor has no matching override` +- [x] `it resolves an override via walkAt when passed a HasScopesInterface implementor` +- [x] `it returns notFound via walkAt when the axis does not match` +- [x] `it walks hierarchy ancestors when the exact scope path has no override` + +## Acceptance Criteria +- All requirements have passing tests +- Both `walk()` and `walkAt()` parameter types changed to `HasScopesInterface` +- The `Marko\Scope\Storage\ScopedOverridesEntity` import is replaced with `Marko\Scope\Storage\HasScopesInterface` in `ScopeWalker.php` +- No conditional logic added — pure type-widening change +- Existing `ScopeWalker` tests continue passing +- Existing `ScopeResolver` tests continue passing without modification (verifies the resolver still compiles against the widened signature) +- Code follows project standards + +## Implementation Notes +- Changed `ScopeWalker::walk()` and `ScopeWalker::walkAt()` parameter type from `ScopedOverridesEntity` to `HasScopesInterface` +- Replaced `use Marko\Scope\Storage\ScopedOverridesEntity;` with `use Marko\Scope\Storage\HasScopesInterface;` in `ScopeWalker.php` +- Added `WalkerTraitProduct` fixture (Entity + HasScopes trait + HasScopesInterface) to `ScopeWalkerTest.php` +- No conditional logic added — pure type-widening change; body of both methods is unchanged +- All existing tests (ScopeWalker + ScopeResolver) continue passing without modification diff --git a/.claude/plans/scope/035-scope-resolver-trait-support.md b/.claude/plans/scope/035-scope-resolver-trait-support.md new file mode 100644 index 00000000..a2ce2a48 --- /dev/null +++ b/.claude/plans/scope/035-scope-resolver-trait-support.md @@ -0,0 +1,47 @@ +# Task 035: Update `ScopeResolver` for Trait-Based Entities + +**Status**: pending +**Depends on**: [033, 034] +**Retry count**: 0 + +## Description +Update `ScopeResolver` so that when an entity itself implements `HasScopesInterface` (via the `HasScopes` trait), the resolver uses the entity directly as the override storage — no companion lookup or creation needed. The companion-based path remains intact as a fallback so existing code is not broken. + +## Context +- Related files: + - `packages/scope/src/Resolver/ScopeResolver.php` — update `findStorage()` logic and all callers + - `packages/scope/tests/Unit/Resolver/ScopeResolverTest.php` — add trait-based entity test cases +- Current flow in `resolved()` / `resolvedAt()`: call `findCompanion($entity)` → returns `?ScopedOverridesEntity`. New flow: call `findStorage($entity)` → returns `?HasScopesInterface`. +- `findStorage()` resolution order (this ordering is part of the contract): + 1. If `$entity instanceof HasScopesInterface` → return `$entity` (the entity IS the storage). + 2. Otherwise iterate `$entity->companions()` and return the first companion that `instanceof HasScopesInterface` (covers `ScopedOverridesEntity` and any other future `HasScopesInterface` implementor). + 3. Otherwise return `null`. +- `setOverride()` currently calls `createCompanion()` when no companion exists. With the trait approach the entity is already the storage — when `findStorage()` returns the entity itself, no companion creation happens; `setOverride()` is called directly on that storage object. +- `clearOverride()` same pattern: when `findStorage()` returns a value (trait-based or companion-based), call `clearOverride()` on it. When `findStorage()` returns null AND the entity does NOT implement `HasScopesInterface`, silently return (existing behavior preserved). +- Rename internal helper from `findCompanion()` to `findStorage()` returning `?HasScopesInterface` to reflect the widened contract. Update the `use` block to import `HasScopesInterface`; the `ScopedOverridesEntity` import remains only in `createCompanion()` (which still hunts specifically for `ScopedOverridesEntity` subclasses in the extender list). +- `createCompanion()` is only called when the entity does NOT implement `HasScopesInterface` and no companion exists. Its logic and error message are unchanged — it intentionally narrows to `ScopedOverridesEntity` subclasses because that is the only known persistence-ready extender pattern. A future `HasScopesInterface` extender that is not a `ScopedOverridesEntity` would not be auto-created (callers must attach it themselves). +- Edge case: if the entity implements `HasScopesInterface` AND has a `ScopedOverridesEntity` companion attached (pathological mixed setup), the entity-self wins per the ordering above. Task 036's validator additionally surfaces this conflict at boot time. + +## Requirements (Test Descriptions) +- [ ] `it resolves a scoped value when the entity itself implements HasScopesInterface` +- [ ] `it falls back to the column value when entity implements HasScopesInterface but has no override` +- [ ] `it resolves a scoped value when a ScopedOverridesEntity companion is attached (backward compat)` +- [ ] `it sets an override directly on the entity when it implements HasScopesInterface and no companion is attached or created` +- [ ] `it clears an override directly on the entity when it implements HasScopesInterface` +- [ ] `it silently no-ops when clearOverride is called on a trait-based entity that has no overrides yet` +- [ ] `it throws ScopeContextException when setOverride is called on an entity that does not implement HasScopesInterface and has no companion registered as an extender` +- [ ] `it resolvedAt returns the correct value for an explicit scope on a trait-based entity` +- [ ] `it returns the column value when resolvedAt finds no override for the given scope on a trait-based entity` +- [ ] `it prefers the entity itself over any attached companion when both implement HasScopesInterface (entity-self wins ordering)` +- [ ] `it does not create or attach a companion when setOverride is called on a trait-based entity` + +## Acceptance Criteria +- All requirements have passing tests +- No companion class created or attached when entity implements `HasScopesInterface` +- Existing companion-based tests continue passing (backward compatibility) +- `findCompanion()` replaced by `findStorage()` returning `?HasScopesInterface` with documented ordering: entity-self first, then companions, then null +- `createCompanion()` retains its existing signature and `ScopedOverridesEntity`-narrowing behavior — only reachable from the legacy companion path +- Code follows project standards + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/scope/036-scope-validator-trait-support.md b/.claude/plans/scope/036-scope-validator-trait-support.md new file mode 100644 index 00000000..58a40f08 --- /dev/null +++ b/.claude/plans/scope/036-scope-validator-trait-support.md @@ -0,0 +1,43 @@ +# Task 036: Update `ScopedEntityValidator` for Trait-Based Entities + +**Status**: completed +**Depends on**: [033] +**Retry count**: 0 + +## Description +Update `ScopedEntityValidator::validate()` to accept trait-based entities as valid. Currently it throws `ScopeConfigurationException` unless the entity has a registered `ScopedOverridesEntity` companion. With the `HasScopes` trait, an entity can provide its own storage — no companion required. + +## Context +- Related files: + - `packages/scope/src/Validation/ScopedEntityValidator.php` — add early-return when entity `implements HasScopesInterface`; add conflict check when entity uses BOTH the trait and a `ScopedOverridesEntity` extender + - `packages/scope/src/Exceptions/ScopeConfigurationException.php` — add new factory `traitAndCompanionConflict(string $parentClass, string $extenderClass): self` + - `packages/scope/tests/Unit/Validation/` — add trait-based entity test cases (including the conflict case) +- Current logic: if entity has scoped properties → look for `ScopedOverridesEntity` extender → throw if missing. +- New logic: + 1. If entity has no scoped properties → return early (unchanged). + 2. If `is_a($entityClass, HasScopesInterface::class, true)` returns true: + - If entity ALSO has a `ScopedOverridesEntity` subclass in its extenders → throw `ScopeConfigurationException::traitAndCompanionConflict()` with a clear message. This prevents the cryptic `EntityException::duplicateColumnInExtender('scopes', ...)` that would otherwise fire from `SchemaRegistry` at schema-build time. + - Otherwise → return (valid). + 3. Otherwise fall through to existing companion check (unchanged). +- The validator receives a class-string, not an instance, so use `is_a($entityClass, HasScopesInterface::class, true)` for the interface check. Note: this returns false if the class is not autoloadable; the validator is invoked at boot after autoload registration, so this is safe. +- The existing companion check path and the existing `missingOverridesExtender` / `wrongOverridesExtenderBase` factory methods remain unchanged. + +## Requirements (Test Descriptions) +- [ ] `it passes validation when the entity class implements HasScopesInterface and has scoped properties` +- [ ] `it passes validation when the entity has a ScopedOverridesEntity companion (backward compat)` +- [ ] `it passes validation when the entity has no scoped properties at all` +- [ ] `it throws ScopeConfigurationException when entity has scoped properties but neither trait nor companion` +- [ ] `it throws ScopeConfigurationException with wrongOverridesExtenderBase when extender exists but is not ScopedOverridesEntity` +- [ ] `it throws ScopeConfigurationException via traitAndCompanionConflict when entity uses HasScopes trait AND has a ScopedOverridesEntity extender registered` +- [ ] `the traitAndCompanionConflict exception message names both the parent class and the conflicting extender class` + +## Acceptance Criteria +- All requirements have passing tests +- Trait-based entity with scoped properties passes without any companion registered +- Trait-based entity with an accidentally-registered `ScopedOverridesEntity` extender fails with a clear scope-domain exception (not the downstream schema-build error) +- New `ScopeConfigurationException::traitAndCompanionConflict()` factory exists with an actionable message and suggestion ("Remove either the `use HasScopes;` trait or the extender class — they both contribute a `scopes` column") +- Existing exception paths and messages unchanged +- Code follows project standards + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/scope/037-has-scopes-readme-update.md b/.claude/plans/scope/037-has-scopes-readme-update.md new file mode 100644 index 00000000..c7f18eb9 --- /dev/null +++ b/.claude/plans/scope/037-has-scopes-readme-update.md @@ -0,0 +1,37 @@ +# Task 037: Update `marko/scope` README for `HasScopes` Trait + +**Status**: pending +**Depends on**: [033, 034, 035, 036] +**Retry count**: 0 + +## Description +Update the `marko/scope` README to document `HasScopes` as the primary (simpler) API for declaring scope storage on an entity. Move the companion-class pattern to a secondary "Alternative: companion class" section so existing users are not broken but new users reach the trait-based path first. + +## Context +- Related files: + - `packages/scope/README.md` — update storage section (this file was produced by task 026 and is currently a short overview that defers detail to external docs; the trait additions warrant inline documentation here) +- The README currently does not document either storage approach in detail (only a quick example with `setOverride`). The new documentation should lead with the trait, then show the companion as an opt-in alternative for cases where separation of concerns is preferred (e.g., separate package providing overrides for another package's entity). +- Required README sections to add/update: + 1. **Quick start** — `use HasScopes` + `implements HasScopesInterface` on the entity + 2. **How it works (schema)** — explain that `#[Column]` on the trait property is picked up by `EntityMetadataFactory` automatically; run migration to add the `scopes` column + 3. **Alternative: companion class** — existing `ScopedOverridesEntity` pattern with rationale ("useful when the scoped overrides are contributed by a separate package, or when you cannot modify the entity class") + 4. **Migration / Don't mix them** — explicit note: do NOT use both `use HasScopes` and a `ScopedOverridesEntity` extender on the same entity. The boot-time validator (task 036) will surface a `ScopeConfigurationException::traitAndCompanionConflict()` if you do; if validation is bypassed the schema build will fail with `EntityException::duplicateColumnInExtender('scopes', ...)`. + 5. **Batch insert note** — trait-based entities have no attached companion, so they are compatible with `Repository::insertBatch()`. Companion-based scoped entities are not (`BatchInsertException::companionsNotSupported` fires). + 6. **Resolver API** — show `resolved()`, `setOverride()`, `clearOverride()` — these work identically regardless of which storage approach is used. +- The `ReadmeTest` in `packages/scope/tests/Unit/ReadmeTest.php` is strict and verifies specific substrings. After edits the README MUST still contain: `# marko/scope`, `Scoped attributes for entities with multi-axis hierarchical fallback`, `## Installation`, `composer require marko/scope`, `scope-mysql`, `scope-pgsql`, `#[Scoped`, `setOverride`, `resolved(`, `locale`, `Product`, `$name`, `## Documentation`. + +## Requirements (Test Descriptions) +- [x] `it passes the existing ReadmeTest after updates` +- [x] `it documents HasScopes trait as the primary storage option` +- [x] `it documents the companion-class approach as an alternative` +- [x] `it warns against using HasScopes trait and ScopedOverridesEntity extender on the same entity` +- [x] `it documents that trait-based entities are compatible with Repository::insertBatch while companion-based entities are not` + +## Acceptance Criteria +- `ReadmeTest` passes +- README accurately describes both storage approaches and the conflict between them +- README documents the batch-insert compatibility difference +- Code follows project standards + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/scope/_plan.md b/.claude/plans/scope/_plan.md index b1e20278..c871a63a 100644 --- a/.claude/plans/scope/_plan.md +++ b/.claude/plans/scope/_plan.md @@ -103,6 +103,11 @@ none | 030 | Implement `orderByRaw` on `MySqlQueryBuilder` (marko/database-mysql) | 029 | completed | | 031 | Implement `orderByRaw` on `PgSqlQueryBuilder` (marko/database-pgsql) | 029 | completed | | 032 | `ScopedOrderByFactory` service | 016 | completed | +| 033 | `HasScopesInterface` + `HasScopes` trait (storage primitives) | - | completed | +| 034 | Update `ScopeWalker` to accept `HasScopesInterface` | 033 | completed | +| 035 | Update `ScopeResolver` for trait-based entities | 033, 034 | completed | +| 036 | Update `ScopedEntityValidator` for trait-based entities | 033 | completed | +| 037 | Update `marko/scope` README for `HasScopes` trait | 033, 034, 035, 036 | completed | ## Architecture Notes diff --git a/docs/src/content/docs/packages/scope-mysql.md b/docs/src/content/docs/packages/scope-mysql.md index e92f1b09..1358797a 100644 --- a/docs/src/content/docs/packages/scope-mysql.md +++ b/docs/src/content/docs/packages/scope-mysql.md @@ -3,7 +3,7 @@ title: marko/scope-mysql description: MySQL and MariaDB driver for marko/scope — scoped ORDER BY and automatic scopes column migration. --- -MySQL and MariaDB driver for `marko/scope` --- enables scoped `ORDER BY` queries and automatic `scopes` column migration via the existing entity extender pipeline. The package provides `MySqlScopeSortRenderer`, which emits `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` expressions for scope-aware sorting. The `scopes` JSON column is added to the parent entity's table automatically when a `ScopedOverridesEntity` extender is registered --- no separate migration helper is needed. Requires MariaDB 10.3+ or MySQL 8.0+. +MySQL and MariaDB driver for `marko/scope` --- enables scoped `ORDER BY` queries and automatic `scopes` column migration. The package provides `MySqlScopeSortRenderer`, which emits `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` expressions for scope-aware sorting. The `scopes` JSON column is added automatically --- either via the `HasScopes` trait on the entity itself, or via a `ScopedOverridesEntity` companion class when the entity cannot be modified. No separate migration helper is needed in either case. Requires MariaDB 10.3+ or MySQL 8.0+. ## Installation @@ -15,9 +15,43 @@ This automatically installs `marko/scope` as a transitive dependency. ## Usage -### Declaring the companion class +### Adding the `scopes` column -Declare a `ScopedOverridesEntity` subclass with `#[Table(extends:)]` pointing at your entity. When `db:migrate` runs, the `scopes` JSON column is merged into the parent table automatically: +There are two ways to get the `scopes` JSON column into your entity's table. + +**Option 1 --- `HasScopes` trait (recommended).** Implement `HasScopesInterface` and use the `HasScopes` trait on the entity. The trait declares the column directly; no companion class is needed: + +```php title="app/catalog/Entity/Product.php" +clearOverride($product, 'price', new Scope('market', 'eu.de')); $productRepository->save($product); ``` +### Mixing storage approaches + +Do **not** use both `use HasScopes` and a `ScopedOverridesEntity` extender on the same entity. The boot-time validator raises `ScopeConfigurationException::traitAndCompanionConflict()` if both are detected. If validation is bypassed, the schema build will fail with a duplicate-column error. + ## Customization ### DB-driven scope registry @@ -291,7 +307,9 @@ return [ | `Marko\Scope\Attributes\Scoped` | Property attribute declaring which axes scope a value | | `Marko\Scope\Context\ScopeContext` | Mutable singleton holding the active path per axis for the current request | | `Marko\Scope\Resolver\ScopeResolver` | Resolves scoped values by walking the active context hierarchy; also writes and clears overrides | -| `Marko\Scope\Storage\ScopedOverridesEntity` | Abstract companion entity holding the JSON `scopes` column | +| `Marko\Scope\Storage\HasScopesInterface` | Interface for entities that store scoped overrides directly via the `HasScopes` trait | +| `Marko\Scope\Storage\HasScopes` | Trait that adds a `$scopes` JSON column to the entity and implements `HasScopesInterface` | +| `Marko\Scope\Storage\ScopedOverridesEntity` | Abstract companion entity holding the JSON `scopes` column (alternative to `HasScopes`) | | `Marko\Scope\Query\ScopedOrderBy` | `QuerySpecification` that orders by resolved scope value | | `Marko\Scope\Query\ScopedOrderByFactory` | Factory for building `ScopedOrderBy` specifications | | `Marko\Scope\Query\ScopeSortRendererInterface` | Interface implemented by driver packages to emit DB-specific `COALESCE` expressions | @@ -315,8 +333,8 @@ return [ |--------|-------------| | `resolved(Entity $entity, string $property): mixed` | Walk the active context hierarchy and return the most specific override, falling back to the column value. | | `resolvedAt(Entity $entity, string $property, Scope $scope): mixed` | Resolve at a specific scope regardless of the active context. | -| `setOverride(Entity $entity, string $property, mixed $value, Scope $scope): void` | Attach a scoped value to the entity's companion. Creates the companion if one doesn't exist yet. | -| `clearOverride(Entity $entity, string $property, Scope $scope): void` | Remove a scoped override from the entity's companion. | +| `setOverride(Entity $entity, string $property, mixed $value, Scope $scope): void` | Attach a scoped value to the entity. For `HasScopesInterface` entities writes directly to `$scopes`; for companion-based entities creates the companion if one does not exist yet. | +| `clearOverride(Entity $entity, string $property, Scope $scope): void` | Remove a scoped override from the entity. | ### `ScopedOrderByFactory` @@ -338,7 +356,7 @@ return [ **`ScopeContext` is a mutable singleton.** It holds active paths for the entire PHP process lifetime. In long-running processes (FPM workers, queue daemons, ReactPHP servers), the bootstrap layer must call `$scopeContext->clearAll()` between requests or jobs to prevent cross-request scope leakage. -**`Repository::insertBatch` does not support scoped entities.** Batch inserts bypass the companion lifecycle and cannot attach per-row overrides. Use individual `save()` calls for entities with `#[Scoped]` properties. +**`Repository::insertBatch` support depends on the storage approach.** Trait-based entities (`use HasScopes`) store overrides directly on the entity and are fully compatible with `insertBatch()`. Companion-based entities (`ScopedOverridesEntity` extender) are **not** compatible --- batch inserts bypass the companion lifecycle and will throw `BatchInsertException::companionsNotSupported()`. Use individual `save()` calls for companion-based scoped entities. **Terminology overlap with `marko/config`.** The `marko/config` package uses the term "tenant scope" as a configuration parameter name. This is unrelated to `marko/scope`'s axis/path concept --- the two systems are independent. diff --git a/packages/scope/README.md b/packages/scope/README.md index a5d56c8a..69c93a1a 100644 --- a/packages/scope/README.md +++ b/packages/scope/README.md @@ -16,6 +16,61 @@ composer require marko/scope-mysql composer require marko/scope-pgsql ``` +## Quick start + +Add the `HasScopes` trait and `HasScopesInterface` to your entity: + +```php +use Marko\Database\Attributes\Column; +use Marko\Database\Attributes\Table; +use Marko\Database\Entity\Entity; +use Marko\Scope\Attributes\Scoped; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; + +#[Table('products')] +class Product extends Entity implements HasScopesInterface +{ + use HasScopes; + + #[Column(primaryKey: true, autoIncrement: true)] + public int $id; + + #[Column(length: 255)] + #[Scoped(axes: ['locale'])] + public string $name = ''; +} +``` + +Then run your migration to add the `scopes` column: + +```bash +php artisan marko:migrate +``` + +## How it works (schema) + +The `HasScopes` trait declares a `$scopes` property with a `#[Column]` attribute. `EntityMetadataFactory` picks this up automatically during schema generation, adding a `scopes` JSON column to your entity's table. No manual column definition is required. + +## Resolver API + +Use `ScopeResolver` to read and write scoped values. The API is identical regardless of which storage approach you use: + +```php +use Marko\Scope\Resolver\ScopeResolver; +use Marko\Scope\Scope; + +// Write a scoped override +$scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); + +// Clear a scoped override +$scopeResolver->clearOverride($product, 'name', new Scope('locale', 'de')); + +// Read with hierarchy fallback (de-DE walks up to de) +$scopeContext->in('locale', 'de-DE'); +$localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' +``` + ## Quick Example ```php @@ -25,10 +80,14 @@ use Marko\Database\Entity\Entity; use Marko\Scope\Attributes\Scoped; use Marko\Scope\Resolver\ScopeResolver; use Marko\Scope\Scope; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; #[Table('products')] -class Product extends Entity +class Product extends Entity implements HasScopesInterface { + use HasScopes; + #[Column(primaryKey: true, autoIncrement: true)] public int $id; @@ -45,6 +104,40 @@ $scopeContext->in('locale', 'de-DE'); $localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' ``` +## Alternative: companion class + +When the scoped overrides are contributed by a separate package, or when you cannot modify the entity class, use a `ScopedOverridesEntity` extender instead: + +```php +use Marko\Database\Attributes\Table; +use Marko\Database\Entity\Entity; +use Marko\Scope\Storage\ScopedOverridesEntity; + +#[Table('product_scopes')] +class ProductScopedOverrides extends ScopedOverridesEntity +{ + // Links back to Product +} +``` + +Register the companion via the module's extender configuration. This pattern is useful when: + +- Scoped overrides are provided by a module that does not own the base entity +- You want to keep the base entity's schema clean +- The entity class cannot be modified (e.g. it comes from a vendor package) + +## Don't mix them + +Do **not** use both `use HasScopes` and a `ScopedOverridesEntity` extender on the same entity. + +The boot-time validator will surface a `ScopeConfigurationException::traitAndCompanionConflict()` error if you do. If validation is bypassed, the schema build will fail with `EntityException::duplicateColumnInExtender('scopes', ...)`. + +## Batch insert compatibility + +Trait-based entities (`use HasScopes`) have no attached companion, so they are fully compatible with `Repository::insertBatch()`. + +Companion-based scoped entities (`ScopedOverridesEntity` extender) are **not** compatible with `Repository::insertBatch()`. Attempting a batch insert will throw `BatchInsertException::companionsNotSupported()`. + ## Documentation Full usage, API reference, and examples: [marko/scope](https://marko.build/docs/packages/scope/) diff --git a/packages/scope/src/Exceptions/ScopeConfigurationException.php b/packages/scope/src/Exceptions/ScopeConfigurationException.php index a0727581..bb33452d 100644 --- a/packages/scope/src/Exceptions/ScopeConfigurationException.php +++ b/packages/scope/src/Exceptions/ScopeConfigurationException.php @@ -24,8 +24,7 @@ public static function malformedString(string $scope): self public static function malformedConfig( string $axis, string $reason, - ): self - { + ): self { return new self( message: "Scope configuration for axis '$axis' is malformed: $reason", context: "Parsing scope configuration for axis '$axis'", @@ -48,8 +47,7 @@ public static function duplicatePath(string $path): self public static function missingOverridesExtender( string $parentClass, array $scopedProperties, - ): self - { + ): self { $propertyLines = []; foreach ($scopedProperties as $property => $axes) { $axesStr = count($axes) > 0 ? implode(', ', $axes) : 'none'; @@ -70,12 +68,22 @@ public static function missingOverridesExtender( public static function wrongOverridesExtenderBase( string $parentClass, string $extenderClass, - ): self - { + ): self { return new self( message: "Extender '$extenderClass' for '$parentClass' does not extend ScopedOverridesEntity.", context: "Validating scoped entity '$parentClass': found extender '$extenderClass' but it does not extend ScopedOverridesEntity.", suggestion: "Make '$extenderClass' extend ScopedOverridesEntity instead of Entity directly.", ); } + + public static function traitAndCompanionConflict( + string $parentClass, + string $extenderClass, + ): self { + return new self( + message: "Entity '$parentClass' uses both the HasScopes trait and has a ScopedOverridesEntity extender '$extenderClass' — they both contribute a `scopes` column.", + context: "Validating scoped entity '$parentClass': found ScopedOverridesEntity extender '$extenderClass' but the entity already uses the HasScopes trait.", + suggestion: 'Remove either the `use HasScopes;` trait or the extender class — they both contribute a `scopes` column', + ); + } } diff --git a/packages/scope/src/Resolution/ScopeWalker.php b/packages/scope/src/Resolution/ScopeWalker.php index a02dfc81..ec789ec2 100644 --- a/packages/scope/src/Resolution/ScopeWalker.php +++ b/packages/scope/src/Resolution/ScopeWalker.php @@ -9,7 +9,7 @@ use Marko\Scope\Exceptions\UnknownScopeException; use Marko\Scope\Registry\ScopeRegistryInterface; use Marko\Scope\Scope; -use Marko\Scope\Storage\ScopedOverridesEntity; +use Marko\Scope\Storage\HasScopesInterface; class ScopeWalker { @@ -18,7 +18,7 @@ class ScopeWalker * @throws UnknownAxisException|UnknownScopeException */ public function walk( - ScopedOverridesEntity $overrides, + HasScopesInterface $overrides, string $property, array $axes, ScopeContext $context, @@ -34,12 +34,13 @@ public function walk( $hierarchy = $registry->getHierarchy($axis); $walked = $hierarchy->walkUp($path); - foreach ($walked as $scope) { - $scopeKey = $axis . ':' . $scope; + $matchedScope = array_find( + $walked, + fn (string $scope) => $overrides->hasOverride($axis . ':' . $scope, $property), + ); - if ($overrides->hasOverride($scopeKey, $property)) { - return ScopeWalkResult::found($overrides->getOverride($scopeKey, $property)); - } + if ($matchedScope !== null) { + return ScopeWalkResult::found($overrides->getOverride($axis . ':' . $matchedScope, $property)); } } @@ -53,7 +54,7 @@ public function walk( * @throws UnknownAxisException|UnknownScopeException */ public function walkAt( - ScopedOverridesEntity $overrides, + HasScopesInterface $overrides, string $property, array $axes, Scope $scope, @@ -68,12 +69,13 @@ public function walkAt( $hierarchy = $registry->getHierarchy($axis); $walked = $hierarchy->walkUp($scope->path); - foreach ($walked as $scopePath) { - $scopeKey = $axis . ':' . $scopePath; + $matchedScope = array_find( + $walked, + fn (string $scopePath) => $overrides->hasOverride($axis . ':' . $scopePath, $property), + ); - if ($overrides->hasOverride($scopeKey, $property)) { - return ScopeWalkResult::found($overrides->getOverride($scopeKey, $property)); - } + if ($matchedScope !== null) { + return ScopeWalkResult::found($overrides->getOverride($axis . ':' . $matchedScope, $property)); } return ScopeWalkResult::notFound(); diff --git a/packages/scope/src/Resolver/ScopeResolver.php b/packages/scope/src/Resolver/ScopeResolver.php index 4dfd3704..0375be82 100644 --- a/packages/scope/src/Resolver/ScopeResolver.php +++ b/packages/scope/src/Resolver/ScopeResolver.php @@ -11,10 +11,12 @@ use Marko\Scope\Context\ScopeContext; use Marko\Scope\Exceptions\ScopeContextException; use Marko\Scope\Exceptions\UnknownAxisException; +use Marko\Scope\Exceptions\UnknownScopeException; use Marko\Scope\Metadata\ScopeMetadataFactory; use Marko\Scope\Registry\ScopeRegistryInterface; use Marko\Scope\Resolution\ScopeWalker; use Marko\Scope\Scope; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; readonly class ScopeResolver @@ -27,14 +29,12 @@ public function __construct( ) {} /** - * @throws ScopeContextException - * @throws UnknownAxisException + * @throws ScopeContextException|UnknownAxisException|UnknownScopeException */ public function resolved( Entity $entity, string $property, - ): mixed - { + ): mixed { $entityClass = get_class($entity); if (!property_exists($entity, $property)) { @@ -45,10 +45,10 @@ public function resolved( $axes = $scopeMetadata->axesForProperty($property); $registry = $this->getRegistry(); - $companion = $this->findCompanion($entity); + $storage = $this->findStorage($entity); - if ($companion !== null) { - $result = $this->scopeWalker->walk($companion, $property, $axes, $this->scopeContext, $registry); + if ($storage !== null) { + $result = $this->scopeWalker->walk($storage, $property, $axes, $this->scopeContext, $registry); if ($result->isFound()) { return $result->value(); @@ -59,23 +59,22 @@ public function resolved( } /** - * @throws UnknownAxisException + * @throws UnknownAxisException|UnknownScopeException */ public function resolvedAt( Entity $entity, string $property, Scope $scope, - ): mixed - { + ): mixed { $entityClass = get_class($entity); $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); $axes = $scopeMetadata->axesForProperty($property); $registry = $this->getRegistry(); - $companion = $this->findCompanion($entity); + $storage = $this->findStorage($entity); - if ($companion !== null) { - $result = $this->scopeWalker->walkAt($companion, $property, $axes, $scope, $registry); + if ($storage !== null) { + $result = $this->scopeWalker->walkAt($storage, $property, $axes, $scope, $registry); if ($result->isFound()) { return $result->value(); @@ -86,18 +85,14 @@ public function resolvedAt( } /** - * @throws ScopeContextException - * @throws UnknownAxisException - * @throws EntityException - * @throws MissingPrimaryKeyException + * @throws ScopeContextException|UnknownAxisException|EntityException|MissingPrimaryKeyException */ public function setOverride( Entity $entity, string $property, mixed $value, Scope $scope, - ): void - { + ): void { $entityClass = get_class($entity); $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); @@ -105,27 +100,26 @@ public function setOverride( throw ScopeContextException::propertyNotScoped($property, $entityClass); } - $companion = $this->findCompanion($entity); + $storage = $this->findStorage($entity); - if ($companion === null) { + if ($storage === null) { $companion = $this->createCompanion($entityClass); $entity->attachCompanion($companion); + $storage = $companion; } $scopeKey = $scope->axisName . ':' . $scope->path; - $companion->setOverride($scopeKey, $property, $value); + $storage->setOverride($scopeKey, $property, $value); } /** - * @throws ScopeContextException - * @throws UnknownAxisException + * @throws ScopeContextException|UnknownAxisException */ public function clearOverride( Entity $entity, string $property, Scope $scope, - ): void - { + ): void { $entityClass = get_class($entity); $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); @@ -133,30 +127,31 @@ public function clearOverride( throw ScopeContextException::propertyNotScoped($property, $entityClass); } - $companion = $this->findCompanion($entity); + $storage = $this->findStorage($entity); - if ($companion === null) { + if ($storage === null) { return; } $scopeKey = $scope->axisName . ':' . $scope->path; - $companion->clearOverride($scopeKey, $property); + $storage->clearOverride($scopeKey, $property); } /** * @param class-string $entityClass - * @throws ScopeContextException - * @throws EntityException - * @throws MissingPrimaryKeyException + * @throws ScopeContextException|EntityException|MissingPrimaryKeyException */ private function createCompanion(string $entityClass): ScopedOverridesEntity { $entityMetadata = $this->entityMetadataFactory->parse($entityClass); - foreach ($entityMetadata->extenders as $extender) { - if (is_subclass_of($extender, ScopedOverridesEntity::class)) { - return new $extender(); - } + $extender = array_find( + $entityMetadata->extenders, + fn (string $candidate) => is_subclass_of($candidate, ScopedOverridesEntity::class), + ); + + if ($extender !== null) { + return new $extender(); } throw new ScopeContextException( @@ -166,15 +161,16 @@ private function createCompanion(string $entityClass): ScopedOverridesEntity ); } - private function findCompanion(Entity $entity): ?ScopedOverridesEntity + private function findStorage(Entity $entity): ?HasScopesInterface { - foreach ($entity->companions() as $companion) { - if ($companion instanceof ScopedOverridesEntity) { - return $companion; - } + if ($entity instanceof HasScopesInterface) { + return $entity; } - return null; + return array_find( + $entity->companions(), + fn ($companion) => $companion instanceof HasScopesInterface, + ); } private function getRegistry(): ScopeRegistryInterface diff --git a/packages/scope/src/Storage/HasScopes.php b/packages/scope/src/Storage/HasScopes.php new file mode 100644 index 00000000..7ad2be6c --- /dev/null +++ b/packages/scope/src/Storage/HasScopes.php @@ -0,0 +1,78 @@ +scopes ?? []; + $scopes[$scopeKey][$property] = $value; + ksort($scopes[$scopeKey]); + ksort($scopes); + $this->scopes = $scopes; + } + + public function getOverride( + string $scopeKey, + string $property, + ): mixed { + return $this->scopes[$scopeKey][$property] ?? null; + } + + /** + * @return array> + */ + public function allOverrides(): array + { + return $this->scopes ?? []; + } + + public function hasOverride( + string $scopeKey, + string $property, + ): bool { + return array_key_exists($scopeKey, $this->scopes ?? []) + && array_key_exists($property, $this->scopes[$scopeKey]); + } + + public function clearOverride( + string $scopeKey, + string $property, + ): void { + if (!isset($this->scopes[$scopeKey])) { + return; + } + + $scopes = $this->scopes; + unset($scopes[$scopeKey][$property]); + + if ($scopes[$scopeKey] === []) { + unset($scopes[$scopeKey]); + } + + ksort($scopes); + $this->scopes = $scopes ?: null; + } +} diff --git a/packages/scope/src/Storage/HasScopesInterface.php b/packages/scope/src/Storage/HasScopesInterface.php new file mode 100644 index 00000000..6f7e5605 --- /dev/null +++ b/packages/scope/src/Storage/HasScopesInterface.php @@ -0,0 +1,34 @@ +> + */ + public function allOverrides(): array; +} diff --git a/packages/scope/src/Storage/ScopedOverridesEntity.php b/packages/scope/src/Storage/ScopedOverridesEntity.php index 69063115..c3dade50 100644 --- a/packages/scope/src/Storage/ScopedOverridesEntity.php +++ b/packages/scope/src/Storage/ScopedOverridesEntity.php @@ -7,7 +7,7 @@ use Marko\Database\Attributes\Column; use Marko\Database\Entity\Entity; -abstract class ScopedOverridesEntity extends Entity +abstract class ScopedOverridesEntity extends Entity implements HasScopesInterface { #[Column(name: 'scopes', type: 'json', nullable: true)] public ?array $scopes = null; @@ -16,8 +16,7 @@ public function setOverride( string $scopeKey, string $property, mixed $value, - ): void - { + ): void { $scopes = $this->scopes ?? []; $scopes[$scopeKey][$property] = $value; ksort($scopes[$scopeKey]); @@ -28,8 +27,7 @@ public function setOverride( public function getOverride( string $scopeKey, string $property, - ): mixed - { + ): mixed { return $this->scopes[$scopeKey][$property] ?? null; } @@ -44,8 +42,7 @@ public function allOverrides(): array public function hasOverride( string $scopeKey, string $property, - ): bool - { + ): bool { return array_key_exists($scopeKey, $this->scopes ?? []) && array_key_exists($property, $this->scopes[$scopeKey]); } @@ -53,8 +50,7 @@ public function hasOverride( public function clearOverride( string $scopeKey, string $property, - ): void - { + ): void { if (!isset($this->scopes[$scopeKey])) { return; } diff --git a/packages/scope/src/Validation/ScopedEntityValidator.php b/packages/scope/src/Validation/ScopedEntityValidator.php index c5c3c447..31c0af1f 100644 --- a/packages/scope/src/Validation/ScopedEntityValidator.php +++ b/packages/scope/src/Validation/ScopedEntityValidator.php @@ -10,6 +10,7 @@ use Marko\Scope\Exceptions\ScopeConfigurationException; use Marko\Scope\Exceptions\UnknownAxisException; use Marko\Scope\Metadata\ScopeMetadataFactory; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; /** @@ -38,6 +39,21 @@ public function validate(string $entityClass): void return; } + if (is_a($entityClass, HasScopesInterface::class, true)) { + $entityMetadata = $this->entityMetadataFactory->parse($entityClass); + + $conflictingExtender = array_find( + $entityMetadata->extenders, + fn (string $extender) => is_subclass_of($extender, ScopedOverridesEntity::class), + ); + + if ($conflictingExtender !== null) { + throw ScopeConfigurationException::traitAndCompanionConflict($entityClass, $conflictingExtender); + } + + return; + } + $entityMetadata = $this->entityMetadataFactory->parse($entityClass); $extenders = $entityMetadata->extenders; @@ -46,13 +62,10 @@ public function validate(string $entityClass): void $scopedProperties[$property] = $scopeMetadata->axesForProperty($property); } - $hasValidExtender = false; - foreach ($extenders as $extender) { - if (is_subclass_of($extender, ScopedOverridesEntity::class)) { - $hasValidExtender = true; - break; - } - } + $hasValidExtender = array_any( + $extenders, + fn (string $extender) => is_subclass_of($extender, ScopedOverridesEntity::class), + ); if (count($extenders) === 0 || !$hasValidExtender) { if (count($extenders) > 0) { diff --git a/packages/scope/tests/Unit/ReadmeTest.php b/packages/scope/tests/Unit/ReadmeTest.php index 68d51268..afcd7c84 100644 --- a/packages/scope/tests/Unit/ReadmeTest.php +++ b/packages/scope/tests/Unit/ReadmeTest.php @@ -50,3 +50,39 @@ expect($content)->toContain('## Documentation'); }); + +it('documents HasScopes trait as the primary storage option', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('HasScopes') + ->and($content)->toContain('HasScopesInterface') + ->and($content)->toContain('use HasScopes'); +}); + +it('documents the companion-class approach as an alternative', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('Alternative') + ->and($content)->toContain('ScopedOverridesEntity'); +}); + +it('warns against using HasScopes trait and ScopedOverridesEntity extender on the same entity', function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('ScopeConfigurationException') + ->and($content)->toContain('traitAndCompanionConflict'); +}); + +it( + 'documents that trait-based entities are compatible with Repository::insertBatch while companion-based entities are not', + function (): void { + $readmePath = dirname(__DIR__, 2) . '/README.md'; + $content = file_get_contents($readmePath); + + expect($content)->toContain('insertBatch') + ->and($content)->toContain('BatchInsertException'); + }, +); diff --git a/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php index 4c1ab363..11bb6cc5 100644 --- a/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php +++ b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php @@ -2,17 +2,33 @@ declare(strict_types=1); +use Marko\Database\Attributes\Column; +use Marko\Database\Attributes\Table; +use Marko\Database\Entity\Entity; use Marko\Scope\Axis\ScopeAxis; use Marko\Scope\Context\ScopeContext; use Marko\Scope\Exceptions\UnknownAxisException; use Marko\Scope\Hierarchy\ScopeHierarchy; use Marko\Scope\Registry\ScopeRegistryInterface; use Marko\Scope\Resolution\ScopeWalker; +use Marko\Scope\Scope; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; // Concrete anonymous-style subclass for tests class WalkerTestOverrides extends ScopedOverridesEntity {} +// Trait-based fixture — does NOT extend ScopedOverridesEntity +#[Table(name: 'walker_trait_products')] +class WalkerTraitProduct extends Entity implements HasScopesInterface +{ + use HasScopes; + + #[Column(primaryKey: true, autoIncrement: true)] + public ?int $id = null; +} + function makeWalkerRegistry(array $axes = []): ScopeRegistryInterface { return new class ($axes) implements ScopeRegistryInterface @@ -171,18 +187,18 @@ function (): void { ]); $context = new ScopeContext($registry); $context->in('geo', 'eu.de')->in('locale', 'de.formal'); - + $overrides = new WalkerTestOverrides(); // No override for 'name' under geo; only under locale ancestor - $overrides->setOverride('locale:de', 'name', 'Hallo'); - + $overrides->setOverride('locale:de', 'name', 'Hallo'); + $walker = new ScopeWalker(); // geo first — no match for 'name' anywhere in geo; should fall through to locale - $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); - + $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); + expect($result->isFound())->toBeTrue() ->and($result->value())->toBe('Hallo'); - } + }, ); it( @@ -194,19 +210,19 @@ function (): void { ]); $context = new ScopeContext($registry); $context->in('geo', 'eu.de')->in('locale', 'de.formal'); - + $overrides = new WalkerTestOverrides(); // geo has an explicit null — should count as "found" and stop cross-axis fallthrough - $overrides->setOverride('geo:eu.de', 'name', null); + $overrides->setOverride('geo:eu.de', 'name', null); // locale has a real value — but should NOT be reached - $overrides->setOverride('locale:de', 'name', 'Hallo'); - + $overrides->setOverride('locale:de', 'name', 'Hallo'); + $walker = new ScopeWalker(); $result = $walker->walk($overrides, 'name', ['geo', 'locale'], $context, $registry); - + expect($result->isFound())->toBeTrue() ->and($result->value())->toBeNull(); - } + }, ); it('returns notFound when no axes are declared and no overrides exist', function (): void { @@ -220,3 +236,76 @@ function (): void { expect($result->isFound())->toBeFalse(); }); + +it('resolves an override via walkAt when passed a HasScopesInterface implementor', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $scope = new Scope(axisName: 'geo', path: 'eu.de'); + + $overrides = new WalkerTraitProduct(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $walker = new ScopeWalker(); + $result = $walker->walkAt($overrides, 'name', ['geo'], $scope, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hemd'); +}); + +it('walks hierarchy ancestors when the exact scope path has no override', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $scope = new Scope(axisName: 'geo', path: 'eu.de'); + + $overrides = new WalkerTraitProduct(); + // Only set override on ancestor 'eu', not on 'eu.de' + $overrides->setOverride('geo:eu', 'name', 'Shirt-EU'); + + $walker = new ScopeWalker(); + $result = $walker->walkAt($overrides, 'name', ['geo'], $scope, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Shirt-EU'); +}); + +it('returns notFound via walkAt when the axis does not match', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $scope = new Scope(axisName: 'locale', path: 'de'); + + $overrides = new WalkerTraitProduct(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $walker = new ScopeWalker(); + $result = $walker->walkAt($overrides, 'name', ['geo'], $scope, $registry); + + expect($result->isFound())->toBeFalse(); +}); + +it('returns notFound via walk when the HasScopesInterface implementor has no matching override', function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTraitProduct(); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + expect($result->isFound())->toBeFalse(); +}); + +it( + 'resolves an override via walk when passed a HasScopesInterface implementor that is not ScopedOverridesEntity', + function (): void { + $registry = makeWalkerRegistry(['geo' => ['eu', 'eu.de']]); + $context = new ScopeContext($registry); + $context->in('geo', 'eu.de'); + + $overrides = new WalkerTraitProduct(); + $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); + + $walker = new ScopeWalker(); + $result = $walker->walk($overrides, 'name', ['geo'], $context, $registry); + + expect($result->isFound())->toBeTrue() + ->and($result->value())->toBe('Hemd'); + }, +); diff --git a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php index f5f392e7..b08e1f1d 100644 --- a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php +++ b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php @@ -16,6 +16,8 @@ use Marko\Scope\Resolution\ScopeWalker; use Marko\Scope\Resolver\ScopeResolver; use Marko\Scope\Scope; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -37,6 +39,22 @@ class ResolverProduct extends Entity #[Table(extends: ResolverProduct::class)] class ResolverProductOverrides extends ScopedOverridesEntity {} +#[Table(name: 'trait_resolver_products')] +class TraitResolverProduct extends Entity implements HasScopesInterface +{ + use HasScopes; + + #[Column(primaryKey: true, autoIncrement: true)] + public ?int $id = null; + + #[Scoped(axes: ['store'])] + #[Column] + public string $name = 'default-name'; + + #[Column] + public string $sku = 'default-sku'; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── function makeResolverRegistry(array $axes = ['store' => ['global', 'global.us']]): ScopeRegistryInterface @@ -183,19 +201,19 @@ function makeResolver(): array 'discovers the correct ScopedOverridesEntity subclass for a given parent entity class via EntityMetadata::extenders', function (): void { [$resolver] = makeResolver(); - + $product = new ResolverProduct(); // No companion exists; resolver must discover ResolverProductOverrides via extenders $scope = new Scope('store', 'global'); $resolver->setOverride($product, 'name', 'Global Name', $scope); - + // The companion created should be the correct subclass - $companion = $product->companion(ResolverProductOverrides::class); - + $companion = $product->companion(ResolverProductOverrides::class); + expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) ->and($companion->getOverride('store:global', 'name'))->toBe('Global Name'); - } + }, ); it('throws ScopeContextException when setOverride targets a property without Scoped', function (): void { @@ -233,3 +251,161 @@ function (): void { expect($result)->toBe('base-name'); }); + +it('resolves a scoped value when the entity itself implements HasScopesInterface', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new TraitResolverProduct(); + $product->setOverride('store:global.us', 'name', 'Trait US Name'); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('Trait US Name'); +}); + +it('falls back to the column value when entity implements HasScopesInterface but has no override', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new TraitResolverProduct(); + $product->name = 'base-trait-name'; + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('base-trait-name'); +}); + +it('resolves a scoped value when a ScopedOverridesEntity companion is attached (backward compat)', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new ResolverProduct(); + $overrides = new ResolverProductOverrides(); + $overrides->setOverride('store:global.us', 'name', 'Companion Name'); + $product->attachCompanion($overrides); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('Companion Name'); +}); + +it( + 'sets an override directly on the entity when it implements HasScopesInterface and no companion is attached or created', + function (): void { + [$resolver] = makeResolver(); + + $product = new TraitResolverProduct(); + + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'Direct Override', $scope); + + expect($product->getOverride('store:global.us', 'name'))->toBe('Direct Override') + ->and($product->companions())->toBeEmpty(); + }, +); + +it('clears an override directly on the entity when it implements HasScopesInterface', function (): void { + [$resolver] = makeResolver(); + + $product = new TraitResolverProduct(); + $product->setOverride('store:global.us', 'name', 'To Be Cleared'); + + $scope = new Scope('store', 'global.us'); + $resolver->clearOverride($product, 'name', $scope); + + expect($product->hasOverride('store:global.us', 'name'))->toBeFalse(); +}); + +it('silently no-ops when clearOverride is called on a trait-based entity that has no overrides yet', function (): void { + [$resolver] = makeResolver(); + + $product = new TraitResolverProduct(); + + $scope = new Scope('store', 'global.us'); + + // Should not throw + $resolver->clearOverride($product, 'name', $scope); + + expect($product->scopes)->toBeNull(); +}); + +it( + 'throws ScopeContextException when setOverride is called on an entity that does not implement HasScopesInterface and has no companion registered as an extender', + function (): void { + // Build a resolver with NO extenders registered for ResolverProduct + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $entityMetaFactory = new EntityMetadataFactory(); + // No linkExtenders call — no companion available + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context, $entityMetaFactory); + + $product = new ResolverProduct(); + $scope = new Scope('store', 'global.us'); + + expect(fn () => $resolver->setOverride($product, 'name', 'Name', $scope)) + ->toThrow(ScopeContextException::class); + }, +); + +it('resolvedAt returns the correct value for an explicit scope on a trait-based entity', function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new TraitResolverProduct(); + $product->setOverride('store:global', 'name', 'Global Name'); + $product->setOverride('store:global.us', 'name', 'US Name'); + + $scope = new Scope('store', 'global'); + $result = $resolver->resolvedAt($product, 'name', $scope); + + expect($result)->toBe('Global Name'); +}); + +it( + 'returns the column value when resolvedAt finds no override for the given scope on a trait-based entity', + function (): void { + [$resolver] = makeResolver(); + + $product = new TraitResolverProduct(); + $product->name = 'column-value'; + + $scope = new Scope('store', 'global.us'); + $result = $resolver->resolvedAt($product, 'name', $scope); + + expect($result)->toBe('column-value'); + }, +); + +it( + 'prefers the entity itself over any attached companion when both implement HasScopesInterface (entity-self wins ordering)', + function (): void { + [$resolver, $context] = makeResolver(); + $context->in('store', 'global.us'); + + $product = new TraitResolverProduct(); + $product->setOverride('store:global.us', 'name', 'Entity Override'); + + // Attach a companion that also has an override — entity should win + $companion = new ResolverProductOverrides(); + $companion->setOverride('store:global.us', 'name', 'Companion Override'); + $product->attachCompanion($companion); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('Entity Override'); + }, +); + +it('does not create or attach a companion when setOverride is called on a trait-based entity', function (): void { + [$resolver] = makeResolver(); + + $product = new TraitResolverProduct(); + + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'Direct', $scope); + + expect($product->companions())->toBeEmpty(); +}); diff --git a/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php new file mode 100644 index 00000000..24d41423 --- /dev/null +++ b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php @@ -0,0 +1,123 @@ +toBeTrue() + ->and(method_exists($entity, 'getOverride'))->toBeTrue() + ->and(method_exists($entity, 'hasOverride'))->toBeTrue() + ->and(method_exists($entity, 'clearOverride'))->toBeTrue() + ->and(method_exists($entity, 'allOverrides'))->toBeTrue(); +}); + +it('stores multiple overrides keyed by scopeKey and property', function (): void { + $entity = new TraitProduct(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->setOverride('locale:de', 'name', 'Hallo'); + $entity->setOverride('geo:eu.de', 'price', 19.99); + + expect($entity->allOverrides())->toBe([ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], + ]); +}); + +it('returns null for unknown scopeKey or property via getOverride', function (): void { + $entity = new TraitProduct(); + + expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() + ->and($entity->getOverride('unknown', 'price'))->toBeNull(); +}); + +it('returns false for hasOverride when no override exists', function (): void { + $entity = new TraitProduct(); + + expect($entity->hasOverride('geo:eu.de', 'name'))->toBeFalse(); +}); + +it('distinguishes an explicit null override from no override via hasOverride', function (): void { + $entity = new TraitProduct(); + + expect($entity->hasOverride('geo:eu.de', 'name'))->toBeFalse(); + + $entity->setOverride('geo:eu.de', 'name', null); + + expect($entity->hasOverride('geo:eu.de', 'name'))->toBeTrue() + ->and($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); +}); + +it('clears a single property override leaving others intact', function (): void { + $entity = new TraitProduct(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->setOverride('geo:eu.de', 'price', 19.99); + $entity->clearOverride('geo:eu.de', 'name'); + + expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() + ->and($entity->getOverride('geo:eu.de', 'price'))->toBe(19.99); +}); + +it('sets scopes to null when the last override is cleared', function (): void { + $entity = new TraitProduct(); + $entity->setOverride('geo:eu.de', 'name', 'Hemd'); + $entity->clearOverride('geo:eu.de', 'name'); + + expect($entity->scopes)->toBeNull(); +}); + +it( + 'declares a json nullable Column attribute on the scopes property when reflected via a consuming class', + function (): void { + $reflection = new ReflectionClass(TraitProduct::class); + + expect($reflection->hasProperty('scopes'))->toBeTrue(); + + $property = $reflection->getProperty('scopes'); + $attributes = $property->getAttributes(Column::class); + + expect($attributes)->toHaveCount(1); + + $column = $attributes[0]->newInstance(); + + expect($column->name)->toBe('scopes') + ->and($column->type)->toBe('json') + ->and($column->nullable)->toBeTrue(); + }, +); + +it('a class using HasScopes can satisfy the HasScopesInterface contract', function (): void { + $entity = new TraitProduct(); + + expect($entity)->toBeInstanceOf(HasScopesInterface::class); +}); + +it('HasScopesInterface declares the override methods', function (): void { + $reflection = new ReflectionClass(HasScopesInterface::class); + + expect($reflection->isInterface())->toBeTrue() + ->and($reflection->hasMethod('setOverride'))->toBeTrue() + ->and($reflection->hasMethod('getOverride'))->toBeTrue() + ->and($reflection->hasMethod('hasOverride'))->toBeTrue() + ->and($reflection->hasMethod('clearOverride'))->toBeTrue() + ->and($reflection->hasMethod('allOverrides'))->toBeTrue(); +}); diff --git a/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php b/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php index d0c8c2ac..b23a595d 100644 --- a/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php +++ b/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php @@ -4,6 +4,7 @@ use Marko\Database\Attributes\Column; use Marko\Database\Entity\Entity; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; // Concrete subclass used for all unit tests @@ -81,15 +82,15 @@ function (): void { $parent = new ConcreteParentEntity(); $overrides = new ConcreteOverrides(); $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); - + $parent->attachCompanion($overrides); - + $retrieved = $parent->companion(ConcreteOverrides::class); - + expect($retrieved)->toBeInstanceOf(ConcreteOverrides::class) ->and($retrieved)->toBe($overrides) ->and($retrieved->getOverride('geo:eu.de', 'name'))->toBe('Hemd'); - } + }, ); it('lists all overrides via allOverrides as the flat scope-key-first map', function (): void { @@ -104,6 +105,22 @@ function (): void { ]); }); +it( + 'ScopedOverridesEntity implements HasScopesInterface and its public method signatures match the interface', + function (): void { + $entity = new ConcreteOverrides(); + + expect($entity)->toBeInstanceOf(HasScopesInterface::class); + + $reflection = new ReflectionClass(ScopedOverridesEntity::class); + $interface = new ReflectionClass(HasScopesInterface::class); + + foreach ($interface->getMethods() as $interfaceMethod) { + expect($reflection->hasMethod($interfaceMethod->getName()))->toBeTrue(); + } + }, +); + it('distinguishes an explicit null override from no override via hasOverride', function (): void { $entity = new ConcreteOverrides(); diff --git a/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php index d92507a6..ea609869 100644 --- a/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php +++ b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php @@ -12,6 +12,8 @@ use Marko\Scope\Hierarchy\ScopeHierarchy; use Marko\Scope\Metadata\ScopeMetadataFactory; use Marko\Scope\Registry\ScopeRegistryInterface; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; use Marko\Scope\Storage\ScopedOverridesEntity; use Marko\Scope\Validation\ScopedEntityValidator; @@ -63,6 +65,22 @@ class ValidatorOtherProductOverridesWrongBase extends Entity public string $extra = ''; } +#[Table(name: 'trait_scoped_products')] +class ValidatorTraitScopedProduct extends Entity implements HasScopesInterface +{ + use HasScopes; + + #[Column(primaryKey: true, autoIncrement: true)] + public ?int $id = null; + + #[Scoped(axes: ['store'])] + #[Column] + public string $name = ''; +} + +#[Table(extends: ValidatorTraitScopedProduct::class)] +class ValidatorTraitScopedProductOverrides extends ScopedOverridesEntity {} + // ─── Helpers ───────────────────────────────────────────────────────────────── function makeValidatorRegistry(): ScopeRegistryInterface @@ -108,9 +126,9 @@ function (): void { $entityFactory = new EntityMetadataFactory(); $entityFactory->linkExtenders(ValidatorScopedProduct::class, [ValidatorScopedProductOverrides::class]); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - + expect(fn () => $validator->validate(ValidatorScopedProduct::class))->not->toThrow(Throwable::class); - } + }, ); it( @@ -119,10 +137,10 @@ function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - + expect(fn () => $validator->validate(ValidatorScopedProduct::class)) ->toThrow(ScopeConfigurationException::class); - } + }, ); it( @@ -132,10 +150,10 @@ function (): void { $entityFactory = new EntityMetadataFactory(); $entityFactory->linkExtenders(ValidatorOtherProduct::class, [ValidatorOtherProductOverridesWrongBase::class]); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - + expect(fn () => $validator->validate(ValidatorOtherProduct::class)) ->toThrow(ScopeConfigurationException::class); - } + }, ); it('includes the parent entity FQCN in the exception message', function (): void { @@ -173,7 +191,7 @@ function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - + try { $validator->validate(ValidatorScopedProduct::class); expect(false)->toBeTrue('Expected exception was not thrown'); @@ -182,5 +200,116 @@ function (): void { ->and($e->getSuggestion())->toContain(ValidatorScopedProduct::class . '::class') ->and($e->getSuggestion())->toContain('ScopedOverridesEntity'); } + }, +); + +it( + 'the traitAndCompanionConflict exception message names both the parent class and the conflicting extender class', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders( + ValidatorTraitScopedProduct::class, + [ValidatorTraitScopedProductOverrides::class], + ); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + try { + $validator->validate(ValidatorTraitScopedProduct::class); + expect(false)->toBeTrue('Expected exception was not thrown'); + } catch (ScopeConfigurationException $e) { + expect($e->getMessage())->toContain(ValidatorTraitScopedProduct::class) + ->and($e->getMessage())->toContain(ValidatorTraitScopedProductOverrides::class); + } + }, +); + +it( + 'throws ScopeConfigurationException via traitAndCompanionConflict when entity uses HasScopes trait AND has a ScopedOverridesEntity extender registered', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders( + ValidatorTraitScopedProduct::class, + [ValidatorTraitScopedProductOverrides::class], + ); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorTraitScopedProduct::class)) + ->toThrow(ScopeConfigurationException::class); + }, +); + +it( + 'throws ScopeConfigurationException with wrongOverridesExtenderBase when extender exists but is not ScopedOverridesEntity', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders(ValidatorOtherProduct::class, [ValidatorOtherProductOverridesWrongBase::class]); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorOtherProduct::class)) + ->toThrow(ScopeConfigurationException::class); + }, +); + +it( + 'throws ScopeConfigurationException when entity has scoped properties but neither trait nor companion', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorScopedProduct::class)) + ->toThrow(ScopeConfigurationException::class); + }, +); + +it('passes validation when the entity has no scoped properties at all', function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + $threw = false; + try { + $validator->validate(ValidatorPlainProduct::class); + } catch (Throwable) { + $threw = true; } + + expect($threw)->toBeFalse(); +}); + +it('passes validation when the entity has a ScopedOverridesEntity companion (backward compat)', function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders(ValidatorScopedProduct::class, [ValidatorScopedProductOverrides::class]); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + $threw = false; + try { + $validator->validate(ValidatorScopedProduct::class); + } catch (Throwable) { + $threw = true; + } + + expect($threw)->toBeFalse(); +}); + +it( + 'passes validation when the entity class implements HasScopesInterface and has scoped properties', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + $threw = false; + try { + $validator->validate(ValidatorTraitScopedProduct::class); + } catch (Throwable) { + $threw = true; + } + + expect($threw)->toBeFalse(); + }, ); From 55ac5b69a6766b1725d110015907111c1fb89d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Wed, 13 May 2026 23:46:10 +0200 Subject: [PATCH 07/13] =?UTF-8?q?feat(scope):=20remove=20ScopedOverridesEn?= =?UTF-8?q?tity=20=E2=80=94=20single=20HasScopes=20approach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates scope storage on HasScopes trait + HasScopesInterface. Removes the ScopedOverridesEntity companion base class, ScopeResolver::createCompanion(), and the two-approach framing throughout. ScopedEntityValidator and ScopeResolver now accept any HasScopesInterface implementor (entity-self or manually-declared companion). Driver auto-migration tests updated to use the manual companion pattern. Co-Authored-By: Claude Sonnet 4.6 --- .../038-remove-scoped-overrides-entity.md | 85 +++++++ .claude/plans/scope/_plan.md | 1 + docs/src/content/docs/packages/scope-mysql.md | 26 +- docs/src/content/docs/packages/scope-pgsql.md | 26 +- docs/src/content/docs/packages/scope.md | 41 +--- .../tests/Feature/AutoMigrationTest.php | 8 +- .../tests/Feature/AutoMigrationTest.php | 8 +- packages/scope/README.md | 102 +------- .../ScopeConfigurationException.php | 47 +--- packages/scope/src/Resolver/ScopeResolver.php | 39 +-- .../src/Storage/ScopedOverridesEntity.php | 68 ------ .../src/Validation/ScopedEntityValidator.php | 39 +-- ...ScopedOverridesEntityDirtyTrackingTest.php | 16 +- .../ScopedOverridesPersistenceTest.php | 25 +- packages/scope/tests/Unit/ReadmeTest.php | 29 +-- .../tests/Unit/Resolution/ScopeWalkerTest.php | 13 +- .../tests/Unit/Resolver/ScopeResolverTest.php | 195 +++++++-------- .../Storage/ScopedOverridesEntityTest.php | 133 ----------- .../Validation/ScopedEntityValidatorTest.php | 225 ++++-------------- 19 files changed, 302 insertions(+), 824 deletions(-) create mode 100644 .claude/plans/scope/038-remove-scoped-overrides-entity.md delete mode 100644 packages/scope/src/Storage/ScopedOverridesEntity.php delete mode 100644 packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php diff --git a/.claude/plans/scope/038-remove-scoped-overrides-entity.md b/.claude/plans/scope/038-remove-scoped-overrides-entity.md new file mode 100644 index 00000000..cc8eea79 --- /dev/null +++ b/.claude/plans/scope/038-remove-scoped-overrides-entity.md @@ -0,0 +1,85 @@ +# Task 038: Remove `ScopedOverridesEntity` — Single-Approach Cleanup + +**Status**: completed +**Depends on**: [033, 034, 035, 036, 037] +**Retry count**: 0 + +## Description +Delete `ScopedOverridesEntity` entirely and consolidate on `HasScopes` + `HasScopesInterface` as the sole scope storage mechanism. Companion classes (for third-party entities) are still supported — users declare them manually using the trait. `ScopeResolver::createCompanion()` is removed; `ScopedEntityValidator` generalises to accept any `HasScopesInterface` implementor (entity-self or companion) rather than requiring a `ScopedOverridesEntity` subclass specifically. + +## Context + +### Why removing `ScopedOverridesEntity` is safe +- `HasScopes` trait provides identical behaviour — every project that used `ScopedOverridesEntity` as a companion base can replace it with a plain class that `extends Entity`, `use HasScopes`, `implements HasScopesInterface`. +- The companion lookup in `ScopeResolver::findStorage()` already uses `instanceof HasScopesInterface`, not `instanceof ScopedOverridesEntity` — no resolver logic changes for the happy path. +- Companion use case (adding scopes to a third-party entity) is preserved; users just write two more lines. + +### Changes required + +**Delete:** +- `packages/scope/src/Storage/ScopedOverridesEntity.php` +- All tests referencing `ScopedOverridesEntity` as a concrete class to instantiate (replace fixtures with trait-based equivalents or plain companions using the trait) +- `packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php` — test for a class that no longer exists; delete the file + +**`packages/scope/src/Resolver/ScopeResolver.php`:** +- Remove `createCompanion()` method entirely +- Update `setOverride()`: when `findStorage()` returns `null`, throw `ScopeContextException` immediately (no companion auto-creation). The error message should say the entity must implement `HasScopesInterface` or have a companion that does. +- Remove the `use Marko\Scope\Storage\ScopedOverridesEntity;` import (was kept only for `createCompanion()`) + +**`packages/scope/src/Validation/ScopedEntityValidator.php`:** +- Remove the `wrongOverridesExtenderBase` path — it was specific to detecting non-`ScopedOverridesEntity` companion bases, which no longer matters +- Remove the `traitAndCompanionConflict` path — the conflict only arose when both the trait AND a `ScopedOverridesEntity` extender contributed a `scopes` column; without the class, the conflict is impossible +- New validation logic (3 branches, in order): + 1. Entity has no `#[Scoped]` properties → return (valid, unchanged) + 2. `is_a($entityClass, HasScopesInterface::class, true)` → return (valid — entity is its own storage) + 3. Entity has at least one registered companion that `implements HasScopesInterface` → return (valid — companion provides storage) + 4. Otherwise → throw `ScopeConfigurationException::missingScopesStorage($entityClass)` (see below) +- Remove the `use Marko\Scope\Storage\ScopedOverridesEntity;` import + +**`packages/scope/src/Exceptions/ScopeConfigurationException.php`:** +- Remove `wrongOverridesExtenderBase()` factory (no longer reachable) +- Remove `traitAndCompanionConflict()` factory (no longer reachable) +- Rename `missingOverridesExtender()` → `missingScopesStorage()` with an updated message: `"Entity '{$entityClass}' has #[Scoped] properties but provides no scope storage. Add 'use HasScopes; implements HasScopesInterface;' to the entity, or register a companion class that implements HasScopesInterface."` +- Keep `message`, `context`, `suggestion` named-parameter pattern + +**`packages/scope/README.md`:** +- Remove "Alternative: companion class" section that references `ScopedOverridesEntity` +- Add a replacement section "Companion class (for third-party entities)" showing the manual pattern: + ```php + #[Table(extends: Product::class)] + class ProductScopedOverrides extends Entity implements HasScopesInterface + { + use HasScopes; + } + ``` +- Update "Don't mix them" warning: mixing is now impossible (no `ScopedOverridesEntity`); replace with a note that a companion providing `HasScopesInterface` and the entity itself implementing `HasScopesInterface` is valid but the entity-self path wins +- Update "Batch insert" note: trait-based entities are compatible; companion-based entities are not + +**Docs pages** (`docs/src/content/docs/packages/scope.md`, `scope-mysql.md`, `scope-pgsql.md`): update to reflect removal. + +### Backwards compatibility note +This is a breaking change for anyone who subclassed `ScopedOverridesEntity`. Migration is mechanical: replace `extends ScopedOverridesEntity` with `extends Entity implements HasScopesInterface { use HasScopes; }`. Since the project is pre-1.0, this is acceptable. + +## Requirements (Test Descriptions) +- [ ] `it accepts a trait-based entity as valid scopes storage (HasScopesInterface on entity)` +- [ ] `it accepts a companion that implements HasScopesInterface as valid scopes storage` +- [ ] `it accepts an entity with no scoped properties regardless of storage` +- [ ] `it throws ScopeConfigurationException missingScopesStorage when entity has scoped properties but no storage` +- [ ] `the missingScopesStorage exception message names the entity class and describes both remedies` +- [ ] `ScopeResolver setOverride throws ScopeContextException when entity has no HasScopesInterface and no compatible companion` +- [ ] `ScopeResolver setOverride works on a trait-based entity` +- [ ] `ScopeResolver setOverride works when a manual HasScopesInterface companion is attached` +- [ ] `ScopeResolver resolved works with a manual HasScopesInterface companion (not ScopedOverridesEntity)` +- [ ] `ScopeResolver does not have a createCompanion method` + +## Acceptance Criteria +- `ScopedOverridesEntity.php` deleted; `ScopedOverridesEntityTest.php` deleted +- No reference to `ScopedOverridesEntity` remains in `src/` or `tests/` +- `createCompanion()` removed from `ScopeResolver` +- `ScopedEntityValidator` passes for `HasScopesInterface` entity and for any `HasScopesInterface` companion +- `ScopeConfigurationException` has `missingScopesStorage()` instead of `missingOverridesExtender()`, `wrongOverridesExtenderBase()`, `traitAndCompanionConflict()` +- All tests passing +- README and docs updated + +## Implementation Notes +(Left blank - filled in by programmer during implementation) diff --git a/.claude/plans/scope/_plan.md b/.claude/plans/scope/_plan.md index c871a63a..d174c326 100644 --- a/.claude/plans/scope/_plan.md +++ b/.claude/plans/scope/_plan.md @@ -108,6 +108,7 @@ none | 035 | Update `ScopeResolver` for trait-based entities | 033, 034 | completed | | 036 | Update `ScopedEntityValidator` for trait-based entities | 033 | completed | | 037 | Update `marko/scope` README for `HasScopes` trait | 033, 034, 035, 036 | completed | +| 038 | Remove `ScopedOverridesEntity` — single-approach cleanup | 033, 034, 035, 036, 037 | completed | ## Architecture Notes diff --git a/docs/src/content/docs/packages/scope-mysql.md b/docs/src/content/docs/packages/scope-mysql.md index 1358797a..52948531 100644 --- a/docs/src/content/docs/packages/scope-mysql.md +++ b/docs/src/content/docs/packages/scope-mysql.md @@ -3,7 +3,7 @@ title: marko/scope-mysql description: MySQL and MariaDB driver for marko/scope — scoped ORDER BY and automatic scopes column migration. --- -MySQL and MariaDB driver for `marko/scope` --- enables scoped `ORDER BY` queries and automatic `scopes` column migration. The package provides `MySqlScopeSortRenderer`, which emits `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` expressions for scope-aware sorting. The `scopes` JSON column is added automatically --- either via the `HasScopes` trait on the entity itself, or via a `ScopedOverridesEntity` companion class when the entity cannot be modified. No separate migration helper is needed in either case. Requires MariaDB 10.3+ or MySQL 8.0+. +MySQL and MariaDB driver for `marko/scope` --- enables scoped `ORDER BY` queries and automatic `scopes` column migration. The package provides `MySqlScopeSortRenderer`, which emits `COALESCE(JSON_UNQUOTE(JSON_EXTRACT(...)), column)` expressions for scope-aware sorting. The `scopes` JSON column is declared by the `HasScopes` trait on the entity; no separate migration helper is needed. Requires MariaDB 10.3+ or MySQL 8.0+. ## Installation @@ -17,9 +17,7 @@ This automatically installs `marko/scope` as a transitive dependency. ### Adding the `scopes` column -There are two ways to get the `scopes` JSON column into your entity's table. - -**Option 1 --- `HasScopes` trait (recommended).** Implement `HasScopesInterface` and use the `HasScopes` trait on the entity. The trait declares the column directly; no companion class is needed: +Implement `HasScopesInterface` and use the `HasScopes` trait on the entity. The trait declares the `scopes` JSON column directly; no companion class is needed: ```php title="app/catalog/Entity/Product.php" clearOverride($product, 'price', new Scope('market', 'eu.de')); $productRepository->save($product); ``` -### Mixing storage approaches - -Do **not** use both `use HasScopes` and a `ScopedOverridesEntity` extender on the same entity. The boot-time validator raises `ScopeConfigurationException::traitAndCompanionConflict()` if both are detected. If validation is bypassed, the schema build will fail with a duplicate-column error. - ## Customization ### DB-driven scope registry @@ -309,7 +277,6 @@ return [ | `Marko\Scope\Resolver\ScopeResolver` | Resolves scoped values by walking the active context hierarchy; also writes and clears overrides | | `Marko\Scope\Storage\HasScopesInterface` | Interface for entities that store scoped overrides directly via the `HasScopes` trait | | `Marko\Scope\Storage\HasScopes` | Trait that adds a `$scopes` JSON column to the entity and implements `HasScopesInterface` | -| `Marko\Scope\Storage\ScopedOverridesEntity` | Abstract companion entity holding the JSON `scopes` column (alternative to `HasScopes`) | | `Marko\Scope\Query\ScopedOrderBy` | `QuerySpecification` that orders by resolved scope value | | `Marko\Scope\Query\ScopedOrderByFactory` | Factory for building `ScopedOrderBy` specifications | | `Marko\Scope\Query\ScopeSortRendererInterface` | Interface implemented by driver packages to emit DB-specific `COALESCE` expressions | @@ -333,7 +300,7 @@ return [ |--------|-------------| | `resolved(Entity $entity, string $property): mixed` | Walk the active context hierarchy and return the most specific override, falling back to the column value. | | `resolvedAt(Entity $entity, string $property, Scope $scope): mixed` | Resolve at a specific scope regardless of the active context. | -| `setOverride(Entity $entity, string $property, mixed $value, Scope $scope): void` | Attach a scoped value to the entity. For `HasScopesInterface` entities writes directly to `$scopes`; for companion-based entities creates the companion if one does not exist yet. | +| `setOverride(Entity $entity, string $property, mixed $value, Scope $scope): void` | Attach a scoped value to the entity. The entity or one of its companions must implement `HasScopesInterface`. | | `clearOverride(Entity $entity, string $property, Scope $scope): void` | Remove a scoped override from the entity. | ### `ScopedOrderByFactory` @@ -356,8 +323,6 @@ return [ **`ScopeContext` is a mutable singleton.** It holds active paths for the entire PHP process lifetime. In long-running processes (FPM workers, queue daemons, ReactPHP servers), the bootstrap layer must call `$scopeContext->clearAll()` between requests or jobs to prevent cross-request scope leakage. -**`Repository::insertBatch` support depends on the storage approach.** Trait-based entities (`use HasScopes`) store overrides directly on the entity and are fully compatible with `insertBatch()`. Companion-based entities (`ScopedOverridesEntity` extender) are **not** compatible --- batch inserts bypass the companion lifecycle and will throw `BatchInsertException::companionsNotSupported()`. Use individual `save()` calls for companion-based scoped entities. - **Terminology overlap with `marko/config`.** The `marko/config` package uses the term "tenant scope" as a configuration parameter name. This is unrelated to `marko/scope`'s axis/path concept --- the two systems are independent. ## Related Packages diff --git a/packages/scope-mysql/tests/Feature/AutoMigrationTest.php b/packages/scope-mysql/tests/Feature/AutoMigrationTest.php index a1996bae..6f0f6898 100644 --- a/packages/scope-mysql/tests/Feature/AutoMigrationTest.php +++ b/packages/scope-mysql/tests/Feature/AutoMigrationTest.php @@ -12,7 +12,8 @@ use Marko\Database\Schema\Column as SchemaColumn; use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table as SchemaTable; -use Marko\Scope\Storage\ScopedOverridesEntity; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; // ── Fixtures ───────────────────────────────────────────────────────────────── @@ -29,7 +30,10 @@ class AutoMigrationProduct extends Entity } #[Table(extends: AutoMigrationProduct::class)] -class AutoMigrationProductScopedOverrides extends ScopedOverridesEntity {} +class AutoMigrationProductScopedOverrides extends Entity implements HasScopesInterface +{ + use HasScopes; +} // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php b/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php index 617b81fb..9e5fc7fc 100644 --- a/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php +++ b/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php @@ -14,7 +14,8 @@ use Marko\Database\Schema\Column as SchemaColumn; use Marko\Database\Schema\SchemaRegistry; use Marko\Database\Schema\Table as SchemaTable; -use Marko\Scope\Storage\ScopedOverridesEntity; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; // Test fixtures @@ -31,7 +32,10 @@ class Product extends Entity } #[Table(extends: Product::class)] -class ProductScopedOverrides extends ScopedOverridesEntity {} +class ProductScopedOverrides extends Entity implements HasScopesInterface +{ + use HasScopes; +} // Tests diff --git a/packages/scope/README.md b/packages/scope/README.md index 69c93a1a..564a756a 100644 --- a/packages/scope/README.md +++ b/packages/scope/README.md @@ -8,7 +8,7 @@ Scoped attributes for entities with multi-axis hierarchical fallback. composer require marko/scope ``` -A driver package is also required for sort rendering: +A driver package is also required: ```bash composer require marko/scope-mysql @@ -16,61 +16,6 @@ composer require marko/scope-mysql composer require marko/scope-pgsql ``` -## Quick start - -Add the `HasScopes` trait and `HasScopesInterface` to your entity: - -```php -use Marko\Database\Attributes\Column; -use Marko\Database\Attributes\Table; -use Marko\Database\Entity\Entity; -use Marko\Scope\Attributes\Scoped; -use Marko\Scope\Storage\HasScopes; -use Marko\Scope\Storage\HasScopesInterface; - -#[Table('products')] -class Product extends Entity implements HasScopesInterface -{ - use HasScopes; - - #[Column(primaryKey: true, autoIncrement: true)] - public int $id; - - #[Column(length: 255)] - #[Scoped(axes: ['locale'])] - public string $name = ''; -} -``` - -Then run your migration to add the `scopes` column: - -```bash -php artisan marko:migrate -``` - -## How it works (schema) - -The `HasScopes` trait declares a `$scopes` property with a `#[Column]` attribute. `EntityMetadataFactory` picks this up automatically during schema generation, adding a `scopes` JSON column to your entity's table. No manual column definition is required. - -## Resolver API - -Use `ScopeResolver` to read and write scoped values. The API is identical regardless of which storage approach you use: - -```php -use Marko\Scope\Resolver\ScopeResolver; -use Marko\Scope\Scope; - -// Write a scoped override -$scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); - -// Clear a scoped override -$scopeResolver->clearOverride($product, 'name', new Scope('locale', 'de')); - -// Read with hierarchy fallback (de-DE walks up to de) -$scopeContext->in('locale', 'de-DE'); -$localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' -``` - ## Quick Example ```php @@ -78,8 +23,6 @@ use Marko\Database\Attributes\Column; use Marko\Database\Attributes\Table; use Marko\Database\Entity\Entity; use Marko\Scope\Attributes\Scoped; -use Marko\Scope\Resolver\ScopeResolver; -use Marko\Scope\Scope; use Marko\Scope\Storage\HasScopes; use Marko\Scope\Storage\HasScopesInterface; @@ -88,56 +31,19 @@ class Product extends Entity implements HasScopesInterface { use HasScopes; - #[Column(primaryKey: true, autoIncrement: true)] - public int $id; - #[Column(length: 255)] #[Scoped(axes: ['locale'])] public string $name = ''; } -// Write a scoped override +// Set a scoped override $scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); -// Read with hierarchy fallback (de-DE walks up to de) +// Resolve with hierarchy fallback (de-DE walks up to de) $scopeContext->in('locale', 'de-DE'); -$localizedName = $scopeResolver->resolved($product, 'name'); // 'Widget DE' -``` - -## Alternative: companion class - -When the scoped overrides are contributed by a separate package, or when you cannot modify the entity class, use a `ScopedOverridesEntity` extender instead: - -```php -use Marko\Database\Attributes\Table; -use Marko\Database\Entity\Entity; -use Marko\Scope\Storage\ScopedOverridesEntity; - -#[Table('product_scopes')] -class ProductScopedOverrides extends ScopedOverridesEntity -{ - // Links back to Product -} +$localizedName = $scopeResolver->resolved($product, 'name'); ``` -Register the companion via the module's extender configuration. This pattern is useful when: - -- Scoped overrides are provided by a module that does not own the base entity -- You want to keep the base entity's schema clean -- The entity class cannot be modified (e.g. it comes from a vendor package) - -## Don't mix them - -Do **not** use both `use HasScopes` and a `ScopedOverridesEntity` extender on the same entity. - -The boot-time validator will surface a `ScopeConfigurationException::traitAndCompanionConflict()` error if you do. If validation is bypassed, the schema build will fail with `EntityException::duplicateColumnInExtender('scopes', ...)`. - -## Batch insert compatibility - -Trait-based entities (`use HasScopes`) have no attached companion, so they are fully compatible with `Repository::insertBatch()`. - -Companion-based scoped entities (`ScopedOverridesEntity` extender) are **not** compatible with `Repository::insertBatch()`. Attempting a batch insert will throw `BatchInsertException::companionsNotSupported()`. - ## Documentation Full usage, API reference, and examples: [marko/scope](https://marko.build/docs/packages/scope/) diff --git a/packages/scope/src/Exceptions/ScopeConfigurationException.php b/packages/scope/src/Exceptions/ScopeConfigurationException.php index bb33452d..c68a9bfc 100644 --- a/packages/scope/src/Exceptions/ScopeConfigurationException.php +++ b/packages/scope/src/Exceptions/ScopeConfigurationException.php @@ -5,7 +5,6 @@ namespace Marko\Scope\Exceptions; use Marko\Core\Exceptions\MarkoException; -use ReflectionClass; /** * Exception thrown when scope configuration is malformed or invalid. @@ -42,48 +41,14 @@ public static function duplicatePath(string $path): self } /** - * @param array> $scopedProperties Map of property name => list of axes + * @param class-string $entityClass */ - public static function missingOverridesExtender( - string $parentClass, - array $scopedProperties, - ): self { - $propertyLines = []; - foreach ($scopedProperties as $property => $axes) { - $axesStr = count($axes) > 0 ? implode(', ', $axes) : 'none'; - $propertyLines[] = " - $property (axes: $axesStr)"; - } - $propertiesContext = implode("\n", $propertyLines); - - $shortName = class_exists($parentClass) ? (new ReflectionClass($parentClass))->getShortName() : $parentClass; - $suggestion = "#[Table(extends: $parentClass::class)]\nclass {$shortName}Overrides extends ScopedOverridesEntity {}"; - - return new self( - message: "Entity '$parentClass' declares Scoped properties but has no ScopedOverridesEntity extender registered.", - context: "Validating scoped entity '$parentClass'.\nScoped properties:\n$propertiesContext", - suggestion: $suggestion, - ); - } - - public static function wrongOverridesExtenderBase( - string $parentClass, - string $extenderClass, - ): self { - return new self( - message: "Extender '$extenderClass' for '$parentClass' does not extend ScopedOverridesEntity.", - context: "Validating scoped entity '$parentClass': found extender '$extenderClass' but it does not extend ScopedOverridesEntity.", - suggestion: "Make '$extenderClass' extend ScopedOverridesEntity instead of Entity directly.", - ); - } - - public static function traitAndCompanionConflict( - string $parentClass, - string $extenderClass, - ): self { + public static function missingScopesStorage(string $entityClass): self + { return new self( - message: "Entity '$parentClass' uses both the HasScopes trait and has a ScopedOverridesEntity extender '$extenderClass' — they both contribute a `scopes` column.", - context: "Validating scoped entity '$parentClass': found ScopedOverridesEntity extender '$extenderClass' but the entity already uses the HasScopes trait.", - suggestion: 'Remove either the `use HasScopes;` trait or the extender class — they both contribute a `scopes` column', + message: "Entity '$entityClass' has #[Scoped] properties but provides no scope storage. Add 'use HasScopes; implements HasScopesInterface;' to the entity, or register a companion class that implements HasScopesInterface.", + context: "Validating scoped entity '$entityClass'", + suggestion: "Either add 'use HasScopes; implements HasScopesInterface;' to '$entityClass', or create and register a companion class extending Entity that implements HasScopesInterface using the HasScopes trait.", ); } } diff --git a/packages/scope/src/Resolver/ScopeResolver.php b/packages/scope/src/Resolver/ScopeResolver.php index 0375be82..57fc9a97 100644 --- a/packages/scope/src/Resolver/ScopeResolver.php +++ b/packages/scope/src/Resolver/ScopeResolver.php @@ -5,9 +5,6 @@ namespace Marko\Scope\Resolver; use Marko\Database\Entity\Entity; -use Marko\Database\Entity\EntityMetadataFactory; -use Marko\Database\Exceptions\EntityException; -use Marko\Database\Exceptions\MissingPrimaryKeyException; use Marko\Scope\Context\ScopeContext; use Marko\Scope\Exceptions\ScopeContextException; use Marko\Scope\Exceptions\UnknownAxisException; @@ -17,7 +14,6 @@ use Marko\Scope\Resolution\ScopeWalker; use Marko\Scope\Scope; use Marko\Scope\Storage\HasScopesInterface; -use Marko\Scope\Storage\ScopedOverridesEntity; readonly class ScopeResolver { @@ -25,7 +21,6 @@ public function __construct( private ScopeMetadataFactory $scopeMetadataFactory, private ScopeWalker $scopeWalker, private ScopeContext $scopeContext, - private EntityMetadataFactory $entityMetadataFactory, ) {} /** @@ -85,7 +80,7 @@ public function resolvedAt( } /** - * @throws ScopeContextException|UnknownAxisException|EntityException|MissingPrimaryKeyException + * @throws ScopeContextException|UnknownAxisException */ public function setOverride( Entity $entity, @@ -103,9 +98,11 @@ public function setOverride( $storage = $this->findStorage($entity); if ($storage === null) { - $companion = $this->createCompanion($entityClass); - $entity->attachCompanion($companion); - $storage = $companion; + throw new ScopeContextException( + message: "Entity '$entityClass' has no scope storage: it must implement HasScopesInterface or have a companion that does.", + context: "Setting scope override for property '$property' on '$entityClass'", + suggestion: "Add 'use HasScopes; implements HasScopesInterface;' to '$entityClass', or register and attach a companion class that implements HasScopesInterface.", + ); } $scopeKey = $scope->axisName . ':' . $scope->path; @@ -137,30 +134,6 @@ public function clearOverride( $storage->clearOverride($scopeKey, $property); } - /** - * @param class-string $entityClass - * @throws ScopeContextException|EntityException|MissingPrimaryKeyException - */ - private function createCompanion(string $entityClass): ScopedOverridesEntity - { - $entityMetadata = $this->entityMetadataFactory->parse($entityClass); - - $extender = array_find( - $entityMetadata->extenders, - fn (string $candidate) => is_subclass_of($candidate, ScopedOverridesEntity::class), - ); - - if ($extender !== null) { - return new $extender(); - } - - throw new ScopeContextException( - message: "No ScopedOverridesEntity subclass found for '$entityClass'", - context: "Creating override companion for '$entityClass'", - suggestion: "Register a class extending ScopedOverridesEntity with #[Table(extends: $entityClass::class)]", - ); - } - private function findStorage(Entity $entity): ?HasScopesInterface { if ($entity instanceof HasScopesInterface) { diff --git a/packages/scope/src/Storage/ScopedOverridesEntity.php b/packages/scope/src/Storage/ScopedOverridesEntity.php deleted file mode 100644 index c3dade50..00000000 --- a/packages/scope/src/Storage/ScopedOverridesEntity.php +++ /dev/null @@ -1,68 +0,0 @@ -scopes ?? []; - $scopes[$scopeKey][$property] = $value; - ksort($scopes[$scopeKey]); - ksort($scopes); - $this->scopes = $scopes; - } - - public function getOverride( - string $scopeKey, - string $property, - ): mixed { - return $this->scopes[$scopeKey][$property] ?? null; - } - - /** - * @return array> - */ - public function allOverrides(): array - { - return $this->scopes ?? []; - } - - public function hasOverride( - string $scopeKey, - string $property, - ): bool { - return array_key_exists($scopeKey, $this->scopes ?? []) - && array_key_exists($property, $this->scopes[$scopeKey]); - } - - public function clearOverride( - string $scopeKey, - string $property, - ): void { - if (!isset($this->scopes[$scopeKey])) { - return; - } - - $scopes = $this->scopes; - unset($scopes[$scopeKey][$property]); - - if ($scopes[$scopeKey] === []) { - unset($scopes[$scopeKey]); - } - - ksort($scopes); - $this->scopes = $scopes ?: null; - } -} diff --git a/packages/scope/src/Validation/ScopedEntityValidator.php b/packages/scope/src/Validation/ScopedEntityValidator.php index 31c0af1f..02b959fb 100644 --- a/packages/scope/src/Validation/ScopedEntityValidator.php +++ b/packages/scope/src/Validation/ScopedEntityValidator.php @@ -11,11 +11,11 @@ use Marko\Scope\Exceptions\UnknownAxisException; use Marko\Scope\Metadata\ScopeMetadataFactory; use Marko\Scope\Storage\HasScopesInterface; -use Marko\Scope\Storage\ScopedOverridesEntity; /** * Boot-time validator that ensures every entity with scoped properties - * has a registered ScopedOverridesEntity extender. + * has scope storage configured — either via the HasScopes trait on the entity itself, + * or via a registered companion class that implements HasScopesInterface. */ readonly class ScopedEntityValidator { @@ -25,7 +25,7 @@ public function __construct( ) {} /** - * Validate that the given entity class has a proper overrides extender if it declares scoped properties. + * Validate that the given entity class has proper scope storage if it declares scoped properties. * * @param class-string $entityClass * @@ -40,39 +40,20 @@ public function validate(string $entityClass): void } if (is_a($entityClass, HasScopesInterface::class, true)) { - $entityMetadata = $this->entityMetadataFactory->parse($entityClass); - - $conflictingExtender = array_find( - $entityMetadata->extenders, - fn (string $extender) => is_subclass_of($extender, ScopedOverridesEntity::class), - ); - - if ($conflictingExtender !== null) { - throw ScopeConfigurationException::traitAndCompanionConflict($entityClass, $conflictingExtender); - } - return; } $entityMetadata = $this->entityMetadataFactory->parse($entityClass); - $extenders = $entityMetadata->extenders; - - $scopedProperties = []; - foreach ($scopeMetadata->scopedProperties() as $property) { - $scopedProperties[$property] = $scopeMetadata->axesForProperty($property); - } - $hasValidExtender = array_any( - $extenders, - fn (string $extender) => is_subclass_of($extender, ScopedOverridesEntity::class), + $hasCompatibleCompanion = array_any( + $entityMetadata->extenders, + fn (string $extender) => is_a($extender, HasScopesInterface::class, true), ); - if (count($extenders) === 0 || !$hasValidExtender) { - if (count($extenders) > 0) { - throw ScopeConfigurationException::wrongOverridesExtenderBase($entityClass, $extenders[0]); - } - - throw ScopeConfigurationException::missingOverridesExtender($entityClass, $scopedProperties); + if ($hasCompatibleCompanion) { + return; } + + throw ScopeConfigurationException::missingScopesStorage($entityClass); } } diff --git a/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php index c0ca23d7..104ee937 100644 --- a/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php +++ b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php @@ -10,7 +10,8 @@ use Marko\Database\Entity\EntityHydrator; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Repository\Repository; -use Marko\Scope\Storage\ScopedOverridesEntity; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; // Parent entity for dirty tracking feature test #[Table('products')] @@ -25,9 +26,12 @@ class DirtyTrackingProduct extends Entity public string $name; } -// ScopedOverridesEntity companion for DirtyTrackingProduct +// HasScopesInterface companion for DirtyTrackingProduct #[Table(extends: DirtyTrackingProduct::class)] -class DirtyTrackingProductOverrides extends ScopedOverridesEntity {} +class DirtyTrackingProductOverrides extends Entity implements HasScopesInterface +{ + use HasScopes; +} // Repository for DirtyTrackingProduct class DirtyTrackingProductRepository extends Repository @@ -57,16 +61,14 @@ public function isConnected(): bool public function query( string $sql, array $bindings = [], - ): array - { + ): array { return []; } public function execute( string $sql, array $bindings = [], - ): int - { + ): int { $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; return 1; diff --git a/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php index 70978984..b1d8b341 100644 --- a/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php +++ b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php @@ -10,7 +10,8 @@ use Marko\Database\Entity\EntityHydrator; use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Repository\Repository; -use Marko\Scope\Storage\ScopedOverridesEntity; +use Marko\Scope\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; // Product entity fixture #[Table('products')] @@ -27,7 +28,10 @@ class Product extends Entity // Companion for scoped overrides #[Table(extends: Product::class)] -class ProductScopedOverrides extends ScopedOverridesEntity {} +class ProductScopedOverrides extends Entity implements HasScopesInterface +{ + use HasScopes; +} // Repository for Product class ProductOverridesRepository extends Repository @@ -63,16 +67,14 @@ public function isConnected(): bool public function query( string $sql, array $bindings = [], - ): array - { + ): array { return []; } public function execute( string $sql, array $bindings = [], - ): int - { + ): int { $this->sqlLog[] = ['sql' => $sql, 'bindings' => $bindings]; $this->lastId++; @@ -159,8 +161,7 @@ public function isConnected(): bool public function query( string $sql, array $bindings = [], - ): array - { + ): array { // Simulate SELECT * FROM products WHERE id = ? if (str_contains($sql, 'WHERE id = ?')) { return [[ @@ -176,8 +177,7 @@ public function query( public function execute( string $sql, array $bindings = [], - ): int - { + ): int { if (str_starts_with($sql, 'INSERT')) { $this->lastId++; $this->insertedScopes = end($bindings); @@ -216,12 +216,11 @@ public function lastInsertId(): int /** @var Product $found */ $found = $repository->find($product->id); - expect($found)->not->toBeNull(); - /** @var ProductScopedOverrides $foundOverrides */ $foundOverrides = $found->companion(ProductScopedOverrides::class); - expect($foundOverrides)->not->toBeNull() + expect($found)->not->toBeNull() + ->and($foundOverrides)->not->toBeNull() ->and($foundOverrides->getOverride('geo:eu.de', 'name'))->toBe('Hemd') ->and($foundOverrides->allOverrides())->toBe(['geo:eu.de' => ['name' => 'Hemd']]); }); diff --git a/packages/scope/tests/Unit/ReadmeTest.php b/packages/scope/tests/Unit/ReadmeTest.php index afcd7c84..f893a011 100644 --- a/packages/scope/tests/Unit/ReadmeTest.php +++ b/packages/scope/tests/Unit/ReadmeTest.php @@ -51,7 +51,7 @@ expect($content)->toContain('## Documentation'); }); -it('documents HasScopes trait as the primary storage option', function (): void { +it('shows HasScopes trait usage in the quick example', function (): void { $readmePath = dirname(__DIR__, 2) . '/README.md'; $content = file_get_contents($readmePath); @@ -59,30 +59,3 @@ ->and($content)->toContain('HasScopesInterface') ->and($content)->toContain('use HasScopes'); }); - -it('documents the companion-class approach as an alternative', function (): void { - $readmePath = dirname(__DIR__, 2) . '/README.md'; - $content = file_get_contents($readmePath); - - expect($content)->toContain('Alternative') - ->and($content)->toContain('ScopedOverridesEntity'); -}); - -it('warns against using HasScopes trait and ScopedOverridesEntity extender on the same entity', function (): void { - $readmePath = dirname(__DIR__, 2) . '/README.md'; - $content = file_get_contents($readmePath); - - expect($content)->toContain('ScopeConfigurationException') - ->and($content)->toContain('traitAndCompanionConflict'); -}); - -it( - 'documents that trait-based entities are compatible with Repository::insertBatch while companion-based entities are not', - function (): void { - $readmePath = dirname(__DIR__, 2) . '/README.md'; - $content = file_get_contents($readmePath); - - expect($content)->toContain('insertBatch') - ->and($content)->toContain('BatchInsertException'); - }, -); diff --git a/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php index 11bb6cc5..f2477612 100644 --- a/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php +++ b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php @@ -14,17 +14,20 @@ use Marko\Scope\Scope; use Marko\Scope\Storage\HasScopes; use Marko\Scope\Storage\HasScopesInterface; -use Marko\Scope\Storage\ScopedOverridesEntity; -// Concrete anonymous-style subclass for tests -class WalkerTestOverrides extends ScopedOverridesEntity {} +// Standalone HasScopesInterface implementor for walker tests (no parent entity needed) +class WalkerTestOverrides implements HasScopesInterface +{ + use HasScopes; +} -// Trait-based fixture — does NOT extend ScopedOverridesEntity +// Trait-based entity fixture #[Table(name: 'walker_trait_products')] class WalkerTraitProduct extends Entity implements HasScopesInterface { use HasScopes; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; } @@ -36,7 +39,7 @@ function makeWalkerRegistry(array $axes = []): ScopeRegistryInterface /** @var array */ private array $builtAxes; - public function __construct(private readonly array $axes) + public function __construct(array $axes) { $this->builtAxes = []; foreach ($axes as $name => $paths) { diff --git a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php index b08e1f1d..adc4d221 100644 --- a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php +++ b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php @@ -5,7 +5,6 @@ use Marko\Database\Attributes\Column; use Marko\Database\Attributes\Table; use Marko\Database\Entity\Entity; -use Marko\Database\Entity\EntityMetadataFactory; use Marko\Scope\Attributes\Scoped; use Marko\Scope\Axis\ScopeAxis; use Marko\Scope\Context\ScopeContext; @@ -18,13 +17,13 @@ use Marko\Scope\Scope; use Marko\Scope\Storage\HasScopes; use Marko\Scope\Storage\HasScopesInterface; -use Marko\Scope\Storage\ScopedOverridesEntity; // ─── Fixtures ──────────────────────────────────────────────────────────────── #[Table(name: 'resolver_products')] class ResolverProduct extends Entity { + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; @@ -32,18 +31,17 @@ class ResolverProduct extends Entity #[Column] public string $name = 'default-name'; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column] public string $sku = 'default-sku'; } -#[Table(extends: ResolverProduct::class)] -class ResolverProductOverrides extends ScopedOverridesEntity {} - #[Table(name: 'trait_resolver_products')] class TraitResolverProduct extends Entity implements HasScopesInterface { use HasScopes; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; @@ -51,10 +49,17 @@ class TraitResolverProduct extends Entity implements HasScopesInterface #[Column] public string $name = 'default-name'; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column] public string $sku = 'default-sku'; } +#[Table(extends: ResolverProduct::class)] +class ManualCompanionProduct extends Entity implements HasScopesInterface +{ + use HasScopes; +} + // ─── Helpers ───────────────────────────────────────────────────────────────── function makeResolverRegistry(array $axes = ['store' => ['global', 'global.us']]): ScopeRegistryInterface @@ -64,7 +69,7 @@ function makeResolverRegistry(array $axes = ['store' => ['global', 'global.us']] /** @var array */ private array $builtAxes; - public function __construct(private readonly array $axes) + public function __construct(array $axes) { $this->builtAxes = []; foreach ($axes as $name => $paths) { @@ -101,16 +106,14 @@ function makeResolverSetup(): array $context = new ScopeContext($registry); $scopeMetaFactory = new ScopeMetadataFactory($registry); $walker = new ScopeWalker(); - $entityMetaFactory = new EntityMetadataFactory(); - $entityMetaFactory->linkExtenders(ResolverProduct::class, [ResolverProductOverrides::class]); - return [$registry, $context, $scopeMetaFactory, $walker, $entityMetaFactory]; + return [$registry, $context, $scopeMetaFactory, $walker]; } function makeResolver(): array { - [$registry, $context, $scopeMetaFactory, $walker, $entityMetaFactory] = makeResolverSetup(); - $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context, $entityMetaFactory); + [$registry, $context, $scopeMetaFactory, $walker] = makeResolverSetup(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); return [$resolver, $context, $registry]; } @@ -122,9 +125,9 @@ function makeResolver(): array $context->in('store', 'global.us'); $product = new ResolverProduct(); - $overrides = new ResolverProductOverrides(); - $overrides->setOverride('store:global.us', 'name', 'US Name'); - $product->attachCompanion($overrides); + $companion = new ManualCompanionProduct(); + $companion->setOverride('store:global.us', 'name', 'US Name'); + $product->attachCompanion($companion); $result = $resolver->resolved($product, 'name'); @@ -137,10 +140,10 @@ function makeResolver(): array $context->in('store', 'global.us'); $product = new ResolverProduct(); - $overrides = new ResolverProductOverrides(); - $overrides->setOverride('store:global', 'name', 'Global Name'); - $overrides->setOverride('store:global.us', 'name', 'US Name'); - $product->attachCompanion($overrides); + $companion = new ManualCompanionProduct(); + $companion->setOverride('store:global', 'name', 'Global Name'); + $companion->setOverride('store:global.us', 'name', 'US Name'); + $product->attachCompanion($companion); $scope = new Scope('store', 'global'); $result = $resolver->resolvedAt($product, 'name', $scope); @@ -148,74 +151,22 @@ function makeResolver(): array expect($result)->toBe('Global Name'); }); -it('sets an override via setOverride attaching a ScopedOverridesEntity companion if missing', function (): void { - [$resolver, $context] = makeResolver(); - $context->in('store', 'global.us'); - - $product = new ResolverProduct(); - // No companion attached yet - - $scope = new Scope('store', 'global.us'); - $resolver->setOverride($product, 'name', 'US Name', $scope); - - $companion = $product->companion(ResolverProductOverrides::class); - - expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) - ->and($companion->getOverride('store:global.us', 'name'))->toBe('US Name'); -}); - -it('sets an override on a new (unsaved) entity then saves so both rows reflect the override', function (): void { - [$resolver] = makeResolver(); - - $product = new ResolverProduct(); - // Simulate "new (unsaved)" entity — no companion exists yet - - $scope = new Scope('store', 'global.us'); - $resolver->setOverride($product, 'name', 'Fresh Name', $scope); - - $companion = $product->companion(ResolverProductOverrides::class); - - expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) - ->and($companion->getOverride('store:global.us', 'name'))->toBe('Fresh Name'); -}); - it('clears an override via clearOverride leaving the companion otherwise intact', function (): void { [$resolver] = makeResolver(); $product = new ResolverProduct(); - $overrides = new ResolverProductOverrides(); - $overrides->setOverride('store:global.us', 'name', 'US Name'); - $overrides->setOverride('store:global.us', 'sku', 'SKU-US'); - $product->attachCompanion($overrides); + $companion = new ManualCompanionProduct(); + $companion->setOverride('store:global.us', 'name', 'US Name'); + $companion->setOverride('store:global.us', 'sku', 'SKU-US'); + $product->attachCompanion($companion); $scope = new Scope('store', 'global.us'); $resolver->clearOverride($product, 'name', $scope); - $companion = $product->companion(ResolverProductOverrides::class); - expect($companion->hasOverride('store:global.us', 'name'))->toBeFalse() ->and($companion->getOverride('store:global.us', 'sku'))->toBe('SKU-US'); }); -it( - 'discovers the correct ScopedOverridesEntity subclass for a given parent entity class via EntityMetadata::extenders', - function (): void { - [$resolver] = makeResolver(); - - $product = new ResolverProduct(); - // No companion exists; resolver must discover ResolverProductOverrides via extenders - - $scope = new Scope('store', 'global'); - $resolver->setOverride($product, 'name', 'Global Name', $scope); - - // The companion created should be the correct subclass - $companion = $product->companion(ResolverProductOverrides::class); - - expect($companion)->toBeInstanceOf(ResolverProductOverrides::class) - ->and($companion->getOverride('store:global', 'name'))->toBe('Global Name'); - }, -); - it('throws ScopeContextException when setOverride targets a property without Scoped', function (): void { [$resolver] = makeResolver(); @@ -243,9 +194,9 @@ function (): void { $product = new ResolverProduct(); $product->name = 'base-name'; - $overrides = new ResolverProductOverrides(); + $companion = new ManualCompanionProduct(); // No override set for 'name' - $product->attachCompanion($overrides); + $product->attachCompanion($companion); $result = $resolver->resolved($product, 'name'); @@ -276,20 +227,6 @@ function (): void { expect($result)->toBe('base-trait-name'); }); -it('resolves a scoped value when a ScopedOverridesEntity companion is attached (backward compat)', function (): void { - [$resolver, $context] = makeResolver(); - $context->in('store', 'global.us'); - - $product = new ResolverProduct(); - $overrides = new ResolverProductOverrides(); - $overrides->setOverride('store:global.us', 'name', 'Companion Name'); - $product->attachCompanion($overrides); - - $result = $resolver->resolved($product, 'name'); - - expect($result)->toBe('Companion Name'); -}); - it( 'sets an override directly on the entity when it implements HasScopesInterface and no companion is attached or created', function (): void { @@ -338,9 +275,7 @@ function (): void { $context = new ScopeContext($registry); $scopeMetaFactory = new ScopeMetadataFactory($registry); $walker = new ScopeWalker(); - $entityMetaFactory = new EntityMetadataFactory(); - // No linkExtenders call — no companion available - $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context, $entityMetaFactory); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); $product = new ResolverProduct(); $scope = new Scope('store', 'global.us'); @@ -389,7 +324,7 @@ function (): void { $product->setOverride('store:global.us', 'name', 'Entity Override'); // Attach a companion that also has an override — entity should win - $companion = new ResolverProductOverrides(); + $companion = new ManualCompanionProduct(); $companion->setOverride('store:global.us', 'name', 'Companion Override'); $product->attachCompanion($companion); @@ -409,3 +344,75 @@ function (): void { expect($product->companions())->toBeEmpty(); }); + +it( + 'ScopeResolver setOverride throws ScopeContextException when entity has no HasScopesInterface and no compatible companion', + function (): void { + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); + + $product = new ResolverProduct(); + $scope = new Scope('store', 'global.us'); + + expect(fn () => $resolver->setOverride($product, 'name', 'Name', $scope)) + ->toThrow(ScopeContextException::class); + }, +); + +it('ScopeResolver setOverride works on a trait-based entity', function (): void { + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); + + $product = new TraitResolverProduct(); + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'Trait Override', $scope); + + expect($product->getOverride('store:global.us', 'name'))->toBe('Trait Override') + ->and($product->companions())->toBeEmpty(); +}); + +it('ScopeResolver setOverride works when a manual HasScopesInterface companion is attached', function (): void { + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); + + $product = new ResolverProduct(); + $companion = new ManualCompanionProduct(); + $product->attachCompanion($companion); + $scope = new Scope('store', 'global.us'); + $resolver->setOverride($product, 'name', 'Companion Override', $scope); + + expect($companion->getOverride('store:global.us', 'name'))->toBe('Companion Override'); +}); + +it('ScopeResolver resolved works with a manual HasScopesInterface companion (not ScopedOverridesEntity)', function (): void { + $registry = makeResolverRegistry(); + $context = new ScopeContext($registry); + $context->in('store', 'global.us'); + $scopeMetaFactory = new ScopeMetadataFactory($registry); + $walker = new ScopeWalker(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); + + $product = new ResolverProduct(); + $companion = new ManualCompanionProduct(); + $companion->setOverride('store:global.us', 'name', 'Manual Companion Name'); + $product->attachCompanion($companion); + + $result = $resolver->resolved($product, 'name'); + + expect($result)->toBe('Manual Companion Name'); +}); + +it('ScopeResolver does not have a createCompanion method', function (): void { + $reflection = new ReflectionClass(ScopeResolver::class); + + expect($reflection->hasMethod('createCompanion'))->toBeFalse(); +}); diff --git a/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php b/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php deleted file mode 100644 index b23a595d..00000000 --- a/packages/scope/tests/Unit/Storage/ScopedOverridesEntityTest.php +++ /dev/null @@ -1,133 +0,0 @@ -isAbstract())->toBeTrue() - ->and($reflection->getParentClass()->getName())->toBe(Entity::class) - ->and($reflection->hasProperty('scopes'))->toBeTrue(); - - $property = $reflection->getProperty('scopes'); - $attributes = $property->getAttributes(Column::class); - - expect($attributes)->toHaveCount(1); - - $column = $attributes[0]->newInstance(); - - expect($column->name)->toBe('scopes') - ->and($column->type)->toBe('json') - ->and($column->nullable)->toBeTrue(); -}); - -it('stores an override keyed by scope key and property via setOverride', function (): void { - $entity = new ConcreteOverrides(); - $entity->setOverride('geo:eu.de', 'name', 'Hemd'); - - expect($entity->scopes)->toBe([ - 'geo:eu.de' => ['name' => 'Hemd'], - ]); -}); - -it('returns the stored override via getOverride for the same property and scope', function (): void { - $entity = new ConcreteOverrides(); - $entity->setOverride('geo:eu.de', 'name', 'Hemd'); - - expect($entity->getOverride('geo:eu.de', 'name'))->toBe('Hemd'); -}); - -it('returns null from getOverride when no override exists at that scope', function (): void { - $entity = new ConcreteOverrides(); - - expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); -}); - -it('removes an override via clearOverride and getOverride returns null afterward', function (): void { - $entity = new ConcreteOverrides(); - $entity->setOverride('geo:eu.de', 'name', 'Hemd'); - $entity->setOverride('geo:eu.de', 'price', 19.99); - $entity->clearOverride('geo:eu.de', 'name'); - - expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() - ->and($entity->getOverride('geo:eu.de', 'price'))->toBe(19.99); -}); - -it('removes the entire scope-key sub-map when its last property is cleared', function (): void { - $entity = new ConcreteOverrides(); - $entity->setOverride('geo:eu.de', 'name', 'Hemd'); - $entity->clearOverride('geo:eu.de', 'name'); - - expect($entity->scopes)->toBeNull(); -}); - -it( - 'functions as a companion attached via Entity::attachCompanion and is retrievable via Entity::companion', - function (): void { - $parent = new ConcreteParentEntity(); - $overrides = new ConcreteOverrides(); - $overrides->setOverride('geo:eu.de', 'name', 'Hemd'); - - $parent->attachCompanion($overrides); - - $retrieved = $parent->companion(ConcreteOverrides::class); - - expect($retrieved)->toBeInstanceOf(ConcreteOverrides::class) - ->and($retrieved)->toBe($overrides) - ->and($retrieved->getOverride('geo:eu.de', 'name'))->toBe('Hemd'); - }, -); - -it('lists all overrides via allOverrides as the flat scope-key-first map', function (): void { - $entity = new ConcreteOverrides(); - $entity->setOverride('locale:de', 'name', 'Hallo'); - $entity->setOverride('geo:eu.de', 'name', 'Hemd'); - $entity->setOverride('geo:eu.de', 'price', 19.99); - - expect($entity->allOverrides())->toBe([ - 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], - 'locale:de' => ['name' => 'Hallo'], - ]); -}); - -it( - 'ScopedOverridesEntity implements HasScopesInterface and its public method signatures match the interface', - function (): void { - $entity = new ConcreteOverrides(); - - expect($entity)->toBeInstanceOf(HasScopesInterface::class); - - $reflection = new ReflectionClass(ScopedOverridesEntity::class); - $interface = new ReflectionClass(HasScopesInterface::class); - - foreach ($interface->getMethods() as $interfaceMethod) { - expect($reflection->hasMethod($interfaceMethod->getName()))->toBeTrue(); - } - }, -); - -it('distinguishes an explicit null override from no override via hasOverride', function (): void { - $entity = new ConcreteOverrides(); - - expect($entity->hasOverride('geo:eu.de', 'name'))->toBeFalse(); - - $entity->setOverride('geo:eu.de', 'name', null); - - expect($entity->hasOverride('geo:eu.de', 'name'))->toBeTrue() - ->and($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); -}); diff --git a/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php index ea609869..b158a1f6 100644 --- a/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php +++ b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php @@ -14,7 +14,6 @@ use Marko\Scope\Registry\ScopeRegistryInterface; use Marko\Scope\Storage\HasScopes; use Marko\Scope\Storage\HasScopesInterface; -use Marko\Scope\Storage\ScopedOverridesEntity; use Marko\Scope\Validation\ScopedEntityValidator; // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -22,9 +21,11 @@ #[Table(name: 'plain_products')] class ValidatorPlainProduct extends Entity { + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column] public string $name = ''; } @@ -32,54 +33,54 @@ class ValidatorPlainProduct extends Entity #[Table(name: 'scoped_products')] class ValidatorScopedProduct extends Entity { + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Scoped(axes: ['store'])] #[Column] public string $name = ''; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Scoped(axes: ['store', 'website'])] #[Column] public string $description = ''; } -#[Table(extends: ValidatorScopedProduct::class)] -class ValidatorScopedProductOverrides extends ScopedOverridesEntity {} - -#[Table(name: 'other_products')] -class ValidatorOtherProduct extends Entity +#[Table(name: 'trait_scoped_products')] +class ValidatorTraitScopedProduct extends Entity implements HasScopesInterface { + use HasScopes; + + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Scoped(axes: ['store'])] #[Column] - public string $title = ''; -} - -#[Table(extends: ValidatorOtherProduct::class)] -class ValidatorOtherProductOverridesWrongBase extends Entity -{ - #[Column] - public string $extra = ''; + public string $name = ''; } -#[Table(name: 'trait_scoped_products')] -class ValidatorTraitScopedProduct extends Entity implements HasScopesInterface +#[Table(name: 'companion_scoped_products')] +class ValidatorCompanionScopedProduct extends Entity { - use HasScopes; - + /** @noinspection PhpUnused - Entity property for structural definition */ #[Column(primaryKey: true, autoIncrement: true)] public ?int $id = null; + /** @noinspection PhpUnused - Entity property for structural definition */ #[Scoped(axes: ['store'])] #[Column] public string $name = ''; } -#[Table(extends: ValidatorTraitScopedProduct::class)] -class ValidatorTraitScopedProductOverrides extends ScopedOverridesEntity {} +#[Table(extends: ValidatorCompanionScopedProduct::class)] +class ValidatorCompanionScopedProductOverrides extends Entity implements HasScopesInterface +{ + use HasScopes; +} // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -111,150 +112,36 @@ public function getHierarchy(string $axisName): ScopeHierarchy // ─── Tests ─────────────────────────────────────────────────────────────────── -it('passes for an entity with no Scoped properties', function (): void { +it('accepts a trait-based entity as valid scopes storage (HasScopesInterface on entity)', function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - expect(fn () => $validator->validate(ValidatorPlainProduct::class))->not->toThrow(Throwable::class); + expect(fn () => $validator->validate(ValidatorTraitScopedProduct::class))->not->toThrow(Throwable::class); }); -it( - 'passes for an entity with Scoped properties when a matching ScopedOverridesEntity extender is registered', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders(ValidatorScopedProduct::class, [ValidatorScopedProductOverrides::class]); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - expect(fn () => $validator->validate(ValidatorScopedProduct::class))->not->toThrow(Throwable::class); - }, -); - -it( - 'throws ScopeConfigurationException when an entity has Scoped properties but no extender is linked', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - expect(fn () => $validator->validate(ValidatorScopedProduct::class)) - ->toThrow(ScopeConfigurationException::class); - }, -); - -it( - 'throws ScopeConfigurationException when the linked extender does not extend ScopedOverridesEntity', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders(ValidatorOtherProduct::class, [ValidatorOtherProductOverridesWrongBase::class]); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - expect(fn () => $validator->validate(ValidatorOtherProduct::class)) - ->toThrow(ScopeConfigurationException::class); - }, -); - -it('includes the parent entity FQCN in the exception message', function (): void { +it('accepts a companion that implements HasScopesInterface as valid scopes storage', function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); + $entityFactory->linkExtenders( + ValidatorCompanionScopedProduct::class, + [ValidatorCompanionScopedProductOverrides::class], + ); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - try { - $validator->validate(ValidatorScopedProduct::class); - expect(false)->toBeTrue('Expected exception was not thrown'); - } catch (ScopeConfigurationException $e) { - expect($e->getMessage())->toContain(ValidatorScopedProduct::class); - } + expect(fn () => $validator->validate(ValidatorCompanionScopedProduct::class))->not->toThrow(Throwable::class); }); -it('lists each Scoped property and its declared axes in the exception context', function (): void { +it('accepts an entity with no scoped properties regardless of storage', function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - try { - $validator->validate(ValidatorScopedProduct::class); - expect(false)->toBeTrue('Expected exception was not thrown'); - } catch (ScopeConfigurationException $e) { - expect($e->getContext())->toContain('name') - ->and($e->getContext())->toContain('store') - ->and($e->getContext())->toContain('description') - ->and($e->getContext())->toContain('website'); - } + expect(fn () => $validator->validate(ValidatorPlainProduct::class))->not->toThrow(Throwable::class); }); it( - 'provides a one-line class declaration including the Table extends attribute in the exception suggestion', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - try { - $validator->validate(ValidatorScopedProduct::class); - expect(false)->toBeTrue('Expected exception was not thrown'); - } catch (ScopeConfigurationException $e) { - expect($e->getSuggestion())->toContain('#[Table(extends:') - ->and($e->getSuggestion())->toContain(ValidatorScopedProduct::class . '::class') - ->and($e->getSuggestion())->toContain('ScopedOverridesEntity'); - } - }, -); - -it( - 'the traitAndCompanionConflict exception message names both the parent class and the conflicting extender class', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders( - ValidatorTraitScopedProduct::class, - [ValidatorTraitScopedProductOverrides::class], - ); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - try { - $validator->validate(ValidatorTraitScopedProduct::class); - expect(false)->toBeTrue('Expected exception was not thrown'); - } catch (ScopeConfigurationException $e) { - expect($e->getMessage())->toContain(ValidatorTraitScopedProduct::class) - ->and($e->getMessage())->toContain(ValidatorTraitScopedProductOverrides::class); - } - }, -); - -it( - 'throws ScopeConfigurationException via traitAndCompanionConflict when entity uses HasScopes trait AND has a ScopedOverridesEntity extender registered', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders( - ValidatorTraitScopedProduct::class, - [ValidatorTraitScopedProductOverrides::class], - ); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - expect(fn () => $validator->validate(ValidatorTraitScopedProduct::class)) - ->toThrow(ScopeConfigurationException::class); - }, -); - -it( - 'throws ScopeConfigurationException with wrongOverridesExtenderBase when extender exists but is not ScopedOverridesEntity', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders(ValidatorOtherProduct::class, [ValidatorOtherProductOverridesWrongBase::class]); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - expect(fn () => $validator->validate(ValidatorOtherProduct::class)) - ->toThrow(ScopeConfigurationException::class); - }, -); - -it( - 'throws ScopeConfigurationException when entity has scoped properties but neither trait nor companion', + 'throws ScopeConfigurationException missingScopesStorage when entity has scoped properties but no storage', function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); @@ -265,51 +152,19 @@ function (): void { }, ); -it('passes validation when the entity has no scoped properties at all', function (): void { +it('the missingScopesStorage exception message names the entity class and describes both remedies', function (): void { $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); $entityFactory = new EntityMetadataFactory(); $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - $threw = false; - try { - $validator->validate(ValidatorPlainProduct::class); - } catch (Throwable) { - $threw = true; - } - - expect($threw)->toBeFalse(); -}); - -it('passes validation when the entity has a ScopedOverridesEntity companion (backward compat)', function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $entityFactory->linkExtenders(ValidatorScopedProduct::class, [ValidatorScopedProductOverrides::class]); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - $threw = false; try { $validator->validate(ValidatorScopedProduct::class); - } catch (Throwable) { - $threw = true; + expect(false)->toBeTrue('Expected ScopeConfigurationException was not thrown'); + } catch (ScopeConfigurationException $e) { + expect($e->getMessage()) + ->toContain(ValidatorScopedProduct::class) + ->and($e->getMessage())->toContain('HasScopes') + ->and($e->getMessage())->toContain('HasScopesInterface') + ->and($e->getMessage())->toContain('companion'); } - - expect($threw)->toBeFalse(); }); - -it( - 'passes validation when the entity class implements HasScopesInterface and has scoped properties', - function (): void { - $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); - $entityFactory = new EntityMetadataFactory(); - $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); - - $threw = false; - try { - $validator->validate(ValidatorTraitScopedProduct::class); - } catch (Throwable) { - $threw = true; - } - - expect($threw)->toBeFalse(); - }, -); From ee6c7dad7e433c36f8a87ff1480692abcbc7f4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 14 May 2026 09:46:40 +0200 Subject: [PATCH 08/13] fix(database-pgsql): JSON-encode array bindings before passing to PDO PDO silently casts PHP arrays to the string "Array" when bound as query parameters. For json/jsonb columns this causes an "invalid input syntax for type json" error at the database level. Co-Authored-By: Claude Sonnet 4.6 --- .../src/Connection/PgSqlConnection.php | 6 ++++ .../tests/Connection/PgSqlConnectionTest.php | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index 2edb610d..9300b521 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -187,6 +187,12 @@ private function bindValues( ): void { foreach ($bindings as $key => $value) { $param = is_int($key) ? $key + 1 : $key; + + if (is_array($value)) { + $statement->bindValue($param, json_encode($value), PDO::PARAM_STR); + continue; + } + $type = match (true) { is_bool($value) => PDO::PARAM_BOOL, is_null($value) => PDO::PARAM_NULL, diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 3ab8c1cd..c68a86fc 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -685,4 +685,33 @@ protected function createPdo( expect(fn () => $connection->beginTransaction()) ->toThrow(TransactionException::class, 'Nested transactions are not supported'); }); + + it('JSON-encodes array bindings instead of casting them to the string "Array"', function (): void { + $config = createTestPgSqlConfig(); + $connection = new class ($config) extends PgSqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = createSqliteMockPdo($options); + $pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)'); + + return $pdo; + } + }; + + $connection->execute( + 'INSERT INTO items (metadata) VALUES (?)', + [['key' => 'value', 'nested' => [1, 2, 3]]], + ); + + $rows = $connection->query('SELECT metadata FROM items'); + + expect($rows[0]['metadata']) + ->toBe('{"key":"value","nested":[1,2,3]}') + ->not->toBe('Array'); + }); }); From c59f058ee84f75a5462b6379af284b181f74fda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 14 May 2026 10:06:21 +0200 Subject: [PATCH 09/13] fix(scope): remove direction from ScopeSortRenderer output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both PgSqlScopeSortRenderer and MySqlScopeSortRenderer were appending direction to the rendered expression. ScopedOrderBy passes direction separately to orderByRaw(), so the query builder was emitting COALESCE(...) ASC ASC — a syntax error at runtime. Co-Authored-By: Claude Sonnet 4.6 --- .../src/Query/MySqlScopeSortRenderer.php | 12 ++---------- .../Unit/Query/MySqlScopeSortRendererTest.php | 16 ++++++++-------- .../src/Query/PgSqlScopeSortRenderer.php | 7 ++----- .../Unit/Query/PgSqlScopeSortRendererTest.php | 16 ++++++++-------- 4 files changed, 20 insertions(+), 31 deletions(-) diff --git a/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php b/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php index 9d3f76d9..4a98c4cb 100644 --- a/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php +++ b/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php @@ -19,11 +19,7 @@ public function render(ScopeSortExpression $expression): string $this->validate($expression); if ($expression->paths === []) { - return sprintf( - '`%s` %s', - $expression->column, - strtoupper($expression->direction), - ); + return sprintf('`%s`', $expression->column); } $jsonParts = []; @@ -39,11 +35,7 @@ public function render(ScopeSortExpression $expression): string $jsonParts[] = sprintf('`%s`', $expression->column); - return sprintf( - 'COALESCE(%s) %s', - implode(', ', $jsonParts), - strtoupper($expression->direction), - ); + return sprintf('COALESCE(%s)', implode(', ', $jsonParts)); } /** diff --git a/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php b/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php index b4290727..519a0ca2 100644 --- a/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php +++ b/packages/scope-mysql/tests/Unit/Query/MySqlScopeSortRendererTest.php @@ -24,7 +24,7 @@ $sql = $renderer->render($expression); expect($sql)->toBe( - 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:en".price\')), JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:".price\')), `price`) ASC', + 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:en".price\')), JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:".price\')), `price`)', ); }); @@ -43,11 +43,11 @@ $sql = $renderer->render($expression); expect($sql)->toBe( - 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:eu.de".name\')), `name`) ASC', + 'COALESCE(JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:eu.de".name\')), `name`)', ); }); -it('falls back to plain ORDER BY column when the expression has no axis paths', function (): void { +it('falls back to plain column expression when the expression has no axis paths', function (): void { $renderer = new MySqlScopeSortRenderer(); $expression = new ScopeSortExpression( @@ -59,10 +59,10 @@ $sql = $renderer->render($expression); - expect($sql)->toBe('`price` ASC'); + expect($sql)->toBe('`price`'); }); -it('preserves direction asc or desc in the output', function (): void { +it('returns just the expression without direction so the query builder can append it', function (): void { $renderer = new MySqlScopeSortRenderer(); $asc = new ScopeSortExpression( @@ -79,8 +79,8 @@ direction: 'desc', ); - expect($renderer->render($asc))->toEndWith(' ASC') - ->and($renderer->render($desc))->toEndWith(' DESC'); + expect($renderer->render($asc))->not->toEndWith(' ASC') + ->and($renderer->render($desc))->not->toEndWith(' DESC'); }); it('validates fallback column, property, and json column identifiers against the safe pattern', function (): void { @@ -147,6 +147,6 @@ . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."store:".price\')), ' . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:de".price\')), ' . 'JSON_UNQUOTE(JSON_EXTRACT(`scopes`, \'$."geo:".price\')), ' - . '`price`) ASC', + . '`price`)', ); }); diff --git a/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php b/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php index c7628df0..3752b05a 100644 --- a/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php +++ b/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php @@ -18,10 +18,8 @@ public function render(ScopeSortExpression $expression): string { $this->validate($expression); - $direction = strtoupper($expression->direction); - if ($expression->paths === []) { - return "\"$expression->column\" $direction"; + return "\"$expression->column\""; } $parts = []; @@ -32,9 +30,8 @@ public function render(ScopeSortExpression $expression): string } $parts[] = "\"$expression->column\""; - $coalesce = 'COALESCE(' . implode(', ', $parts) . ')'; - return "$coalesce $direction"; + return 'COALESCE(' . implode(', ', $parts) . ')'; } /** diff --git a/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php b/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php index b51cbbc8..6786cde2 100644 --- a/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php +++ b/packages/scope-pgsql/tests/Unit/Query/PgSqlScopeSortRendererTest.php @@ -20,7 +20,7 @@ $sql = $renderer->render($expression); - expect($sql)->toBe('COALESCE("scopes"->\'store:store\'->>\'price\', "price") ASC'); + expect($sql)->toBe('COALESCE("scopes"->\'store:store\'->>\'price\', "price")'); }); it('renders a multi-axis sort with axes in declared priority order', function (): void { @@ -41,7 +41,7 @@ $sql = $renderer->render($expression); expect($sql)->toBe( - 'COALESCE("scopes"->\'store:store.en\'->>\'price\', "scopes"->\'store:store\'->>\'price\', "scopes"->\'geo:geo.de\'->>\'price\', "scopes"->\'geo:geo\'->>\'price\', "price") ASC', + 'COALESCE("scopes"->\'store:store.en\'->>\'price\', "scopes"->\'store:store\'->>\'price\', "scopes"->\'geo:geo.de\'->>\'price\', "scopes"->\'geo:geo\'->>\'price\', "price")', ); }); @@ -86,7 +86,7 @@ )))->toThrow(InvalidColumnException::class); }); -it('preserves direction asc or desc in the output', function (): void { +it('returns just the expression without direction so the query builder can append it', function (): void { $renderer = new PgSqlScopeSortRenderer(); $ascending = new ScopeSortExpression( @@ -103,11 +103,11 @@ direction: 'desc', ); - expect($renderer->render($ascending))->toEndWith(' ASC') - ->and($renderer->render($descending))->toEndWith(' DESC'); + expect($renderer->render($ascending))->not->toEndWith(' ASC') + ->and($renderer->render($descending))->not->toEndWith(' DESC'); }); -it('falls back to plain ORDER BY column when the expression has no axis paths', function (): void { +it('falls back to plain column expression when the expression has no axis paths', function (): void { $renderer = new PgSqlScopeSortRenderer(); $expression = new ScopeSortExpression( @@ -119,7 +119,7 @@ $sql = $renderer->render($expression); - expect($sql)->toBe('"price" ASC'); + expect($sql)->toBe('"price"'); }); it('embeds path segments containing dots correctly into PG jsonb paths (e.g. eu.de)', function (): void { @@ -136,5 +136,5 @@ $sql = $renderer->render($expression); - expect($sql)->toBe('COALESCE("scopes"->\'geo:eu.de\'->>\'price\', "price") ASC'); + expect($sql)->toBe('COALESCE("scopes"->\'geo:eu.de\'->>\'price\', "price")'); }); From 3958293cb30da6182dea447478beabad830f7056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 14 May 2026 10:06:43 +0200 Subject: [PATCH 10/13] fix(database-pgsql): JSON-encode array bindings before passing to PDO PDO silently casts PHP arrays to the string "Array" when bound as query parameters. For json/jsonb columns this causes an "invalid input syntax for type json" error at the database level. Co-Authored-By: Claude Sonnet 4.6 --- .../src/Connection/PgSqlConnection.php | 6 ++++ .../tests/Connection/PgSqlConnectionTest.php | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/packages/database-pgsql/src/Connection/PgSqlConnection.php b/packages/database-pgsql/src/Connection/PgSqlConnection.php index 2edb610d..9300b521 100644 --- a/packages/database-pgsql/src/Connection/PgSqlConnection.php +++ b/packages/database-pgsql/src/Connection/PgSqlConnection.php @@ -187,6 +187,12 @@ private function bindValues( ): void { foreach ($bindings as $key => $value) { $param = is_int($key) ? $key + 1 : $key; + + if (is_array($value)) { + $statement->bindValue($param, json_encode($value), PDO::PARAM_STR); + continue; + } + $type = match (true) { is_bool($value) => PDO::PARAM_BOOL, is_null($value) => PDO::PARAM_NULL, diff --git a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php index 3ab8c1cd..c68a86fc 100644 --- a/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php +++ b/packages/database-pgsql/tests/Connection/PgSqlConnectionTest.php @@ -685,4 +685,33 @@ protected function createPdo( expect(fn () => $connection->beginTransaction()) ->toThrow(TransactionException::class, 'Nested transactions are not supported'); }); + + it('JSON-encodes array bindings instead of casting them to the string "Array"', function (): void { + $config = createTestPgSqlConfig(); + $connection = new class ($config) extends PgSqlConnection + { + protected function createPdo( + string $dsn, + string $username, + string $password, + array $options, + ): PDO { + $pdo = createSqliteMockPdo($options); + $pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, metadata TEXT)'); + + return $pdo; + } + }; + + $connection->execute( + 'INSERT INTO items (metadata) VALUES (?)', + [['key' => 'value', 'nested' => [1, 2, 3]]], + ); + + $rows = $connection->query('SELECT metadata FROM items'); + + expect($rows[0]['metadata']) + ->toBe('{"key":"value","nested":[1,2,3]}') + ->not->toBe('Array'); + }); }); From 7313833f34715e1887e69f50cd096ba1d21e51f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Thu, 14 May 2026 14:45:04 +0200 Subject: [PATCH 11/13] fix(database): link entity extenders at boot so companions hydrate during HTTP requests EntityMetadataFactory is now a singleton and a boot callback discovers all entity classes and calls linkExtendersFrom() so extender metadata is populated before any repository hydration occurs. Previously linkExtenders() was only called from CLI migration commands, leaving companions unattached at runtime. Closes #73 Co-Authored-By: Claude Sonnet 4.6 --- packages/database/module.php | 17 ++++++++++++ .../src/Entity/EntityMetadataFactory.php | 27 +++++++++++++++++++ .../Entity/EntityMetadataFactoryTest.php | 24 +++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/packages/database/module.php b/packages/database/module.php index 55671f32..d76ad934 100644 --- a/packages/database/module.php +++ b/packages/database/module.php @@ -5,11 +5,28 @@ use Marko\Core\Container\ContainerInterface; use Marko\Core\Path\ProjectPaths; use Marko\Database\Connection\TransactionInterface; +use Marko\Database\Entity\EntityDiscovery; +use Marko\Database\Entity\EntityMetadataFactory; use Marko\Database\Seed\SeederDiscovery; use Marko\Database\Seed\SeederDiscoveryInterface; use Marko\Database\Seed\SeederRunner; return [ + 'singletons' => [ + EntityMetadataFactory::class, + ], + 'boot' => function ( + EntityDiscovery $discovery, + EntityMetadataFactory $metadataFactory, + ProjectPaths $paths, + ): void { + $entityClasses = array_merge( + $discovery->discoverInVendor($paths->vendor), + $discovery->discoverInModules($paths->modules), + $discovery->discoverInApp($paths->app), + ); + $metadataFactory->linkExtendersFrom($entityClasses); + }, 'bindings' => [ SeederDiscoveryInterface::class => SeederDiscovery::class, SeederRunner::class => function (ContainerInterface $container): SeederRunner { diff --git a/packages/database/src/Entity/EntityMetadataFactory.php b/packages/database/src/Entity/EntityMetadataFactory.php index 5a8cdcb8..33952a77 100644 --- a/packages/database/src/Entity/EntityMetadataFactory.php +++ b/packages/database/src/Entity/EntityMetadataFactory.php @@ -215,6 +215,33 @@ public function linkExtenders( return $linked; } + /** + * Scan a list of entity classes and link any extenders to their parent metadata. + * + * @param array $entityClasses + */ + public function linkExtendersFrom(array $entityClasses): void + { + $extenders = []; + foreach ($entityClasses as $entityClass) { + $reflection = new ReflectionClass($entityClass); + $tableAttrs = $reflection->getAttributes(Table::class); + if (count($tableAttrs) === 0) { + continue; + } + $tableAttr = $tableAttrs[0]->newInstance(); + if ($tableAttr->extends !== null) { + $extenders[$tableAttr->extends][] = $entityClass; + } + } + foreach ($extenders as $parentClass => $extenderClasses) { + if (!class_exists($parentClass, true)) { + throw EntityException::extenderParentClassNotFound($extenderClasses[0], $parentClass); + } + $this->linkExtenders($parentClass, $extenderClasses); + } + } + /** * Clear the metadata cache. */ diff --git a/packages/database/tests/Entity/EntityMetadataFactoryTest.php b/packages/database/tests/Entity/EntityMetadataFactoryTest.php index 1c2aa2ee..1f1a3ff4 100644 --- a/packages/database/tests/Entity/EntityMetadataFactoryTest.php +++ b/packages/database/tests/Entity/EntityMetadataFactoryTest.php @@ -531,6 +531,30 @@ class UntypedPropertyEntity extends Entity ->toThrow(EntityException::class, "Chained extension is not supported. $extender's parent $parent is itself an extender. Extend the root entity directly."); }); +it('linkExtendersFrom scans entity classes and links extenders to their parents', function (): void { + $this->factory->linkExtendersFrom([ + ExtenderParentEntity::class, + BasicExtenderEntity::class, + ]); + + $parentMetadata = $this->factory->parse(ExtenderParentEntity::class); + + expect($parentMetadata->extenders)->toBe([BasicExtenderEntity::class]); +}); + +it('linkExtendersFrom ignores entities without extends', function (): void { + $this->factory->linkExtendersFrom([ExtenderParentEntity::class]); + + $parentMetadata = $this->factory->parse(ExtenderParentEntity::class); + + expect($parentMetadata->extenders)->toBe([]); +}); + +it('linkExtendersFrom throws when an extender references a parent class that cannot be autoloaded', function (): void { + expect(fn () => $this->factory->linkExtendersFrom([ExtenderWithMissingParentEntity::class])) + ->toThrow(EntityException::class, 'does not exist'); +}); + it('clears cached metadata', function (): void { $entity = new #[Table('test')] class () extends Entity { From 712d9d0efae5e10a74b5f3e5e71459cf10eaf3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Fri, 15 May 2026 12:33:05 +0200 Subject: [PATCH 12/13] refactor(scope): rename getOverride/allOverrides to match framework bare-noun convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames HasScopesInterface accessor methods to align with the rest of the framework (Entity::companion/companions, CursorInterface::parameter/parameters): getOverride() → override() allOverrides() → overrides() Co-Authored-By: Claude Sonnet 4.6 --- packages/scope/src/Resolution/ScopeWalker.php | 4 ++-- packages/scope/src/Storage/HasScopes.php | 4 ++-- .../scope/src/Storage/HasScopesInterface.php | 4 ++-- .../ScopedOverridesPersistenceTest.php | 4 ++-- .../tests/Unit/Resolver/ScopeResolverTest.php | 8 +++---- .../tests/Unit/Storage/HasScopesTraitTest.php | 22 +++++++++---------- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/scope/src/Resolution/ScopeWalker.php b/packages/scope/src/Resolution/ScopeWalker.php index ec789ec2..845e40cb 100644 --- a/packages/scope/src/Resolution/ScopeWalker.php +++ b/packages/scope/src/Resolution/ScopeWalker.php @@ -40,7 +40,7 @@ public function walk( ); if ($matchedScope !== null) { - return ScopeWalkResult::found($overrides->getOverride($axis . ':' . $matchedScope, $property)); + return ScopeWalkResult::found($overrides->override($axis . ':' . $matchedScope, $property)); } } @@ -75,7 +75,7 @@ public function walkAt( ); if ($matchedScope !== null) { - return ScopeWalkResult::found($overrides->getOverride($axis . ':' . $matchedScope, $property)); + return ScopeWalkResult::found($overrides->override($axis . ':' . $matchedScope, $property)); } return ScopeWalkResult::notFound(); diff --git a/packages/scope/src/Storage/HasScopes.php b/packages/scope/src/Storage/HasScopes.php index 7ad2be6c..b8ee47fd 100644 --- a/packages/scope/src/Storage/HasScopes.php +++ b/packages/scope/src/Storage/HasScopes.php @@ -34,7 +34,7 @@ public function setOverride( $this->scopes = $scopes; } - public function getOverride( + public function override( string $scopeKey, string $property, ): mixed { @@ -44,7 +44,7 @@ public function getOverride( /** * @return array> */ - public function allOverrides(): array + public function overrides(): array { return $this->scopes ?? []; } diff --git a/packages/scope/src/Storage/HasScopesInterface.php b/packages/scope/src/Storage/HasScopesInterface.php index 6f7e5605..a6ab627e 100644 --- a/packages/scope/src/Storage/HasScopesInterface.php +++ b/packages/scope/src/Storage/HasScopesInterface.php @@ -12,7 +12,7 @@ public function setOverride( mixed $value, ): void; - public function getOverride( + public function override( string $scopeKey, string $property, ): mixed; @@ -30,5 +30,5 @@ public function clearOverride( /** * @return array> */ - public function allOverrides(): array; + public function overrides(): array; } diff --git a/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php index b1d8b341..a215cd2e 100644 --- a/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php +++ b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php @@ -221,8 +221,8 @@ public function lastInsertId(): int expect($found)->not->toBeNull() ->and($foundOverrides)->not->toBeNull() - ->and($foundOverrides->getOverride('geo:eu.de', 'name'))->toBe('Hemd') - ->and($foundOverrides->allOverrides())->toBe(['geo:eu.de' => ['name' => 'Hemd']]); + ->and($foundOverrides->override('geo:eu.de', 'name'))->toBe('Hemd') + ->and($foundOverrides->overrides())->toBe(['geo:eu.de' => ['name' => 'Hemd']]); }); it('writes null into the scopes column when all overrides are cleared', function (): void { diff --git a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php index adc4d221..4d2beeb1 100644 --- a/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php +++ b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php @@ -164,7 +164,7 @@ function makeResolver(): array $resolver->clearOverride($product, 'name', $scope); expect($companion->hasOverride('store:global.us', 'name'))->toBeFalse() - ->and($companion->getOverride('store:global.us', 'sku'))->toBe('SKU-US'); + ->and($companion->override('store:global.us', 'sku'))->toBe('SKU-US'); }); it('throws ScopeContextException when setOverride targets a property without Scoped', function (): void { @@ -237,7 +237,7 @@ function (): void { $scope = new Scope('store', 'global.us'); $resolver->setOverride($product, 'name', 'Direct Override', $scope); - expect($product->getOverride('store:global.us', 'name'))->toBe('Direct Override') + expect($product->override('store:global.us', 'name'))->toBe('Direct Override') ->and($product->companions())->toBeEmpty(); }, ); @@ -373,7 +373,7 @@ function (): void { $scope = new Scope('store', 'global.us'); $resolver->setOverride($product, 'name', 'Trait Override', $scope); - expect($product->getOverride('store:global.us', 'name'))->toBe('Trait Override') + expect($product->override('store:global.us', 'name'))->toBe('Trait Override') ->and($product->companions())->toBeEmpty(); }); @@ -390,7 +390,7 @@ function (): void { $scope = new Scope('store', 'global.us'); $resolver->setOverride($product, 'name', 'Companion Override', $scope); - expect($companion->getOverride('store:global.us', 'name'))->toBe('Companion Override'); + expect($companion->override('store:global.us', 'name'))->toBe('Companion Override'); }); it('ScopeResolver resolved works with a manual HasScopesInterface companion (not ScopedOverridesEntity)', function (): void { diff --git a/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php index 24d41423..02d7ceca 100644 --- a/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php +++ b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php @@ -25,10 +25,10 @@ class TraitProduct extends Entity implements HasScopesInterface $entity = new TraitProduct(); expect(method_exists($entity, 'setOverride'))->toBeTrue() - ->and(method_exists($entity, 'getOverride'))->toBeTrue() + ->and(method_exists($entity, 'override'))->toBeTrue() ->and(method_exists($entity, 'hasOverride'))->toBeTrue() ->and(method_exists($entity, 'clearOverride'))->toBeTrue() - ->and(method_exists($entity, 'allOverrides'))->toBeTrue(); + ->and(method_exists($entity, 'overrides'))->toBeTrue(); }); it('stores multiple overrides keyed by scopeKey and property', function (): void { @@ -37,17 +37,17 @@ class TraitProduct extends Entity implements HasScopesInterface $entity->setOverride('locale:de', 'name', 'Hallo'); $entity->setOverride('geo:eu.de', 'price', 19.99); - expect($entity->allOverrides())->toBe([ + expect($entity->overrides())->toBe([ 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], 'locale:de' => ['name' => 'Hallo'], ]); }); -it('returns null for unknown scopeKey or property via getOverride', function (): void { +it('returns null for unknown scopeKey or property via override', function (): void { $entity = new TraitProduct(); - expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() - ->and($entity->getOverride('unknown', 'price'))->toBeNull(); + expect($entity->override('geo:eu.de', 'name'))->toBeNull() + ->and($entity->override('unknown', 'price'))->toBeNull(); }); it('returns false for hasOverride when no override exists', function (): void { @@ -64,7 +64,7 @@ class TraitProduct extends Entity implements HasScopesInterface $entity->setOverride('geo:eu.de', 'name', null); expect($entity->hasOverride('geo:eu.de', 'name'))->toBeTrue() - ->and($entity->getOverride('geo:eu.de', 'name'))->toBeNull(); + ->and($entity->override('geo:eu.de', 'name'))->toBeNull(); }); it('clears a single property override leaving others intact', function (): void { @@ -73,8 +73,8 @@ class TraitProduct extends Entity implements HasScopesInterface $entity->setOverride('geo:eu.de', 'price', 19.99); $entity->clearOverride('geo:eu.de', 'name'); - expect($entity->getOverride('geo:eu.de', 'name'))->toBeNull() - ->and($entity->getOverride('geo:eu.de', 'price'))->toBe(19.99); + expect($entity->override('geo:eu.de', 'name'))->toBeNull() + ->and($entity->override('geo:eu.de', 'price'))->toBe(19.99); }); it('sets scopes to null when the last override is cleared', function (): void { @@ -116,8 +116,8 @@ function (): void { expect($reflection->isInterface())->toBeTrue() ->and($reflection->hasMethod('setOverride'))->toBeTrue() - ->and($reflection->hasMethod('getOverride'))->toBeTrue() + ->and($reflection->hasMethod('override'))->toBeTrue() ->and($reflection->hasMethod('hasOverride'))->toBeTrue() ->and($reflection->hasMethod('clearOverride'))->toBeTrue() - ->and($reflection->hasMethod('allOverrides'))->toBeTrue(); + ->and($reflection->hasMethod('overrides'))->toBeTrue(); }); From 3e070537e1b2e6bdcfe4e3ab3cae2f2c837b749e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Biarda?= <1135380+michalbiarda@users.noreply.github.com> Date: Fri, 15 May 2026 12:58:18 +0200 Subject: [PATCH 13/13] refactor(scope): deduplicate walker matching loop and resolver prologue Extract a shared findFirstMatch() helper in ScopeWalker so walk() and walkAt() no longer repeat the hierarchy walk + override scan. Extract assertScopedAndFindStorage() in ScopeResolver to share the prologue of setOverride() and clearOverride(). Inline the trivial getRegistry() wrapper and use Scope::toString() in place of inline "axis:path" concatenation so the format lives in one place. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/scope/src/Resolution/ScopeWalker.php | 40 +++++------ packages/scope/src/Resolver/ScopeResolver.php | 66 +++++++++---------- 2 files changed, 52 insertions(+), 54 deletions(-) diff --git a/packages/scope/src/Resolution/ScopeWalker.php b/packages/scope/src/Resolution/ScopeWalker.php index 845e40cb..8352d877 100644 --- a/packages/scope/src/Resolution/ScopeWalker.php +++ b/packages/scope/src/Resolution/ScopeWalker.php @@ -31,16 +31,10 @@ public function walk( continue; } - $hierarchy = $registry->getHierarchy($axis); - $walked = $hierarchy->walkUp($path); + $result = $this->findFirstMatch($overrides, $property, $axis, $path, $registry); - $matchedScope = array_find( - $walked, - fn (string $scope) => $overrides->hasOverride($axis . ':' . $scope, $property), - ); - - if ($matchedScope !== null) { - return ScopeWalkResult::found($overrides->override($axis . ':' . $matchedScope, $property)); + if ($result->isFound()) { + return $result; } } @@ -60,24 +54,32 @@ public function walkAt( Scope $scope, ScopeRegistryInterface $registry, ): ScopeWalkResult { - $axis = $scope->axisName; - - if (!in_array($axis, $axes, true)) { + if (!in_array($scope->axisName, $axes, true)) { return ScopeWalkResult::notFound(); } - $hierarchy = $registry->getHierarchy($axis); - $walked = $hierarchy->walkUp($scope->path); + return $this->findFirstMatch($overrides, $property, $scope->axisName, $scope->path, $registry); + } + + /** + * @throws UnknownAxisException|UnknownScopeException + */ + private function findFirstMatch( + HasScopesInterface $overrides, + string $property, + string $axis, + string $path, + ScopeRegistryInterface $registry, + ): ScopeWalkResult { + $walked = $registry->getHierarchy($axis)->walkUp($path); $matchedScope = array_find( $walked, fn (string $scopePath) => $overrides->hasOverride($axis . ':' . $scopePath, $property), ); - if ($matchedScope !== null) { - return ScopeWalkResult::found($overrides->override($axis . ':' . $matchedScope, $property)); - } - - return ScopeWalkResult::notFound(); + return $matchedScope !== null + ? ScopeWalkResult::found($overrides->override($axis . ':' . $matchedScope, $property)) + : ScopeWalkResult::notFound(); } } diff --git a/packages/scope/src/Resolver/ScopeResolver.php b/packages/scope/src/Resolver/ScopeResolver.php index 57fc9a97..545a3395 100644 --- a/packages/scope/src/Resolver/ScopeResolver.php +++ b/packages/scope/src/Resolver/ScopeResolver.php @@ -10,7 +10,6 @@ use Marko\Scope\Exceptions\UnknownAxisException; use Marko\Scope\Exceptions\UnknownScopeException; use Marko\Scope\Metadata\ScopeMetadataFactory; -use Marko\Scope\Registry\ScopeRegistryInterface; use Marko\Scope\Resolution\ScopeWalker; use Marko\Scope\Scope; use Marko\Scope\Storage\HasScopesInterface; @@ -36,14 +35,17 @@ public function resolved( throw ScopeContextException::unknownProperty($entityClass, $property); } - $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); - $axes = $scopeMetadata->axesForProperty($property); - $registry = $this->getRegistry(); - + $axes = $this->scopeMetadataFactory->for($entityClass)->axesForProperty($property); $storage = $this->findStorage($entity); if ($storage !== null) { - $result = $this->scopeWalker->walk($storage, $property, $axes, $this->scopeContext, $registry); + $result = $this->scopeWalker->walk( + $storage, + $property, + $axes, + $this->scopeContext, + $this->scopeContext->registry(), + ); if ($result->isFound()) { return $result->value(); @@ -62,14 +64,11 @@ public function resolvedAt( Scope $scope, ): mixed { $entityClass = get_class($entity); - $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); - $axes = $scopeMetadata->axesForProperty($property); - $registry = $this->getRegistry(); - + $axes = $this->scopeMetadataFactory->for($entityClass)->axesForProperty($property); $storage = $this->findStorage($entity); if ($storage !== null) { - $result = $this->scopeWalker->walkAt($storage, $property, $axes, $scope, $registry); + $result = $this->scopeWalker->walkAt($storage, $property, $axes, $scope, $this->scopeContext->registry()); if ($result->isFound()) { return $result->value(); @@ -88,16 +87,11 @@ public function setOverride( mixed $value, Scope $scope, ): void { - $entityClass = get_class($entity); - $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); - - if (!$scopeMetadata->isScoped($property)) { - throw ScopeContextException::propertyNotScoped($property, $entityClass); - } - - $storage = $this->findStorage($entity); + $storage = $this->assertScopedAndFindStorage($entity, $property); if ($storage === null) { + $entityClass = get_class($entity); + throw new ScopeContextException( message: "Entity '$entityClass' has no scope storage: it must implement HasScopesInterface or have a companion that does.", context: "Setting scope override for property '$property' on '$entityClass'", @@ -105,8 +99,7 @@ public function setOverride( ); } - $scopeKey = $scope->axisName . ':' . $scope->path; - $storage->setOverride($scopeKey, $property, $value); + $storage->setOverride($scope->toString(), $property, $value); } /** @@ -117,21 +110,29 @@ public function clearOverride( string $property, Scope $scope, ): void { - $entityClass = get_class($entity); - $scopeMetadata = $this->scopeMetadataFactory->for($entityClass); + $storage = $this->assertScopedAndFindStorage($entity, $property); - if (!$scopeMetadata->isScoped($property)) { - throw ScopeContextException::propertyNotScoped($property, $entityClass); + if ($storage === null) { + return; } - $storage = $this->findStorage($entity); + $storage->clearOverride($scope->toString(), $property); + } - if ($storage === null) { - return; + /** + * @throws ScopeContextException|UnknownAxisException + */ + private function assertScopedAndFindStorage( + Entity $entity, + string $property, + ): ?HasScopesInterface { + $entityClass = get_class($entity); + + if (!$this->scopeMetadataFactory->for($entityClass)->isScoped($property)) { + throw ScopeContextException::propertyNotScoped($property, $entityClass); } - $scopeKey = $scope->axisName . ':' . $scope->path; - $storage->clearOverride($scopeKey, $property); + return $this->findStorage($entity); } private function findStorage(Entity $entity): ?HasScopesInterface @@ -145,9 +146,4 @@ private function findStorage(Entity $entity): ?HasScopesInterface fn ($companion) => $companion instanceof HasScopesInterface, ); } - - private function getRegistry(): ScopeRegistryInterface - { - return $this->scopeContext->registry(); - } }