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/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/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/_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..d174c326 --- /dev/null +++ b/.claude/plans/scope/_plan.md @@ -0,0 +1,201 @@ +# 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 | +| 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 | +| 038 | Remove `ScopedOverridesEntity` — single-approach cleanup | 033, 034, 035, 036, 037 | 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..52948531 --- /dev/null +++ b/docs/src/content/docs/packages/scope-mysql.md @@ -0,0 +1,131 @@ +--- +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 declared by the `HasScopes` trait on the entity; 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 + +### Adding the `scopes` column + +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" +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..4c73b335 --- /dev/null +++ b/docs/src/content/docs/packages/scope-pgsql.md @@ -0,0 +1,110 @@ +--- +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. The `scopes` column is declared by the `HasScopes` trait on the entity. 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 + +### Adding the `scopes` column + +Implement `HasScopesInterface` and use the `HasScopes` trait on the entity. The trait declares the `scopes` JSONB column directly; no companion class is needed: + +```php title="app/catalog/Entity/Product.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..5555c8ea --- /dev/null +++ b/docs/src/content/docs/packages/scope.md @@ -0,0 +1,332 @@ +--- +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`, `HasScopesInterface`, the `HasScopes` trait, and the `ScopedOrderBy` query specification --- but no database driver. Applications must install `marko/scope-mysql` or `marko/scope-pgsql` to persist and query overrides. Scope storage is provided by the `HasScopes` trait: entities implement `HasScopesInterface` and include the trait, which declares a `$scopes` JSON column automatically. + +## 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. Implement `HasScopesInterface` and use the `HasScopes` trait on your entity. The trait declares a `$scopes` JSON column automatically --- no separate migration helper is required: + +```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\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\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. 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` + +| 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. + +**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/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/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/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/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'); + }); }); 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')) { 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/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/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/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/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/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'), 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 { 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..4a98c4cb --- /dev/null +++ b/packages/scope-mysql/src/Query/MySqlScopeSortRenderer.php @@ -0,0 +1,70 @@ +validate($expression); + + if ($expression->paths === []) { + return sprintf('`%s`', $expression->column); + } + + $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)', implode(', ', $jsonParts)); + } + + /** + * @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..6f0f6898 --- /dev/null +++ b/packages/scope-mysql/tests/Feature/AutoMigrationTest.php @@ -0,0 +1,190 @@ +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..519a0ca2 --- /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`)', + ); +}); + +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`)', + ); +}); + +it('falls back to plain column expression 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`'); +}); + +it('returns just the expression without direction so the query builder can append it', 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))->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 { + $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`)', + ); +}); 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..3752b05a --- /dev/null +++ b/packages/scope-pgsql/src/Query/PgSqlScopeSortRenderer.php @@ -0,0 +1,60 @@ +validate($expression); + + if ($expression->paths === []) { + return "\"$expression->column\""; + } + + $parts = []; + + foreach ($expression->paths as $path) { + $jsonKey = $path['axis'] . ':' . $path['path']; + $parts[] = "\"$expression->jsonColumn\"->'$jsonKey'->>'$expression->property'"; + } + + $parts[] = "\"$expression->column\""; + + return 'COALESCE(' . implode(', ', $parts) . ')'; + } + + /** + * @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..9e5fc7fc --- /dev/null +++ b/packages/scope-pgsql/tests/Feature/AutoMigrationTest.php @@ -0,0 +1,181 @@ +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..6786cde2 --- /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")'); +}); + +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")', + ); +}); + +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('returns just the expression without direction so the query builder can append it', 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))->not->toEndWith(' ASC') + ->and($renderer->render($descending))->not->toEndWith(' DESC'); +}); + +it('falls back to plain column expression 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"'); +}); + +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")'); +}); 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..564a756a --- /dev/null +++ b/packages/scope/README.md @@ -0,0 +1,49 @@ +# marko/scope + +Scoped attributes for entities with multi-axis hierarchical fallback. + +## Installation + +```bash +composer require marko/scope +``` + +A driver package is also required: + +```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\Storage\HasScopes; +use Marko\Scope\Storage\HasScopesInterface; + +#[Table('products')] +class Product extends Entity implements HasScopesInterface +{ + use HasScopes; + + #[Column(length: 255)] + #[Scoped(axes: ['locale'])] + public string $name = ''; +} + +// Set a scoped override +$scopeResolver->setOverride($product, 'name', 'Widget DE', new Scope('locale', 'de')); + +// Resolve with hierarchy fallback (de-DE walks up to de) +$scopeContext->in('locale', 'de-DE'); +$localizedName = $scopeResolver->resolved($product, 'name'); +``` + +## 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..c68a9bfc --- /dev/null +++ b/packages/scope/src/Exceptions/ScopeConfigurationException.php @@ -0,0 +1,54 @@ + */ + 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..8352d877 --- /dev/null +++ b/packages/scope/src/Resolution/ScopeWalker.php @@ -0,0 +1,85 @@ + $axes + * @throws UnknownAxisException|UnknownScopeException + */ + public function walk( + HasScopesInterface $overrides, + string $property, + array $axes, + ScopeContext $context, + ScopeRegistryInterface $registry, + ): ScopeWalkResult { + foreach ($axes as $axis) { + $path = $context->get($axis); + + if ($path === null) { + continue; + } + + $result = $this->findFirstMatch($overrides, $property, $axis, $path, $registry); + + if ($result->isFound()) { + return $result; + } + } + + return ScopeWalkResult::notFound(); + } + + /** + * Walk overrides for a single explicit scope, ignoring any ambient ScopeContext. + * + * @param list $axes + * @throws UnknownAxisException|UnknownScopeException + */ + public function walkAt( + HasScopesInterface $overrides, + string $property, + array $axes, + Scope $scope, + ScopeRegistryInterface $registry, + ): ScopeWalkResult { + if (!in_array($scope->axisName, $axes, true)) { + return ScopeWalkResult::notFound(); + } + + 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), + ); + + 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 new file mode 100644 index 00000000..545a3395 --- /dev/null +++ b/packages/scope/src/Resolver/ScopeResolver.php @@ -0,0 +1,149 @@ +scopeMetadataFactory->for($entityClass)->axesForProperty($property); + $storage = $this->findStorage($entity); + + if ($storage !== null) { + $result = $this->scopeWalker->walk( + $storage, + $property, + $axes, + $this->scopeContext, + $this->scopeContext->registry(), + ); + + if ($result->isFound()) { + return $result->value(); + } + } + + return $entity->{$property}; + } + + /** + * @throws UnknownAxisException|UnknownScopeException + */ + public function resolvedAt( + Entity $entity, + string $property, + Scope $scope, + ): mixed { + $entityClass = get_class($entity); + $axes = $this->scopeMetadataFactory->for($entityClass)->axesForProperty($property); + $storage = $this->findStorage($entity); + + if ($storage !== null) { + $result = $this->scopeWalker->walkAt($storage, $property, $axes, $scope, $this->scopeContext->registry()); + + if ($result->isFound()) { + return $result->value(); + } + } + + return $entity->{$property}; + } + + /** + * @throws ScopeContextException|UnknownAxisException + */ + public function setOverride( + Entity $entity, + string $property, + mixed $value, + Scope $scope, + ): void { + $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'", + suggestion: "Add 'use HasScopes; implements HasScopesInterface;' to '$entityClass', or register and attach a companion class that implements HasScopesInterface.", + ); + } + + $storage->setOverride($scope->toString(), $property, $value); + } + + /** + * @throws ScopeContextException|UnknownAxisException + */ + public function clearOverride( + Entity $entity, + string $property, + Scope $scope, + ): void { + $storage = $this->assertScopedAndFindStorage($entity, $property); + + if ($storage === null) { + return; + } + + $storage->clearOverride($scope->toString(), $property); + } + + /** + * @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); + } + + return $this->findStorage($entity); + } + + private function findStorage(Entity $entity): ?HasScopesInterface + { + if ($entity instanceof HasScopesInterface) { + return $entity; + } + + return array_find( + $entity->companions(), + fn ($companion) => $companion instanceof HasScopesInterface, + ); + } +} 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/HasScopes.php b/packages/scope/src/Storage/HasScopes.php new file mode 100644 index 00000000..b8ee47fd --- /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 override( + string $scopeKey, + string $property, + ): mixed { + return $this->scopes[$scopeKey][$property] ?? null; + } + + /** + * @return array> + */ + public function overrides(): 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..a6ab627e --- /dev/null +++ b/packages/scope/src/Storage/HasScopesInterface.php @@ -0,0 +1,34 @@ +> + */ + public function overrides(): array; +} 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/Validation/ScopedEntityValidator.php b/packages/scope/src/Validation/ScopedEntityValidator.php new file mode 100644 index 00000000..02b959fb --- /dev/null +++ b/packages/scope/src/Validation/ScopedEntityValidator.php @@ -0,0 +1,59 @@ +scopeMetadataFactory->for($entityClass); + + if (!$scopeMetadata->hasScopedProperties()) { + return; + } + + if (is_a($entityClass, HasScopesInterface::class, true)) { + return; + } + + $entityMetadata = $this->entityMetadataFactory->parse($entityClass); + + $hasCompatibleCompanion = array_any( + $entityMetadata->extenders, + fn (string $extender) => is_a($extender, HasScopesInterface::class, true), + ); + + if ($hasCompatibleCompanion) { + return; + } + + throw ScopeConfigurationException::missingScopesStorage($entityClass); + } +} diff --git a/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php new file mode 100644 index 00000000..104ee937 --- /dev/null +++ b/packages/scope/tests/Feature/ScopedOverridesEntityDirtyTrackingTest.php @@ -0,0 +1,121 @@ +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..a215cd2e --- /dev/null +++ b/packages/scope/tests/Feature/ScopedOverridesPersistenceTest.php @@ -0,0 +1,312 @@ +}> $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); + + /** @var ProductScopedOverrides $foundOverrides */ + $foundOverrides = $found->companion(ProductScopedOverrides::class); + + expect($found)->not->toBeNull() + ->and($foundOverrides)->not->toBeNull() + ->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 { + $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..f893a011 --- /dev/null +++ b/packages/scope/tests/Unit/ReadmeTest.php @@ -0,0 +1,61 @@ +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'); +}); + +it('shows HasScopes trait usage in the quick example', 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'); +}); 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..f2477612 --- /dev/null +++ b/packages/scope/tests/Unit/Resolution/ScopeWalkerTest.php @@ -0,0 +1,314 @@ + */ + private array $builtAxes; + + public function __construct(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(); +}); + +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 new file mode 100644 index 00000000..4d2beeb1 --- /dev/null +++ b/packages/scope/tests/Unit/Resolver/ScopeResolverTest.php @@ -0,0 +1,418 @@ + ['global', 'global.us']]): ScopeRegistryInterface +{ + return new class ($axes) implements ScopeRegistryInterface + { + /** @var array */ + private array $builtAxes; + + public function __construct(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(); + + return [$registry, $context, $scopeMetaFactory, $walker]; +} + +function makeResolver(): array +{ + [$registry, $context, $scopeMetaFactory, $walker] = makeResolverSetup(); + $resolver = new ScopeResolver($scopeMetaFactory, $walker, $context); + + 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(); + $companion = new ManualCompanionProduct(); + $companion->setOverride('store:global.us', 'name', 'US Name'); + $product->attachCompanion($companion); + + $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(); + $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); + + expect($result)->toBe('Global Name'); +}); + +it('clears an override via clearOverride leaving the companion otherwise intact', function (): void { + [$resolver] = makeResolver(); + + $product = new ResolverProduct(); + $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); + + expect($companion->hasOverride('store:global.us', 'name'))->toBeFalse() + ->and($companion->override('store:global.us', 'sku'))->toBe('SKU-US'); +}); + +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'; + $companion = new ManualCompanionProduct(); + // No override set for 'name' + $product->attachCompanion($companion); + + $result = $resolver->resolved($product, 'name'); + + 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( + '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->override('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(); + $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('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 ManualCompanionProduct(); + $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(); +}); + +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->override('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->override('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/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/HasScopesTraitTest.php b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php new file mode 100644 index 00000000..02d7ceca --- /dev/null +++ b/packages/scope/tests/Unit/Storage/HasScopesTraitTest.php @@ -0,0 +1,123 @@ +toBeTrue() + ->and(method_exists($entity, 'override'))->toBeTrue() + ->and(method_exists($entity, 'hasOverride'))->toBeTrue() + ->and(method_exists($entity, 'clearOverride'))->toBeTrue() + ->and(method_exists($entity, 'overrides'))->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->overrides())->toBe([ + 'geo:eu.de' => ['name' => 'Hemd', 'price' => 19.99], + 'locale:de' => ['name' => 'Hallo'], + ]); +}); + +it('returns null for unknown scopeKey or property via override', function (): void { + $entity = new TraitProduct(); + + 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 { + $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->override('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->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 { + $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('override'))->toBeTrue() + ->and($reflection->hasMethod('hasOverride'))->toBeTrue() + ->and($reflection->hasMethod('clearOverride'))->toBeTrue() + ->and($reflection->hasMethod('overrides'))->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/Validation/ScopedEntityValidatorTest.php b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php new file mode 100644 index 00000000..b158a1f6 --- /dev/null +++ b/packages/scope/tests/Unit/Validation/ScopedEntityValidatorTest.php @@ -0,0 +1,170 @@ + $validator->validate(ValidatorTraitScopedProduct::class))->not->toThrow(Throwable::class); +}); + +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); + + expect(fn () => $validator->validate(ValidatorCompanionScopedProduct::class))->not->toThrow(Throwable::class); +}); + +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); + + expect(fn () => $validator->validate(ValidatorPlainProduct::class))->not->toThrow(Throwable::class); +}); + +it( + 'throws ScopeConfigurationException missingScopesStorage when entity has scoped properties but no storage', + function (): void { + $scopeFactory = new ScopeMetadataFactory(makeValidatorRegistry()); + $entityFactory = new EntityMetadataFactory(); + $validator = new ScopedEntityValidator($scopeFactory, $entityFactory); + + expect(fn () => $validator->validate(ValidatorScopedProduct::class)) + ->toThrow(ScopeConfigurationException::class); + }, +); + +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); + + try { + $validator->validate(ValidatorScopedProduct::class); + 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'); + } +});