Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .claude/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
37 changes: 37 additions & 0 deletions .claude/plans/scope/001-bootstrap-marko-scope.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 36 additions & 0 deletions .claude/plans/scope/002-scope-value-objects.md
Original file line number Diff line number Diff line change
@@ -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).
35 changes: 35 additions & 0 deletions .claude/plans/scope/003-scope-hierarchy.md
Original file line number Diff line number Diff line change
@@ -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<string, bool>) 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.
35 changes: 35 additions & 0 deletions .claude/plans/scope/004-scope-exceptions.md
Original file line number Diff line number Diff line change
@@ -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`
27 changes: 27 additions & 0 deletions .claude/plans/scope/005-scoped-attribute.md
Original file line number Diff line number Diff line change
@@ -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.)
31 changes: 31 additions & 0 deletions .claude/plans/scope/006-scope-registry.md
Original file line number Diff line number Diff line change
@@ -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.)
30 changes: 30 additions & 0 deletions .claude/plans/scope/007-scope-context.md
Original file line number Diff line number Diff line change
@@ -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<string, string>` (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.)
30 changes: 30 additions & 0 deletions .claude/plans/scope/008-scope-metadata.md
Original file line number Diff line number Diff line change
@@ -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.)
44 changes: 44 additions & 0 deletions .claude/plans/scope/009-scoped-data-serializer.md
Original file line number Diff line number Diff line change
@@ -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.
Loading