Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Task 001: EntityExtension base class + `#[ExtensionOf]` attribute

**Status**: completed
**Depends on**: none
**Retry count**: 0

## Description
Create the two foundational primitives for the entity extension system: the `EntityExtension` abstract base class (parallel to `Entity`) and the `#[ExtensionOf]` attribute that declares which entity class an extension targets. These have no logic — they're the type anchors everything else builds on.

## Context
- Related files: `packages/database/src/Entity/Entity.php`, `packages/database/src/Attributes/Column.php`
- Both live in `packages/database` — no new package needed
- `ExtensionOf` cannot be a class name (`extends` is a reserved PHP keyword, class names are case-insensitive so `Extends` is also forbidden); use `ExtensionOf`
- The attribute target is `TARGET_CLASS` — it goes on the extension class, not on properties
- The attribute MUST be declared as `readonly` and constructed with constructor property promotion, mirroring `packages/database/src/Attributes/Table.php`
- The attribute's stored class is exposed as a public `entityClass` property (typed `string`, but documented as `class-string<Entity>` via phpdoc) for consumers to read
- Validation that the argument is a valid `Entity` subclass happens in discovery (task 003), not in the attribute constructor
- `EntityExtension` is `abstract` and has no body — pure type anchor (parallel to `Entity`)

## Requirements (Test Descriptions)
- [ ] `it can be extended to create a concrete extension class`
- [ ] `it rejects direct instantiation as an abstract class`
- [ ] `it has an ExtensionOf attribute that accepts an entity class string`
- [ ] `it targets class-level application only`
- [ ] `it stores the entity class in the attribute`

## Acceptance Criteria
- All requirements have passing tests
- `EntityExtension` lives at `Marko\Database\Entity\EntityExtension`
- `ExtensionOf` lives at `Marko\Database\Attributes\ExtensionOf`
- Code follows project standards
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Task 002: ExtensionMetadata + EntityExtensionMetadataFactory

**Status**: completed
**Depends on**: [001]
**Retry count**: 0

## Description
Create `ExtensionMetadata` — a value object that holds parsed column and property metadata for a single extension class — and `EntityExtensionMetadataFactory` that parses an `EntityExtension` subclass via reflection, extracting `#[Column]`-annotated properties. This is similar to `EntityMetadataFactory` but without requiring `#[Table]` or a primary key.

## Context
- Related files:
- `packages/database/src/Entity/EntityMetadata.php` — reference shape
- `packages/database/src/Entity/EntityMetadataFactory.php` — reference for property parsing logic
- `packages/database/src/Entity/PropertyMetadata.php`
- `packages/database/src/Entity/ColumnMetadata.php`
- `packages/database/src/Attributes/Column.php`
- `ExtensionMetadata` holds: `extensionClass`, `entityClass`, `properties: array<string, PropertyMetadata>`, `columns: array<ColumnMetadata>`. It MUST be declared `readonly` with constructor property promotion (parallel to `EntityMetadata`).
- The factory must reuse the same PHP→DB type inference and camelCase→snake_case logic as `EntityMetadataFactory`; do not duplicate with different behaviour, but duplication is acceptable since the classes have different validation rules
- BackedEnum default values must be converted to their backing value, matching `EntityMetadataFactory` behaviour
- JSON column type/nullable mismatch validation (parallel to `EntityMetadataFactory`) must be enforced
- Extension classes must have at least one `#[Column]` property — throw `EntityException` if none found (new factory method on `EntityException`: `extensionHasNoColumns`)
- Extension classes must not declare a primary key column — throw `EntityException` if one is found (new factory method: `extensionDeclaresPrimaryKey`)
- Extension classes must not declare relationships (`#[HasOne]`, `#[HasMany]`, `#[BelongsTo]`, `#[BelongsToMany]`) — these are out of scope; throw `EntityException` if any found (new factory method: `extensionDeclaresRelationship`)
- Extension classes must not declare `#[Table]` or `#[Index]` attributes — throw `EntityException` if any found
- The factory reads the `#[ExtensionOf]` attribute on the extension class to populate `entityClass`; if missing, throw `EntityException` (new factory method: `extensionMissingExtensionOf`)

## Requirements (Test Descriptions)
- [ ] `it parses public Column-annotated properties into ExtensionMetadata`
- [ ] `it stores the extension class and entity class on ExtensionMetadata`
- [ ] `it throws when extension class has no Column-annotated properties`
- [ ] `it throws when extension class declares a primary key column`
- [ ] `it throws when extension class declares a relationship attribute`
- [ ] `it throws when extension class is missing the ExtensionOf attribute`
- [ ] `it infers database types from PHP scalar types`
- [ ] `it converts camelCase property names to snake_case column names`
- [ ] `it uses explicit column name from Column attribute when provided`
- [ ] `it correctly marks nullable properties`
- [ ] `it captures declared default values`
- [ ] `it converts BackedEnum default values to their backing value`
- [ ] `it throws when extension class does not extend EntityExtension`
- [ ] `it throws when a json column type does not match the PHP array type`
- [ ] `it caches parsed metadata by class`

## Acceptance Criteria
- All requirements have passing tests
- `ExtensionMetadata` lives at `Marko\Database\Entity\ExtensionMetadata`
- `EntityExtensionMetadataFactory` lives at `Marko\Database\Entity\EntityExtensionMetadataFactory`
- New static factory methods added to `Marko\Database\Exceptions\EntityException`:
- `extensionHasNoColumns(string $extensionClass)`
- `extensionDeclaresPrimaryKey(string $extensionClass, string $propertyName)`
- `extensionDeclaresRelationship(string $extensionClass, string $propertyName)`
- `extensionDeclaresTableAttribute(string $extensionClass)` (only if `#[Table]` is found on an extension)
- `extensionMissingExtensionOf(string $extensionClass)`
- `notExtendsEntityExtension(string $extensionClass)`
- Code follows project standards
50 changes: 50 additions & 0 deletions .claude/plans/entity-extension/003-registry-and-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Task 003: EntityExtensionRegistry + EntityExtensionDiscovery

**Status**: completed
**Depends on**: [001, 002]
**Retry count**: 0

## Description
Create `EntityExtensionRegistry` — a singleton that maps entity class strings to their registered extension class strings — and `EntityExtensionDiscovery` that scans `src/EntityExtension/` directories across vendor/modules/app for classes bearing `#[ExtensionOf]`. Discovery validates that the target entity class is actually an `Entity` subclass.

## Context
- Related files:
- `packages/database/src/Entity/EntityDiscovery.php` — exact parallel; follow its scanning pattern
- `packages/core/src/Discovery/ClassFileParser.php` — used to find/load PHP files
- `packages/database/src/Entity/Entity.php`
- `packages/database/src/Entity/EntityExtension.php` (from task 001)
- `packages/database/src/Attributes/ExtensionOf.php` (from task 001)
- `packages/database/src/Entity/EntityExtensionMetadataFactory.php` (from task 002)
- `EntityExtensionDiscovery` takes a `ClassFileParser` via constructor (mirror `EntityDiscovery`)
- `EntityExtensionDiscovery` scans:
- `vendor/*/*/src/EntityExtension/` (two vendor levels, like `EntityDiscovery`)
- `modules/*/*/src/EntityExtension/` (two module levels)
- `app/*/EntityExtension/` and `app/*/src/EntityExtension/`
- Each `discoverIn*` method MUST return `array<array{0: class-string<Entity>, 1: class-string<EntityExtension>}>` — i.e. a list of `[entityClass, extensionClass]` pairs. This is consumed by the boot wiring in task 009.
- Discovery throws a loud `EntityException` if `#[ExtensionOf]` names a class that does not extend `Entity` (new factory method on `EntityException`: `extensionOfTargetNotEntity`)
- Discovery silently skips files without `#[ExtensionOf]` or that don't extend `EntityExtension`
- `EntityExtensionRegistry::register(string $entityClass, string $extensionClass)` is idempotent (safe to call twice with the same pair)
- `EntityExtensionRegistry::getExtensions(string $entityClass): array<class-string<EntityExtension>>` returns all registered extension classes for an entity, empty array if none
- `EntityExtensionRegistry` is not responsible for parsing; it only holds the mapping
- `EntityExtensionRegistry` is mutable (state added via `register()`); do NOT mark it `readonly`

## Requirements (Test Descriptions)
- [ ] `it registers an extension class against its target entity class`
- [ ] `it returns all registered extension classes for an entity`
- [ ] `it returns empty array for entity with no registered extensions`
- [ ] `it is idempotent when registering the same pair twice`
- [ ] `it discovers extension classes in vendor src/EntityExtension directories`
- [ ] `it discovers extension classes in modules src/EntityExtension directories`
- [ ] `it discovers extension classes in app EntityExtension directories`
- [ ] `it skips classes without ExtensionOf attribute`
- [ ] `it skips classes that do not extend EntityExtension`
- [ ] `it throws when ExtensionOf target class does not extend Entity`

## Acceptance Criteria
- All requirements have passing tests
- `EntityExtensionRegistry` lives at `Marko\Database\Entity\EntityExtensionRegistry`
- `EntityExtensionDiscovery` lives at `Marko\Database\Entity\EntityExtensionDiscovery`
- `EntityExtensionRegistry` exposes `register(string $entityClass, string $extensionClass): void` and `getExtensions(string $entityClass): array<class-string<EntityExtension>>`
- `EntityExtensionDiscovery` exposes `discoverInVendor()`, `discoverInModules()`, `discoverInApp()`, each returning `array<array{0: class-string<Entity>, 1: class-string<EntityExtension>}>`
- New factory method added to `Marko\Database\Exceptions\EntityException`: `extensionOfTargetNotEntity(string $extensionClass, string $targetClass)`
- Code follows project standards
29 changes: 29 additions & 0 deletions .claude/plans/entity-extension/004-entity-extension-bag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Task 004: Entity — extension bag, `extension()`, `setExtension()`

**Status**: completed
**Depends on**: [001]
**Retry count**: 0

## Description
Modify the `Entity` base class to carry a private extension bag and expose two methods: `setExtension()` for the hydrator to attach extension instances, and `extension()` — a `@template`-typed generic accessor — for consumers to retrieve typed extension instances. This is the only change to `Entity`.

## Context
- Related files: `packages/database/src/Entity/Entity.php`
- The extension bag is `private array $extensions = []` keyed by extension class name (`class-string<EntityExtension>`)
- Both methods MUST be `public` — `setExtension()` is called from `EntityHydrator` (task 006) and `Repository` consumers; `extension()` is the read accessor for consumers
- `extension()` must use `@template T of EntityExtension` and `@param class-string<T>` / `@return T|null` so that PhpStorm, Psalm, and PHPStan all resolve the return type from the argument. The concrete return type hint is `?EntityExtension`
- `setExtension()` accepts `EntityExtension $extension` and stores it keyed by `$extension::class`
- Calling `setExtension()` twice with the same class overwrites the previous instance (last write wins)
- `Entity` is not currently abstract-with-no-properties on purpose; the existing `EntityTest` instantiates an anonymous subclass with declared public properties — the added `$extensions` private property must not collide with any existing public property name. Verify the new property name `extensions` is not already declared on any entity in the repo before finalising. (Quick grep: search for `public.*\$extensions` across `packages/*/src/Entity/`.)
- Tests for `Entity` currently only cover that it is abstract; add new tests in a separate test class or file rather than modifying existing tests if they would break

## Requirements (Test Descriptions)
- [ ] `it returns null for an extension that has not been set`
- [ ] `it returns the extension instance after it is set`
- [ ] `it overwrites a previously set extension of the same class`
- [ ] `it stores multiple extensions independently by class`

## Acceptance Criteria
- All requirements have passing tests
- IDE type inference: `$entity->extension(MyExtension::class)` resolves to `MyExtension|null` via `@template`
- Code follows project standards
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Task 005: EntityMetadata extensions field + EntityMetadataFactory merge

**Status**: completed
**Depends on**: [002, 003]
**Retry count**: 0

## Description
Add an `extensions` field to `EntityMetadata` (a map of extension class → `ExtensionMetadata`) and update `EntityMetadataFactory` to query `EntityExtensionRegistry` when parsing an entity, building and attaching `ExtensionMetadata` for each registered extension. Detect and loudly report column name conflicts between the base entity and its extensions, or between two extensions.

## Context
- Related files:
- `packages/database/src/Entity/EntityMetadata.php` (currently `readonly class`)
- `packages/database/src/Entity/EntityMetadataFactory.php`
- `packages/database/src/Entity/ExtensionMetadata.php` (from task 002)
- `packages/database/src/Entity/EntityExtensionRegistry.php` (from task 003)
- `packages/database/src/Entity/EntityExtensionMetadataFactory.php` (from task 002)
- `packages/database/tests/Entity/EntityMetadataTest.php` — may need updating
- `packages/database/tests/Entity/EntityMetadataFactoryTest.php` — instantiates `new EntityMetadataFactory()`; must continue to work
- `packages/database/tests/Entity/EntityMetadataFactoryRelationshipTest.php` — instantiates `new EntityMetadataFactory()`; must continue to work
- All 26+ test files that call `new EntityMetadataFactory()` (search for the pattern across the monorepo before editing)
- `EntityMetadata` gains a constructor parameter and property `public array $extensions = []` typed as `array<class-string<EntityExtension>, ExtensionMetadata>`, default empty. Because `EntityMetadata` is declared `readonly`, the field MUST be added as the LAST constructor parameter with a default of `[]` so all existing call-sites (`new EntityMetadata(entityClass: ..., tableName: ..., ...)`) continue to compile. Add appropriate phpdoc.
- `EntityMetadataFactory` gains a constructor dependency on `EntityExtensionRegistry` and `EntityExtensionMetadataFactory`. **BREAKING-CHANGE MITIGATION**: both new constructor parameters MUST be nullable with a default of `null`. When `null`, the factory behaves as before (no extensions merged). The container will still inject real instances in production (because both are autowirable); only test code that does `new EntityMetadataFactory()` without args remains green without modification. Document this explicitly in a class-level comment.
- After parsing the base entity properties, IF `$this->registry !== null && $this->extensionMetadataFactory !== null`, the factory calls `$this->registry->getExtensions($entityClass)`, parses each via `EntityExtensionMetadataFactory::parse()`, and builds an `array<class-string<EntityExtension>, ExtensionMetadata>` keyed by extension class name; this is passed to the `EntityMetadata` constructor.
- Column conflict detection: collect all column names (base + all extensions); if any column name appears more than once, throw `EntityException` (new factory method: `extensionColumnConflict(string $entityClass, string $columnName, string $sourceA, string $sourceB)`) naming both the base/extension sources and the conflicting column name. `$sourceA`/`$sourceB` are either the entity class or an extension class.
- Property NAME collisions are also possible (two extensions could declare the same public property name even if their column names differ). Detect and throw the same exception class (new factory method: `extensionPropertyConflict`).
- The metadata cache must be invalidated/rebuilt if extensions are added after first parse — in practice the registry is fully populated before any parse call (boot ordering, task 009), so this is safe. Do not implement cache invalidation, but add a comment to that effect on the cache field.

## Requirements (Test Descriptions)
- [ ] `it includes an empty extensions map by default on EntityMetadata`
- [ ] `it constructs EntityMetadataFactory with null registry and factory for backward compatibility`
- [ ] `it populates extensions from the registry when parsing an entity with registered extensions`
- [ ] `it leaves extensions empty when no extensions are registered for the entity`
- [ ] `it leaves extensions empty when constructed without a registry`
- [ ] `it throws when an extension column name collides with a base entity column name`
- [ ] `it throws when two extension column names collide with each other`
- [ ] `it throws when an extension property name collides with a base entity property name`
- [ ] `it caches entity metadata including extensions`

## Acceptance Criteria
- All requirements have passing tests
- Existing `EntityMetadataTest` and `EntityMetadataFactoryRelationshipTest` tests continue to pass without modification
- All 26+ test files that instantiate `new EntityMetadataFactory()` directly continue to compile and pass
- New factory methods added to `Marko\Database\Exceptions\EntityException`:
- `extensionColumnConflict(string $entityClass, string $columnName, string $sourceA, string $sourceB)`
- `extensionPropertyConflict(string $entityClass, string $propertyName, string $sourceA, string $sourceB)`
- Code follows project standards
Loading