Skip to content
Merged
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
21 changes: 13 additions & 8 deletions .claude/plans/tier4-performance/001-findoneby-limit-one.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Task 001: F1 — Add `LIMIT 1` to `findOneBy`

**Status**: pending
**Status**: complete
**Depends on**: [none]
**Retry count**: 0

Expand All @@ -16,19 +16,24 @@
- Build the WHERE clause with the same property->column mapping `findBy` uses (`metadata->getPropertyToColumnMap()`, criteria joined with ` AND `, positional bindings); append `LIMIT 1` to the SQL. Result-equivalence: because `findBy` issues no `ORDER BY`, the row returned under `LIMIT 1` is the same arbitrary "first" row `findBy()->first()` returned — do NOT introduce an `ORDER BY` (that would change which row is returned). When zero rows match, return `null`.
- Hydrate `$rows[0]` and eager-load it via the SAME private `eagerLoadRelationships([$entity])` call `find()`/`findBy` use, which reads `$this->pendingRelationships`. The `with(...)->findOneBy(...)` chain must still populate relationships on the single result — verify the clone's `pendingRelationships` flow through the new direct path (do not bypass it by short-circuiting before `eagerLoadRelationships`).
- Query-count assertions via inline anonymous `ConnectionInterface` stub with `public array &$queries` (mirroring `RepositoryCrudTest`): assert exactly one `query()` call whose SQL ends with/contains `LIMIT 1`, and assert the returned entity equals what the old `findBy()->first()` path produced.
- **The stub MUST implement `driverName(): string`** (Tier 2 added this to `ConnectionInterface`, line 62). An anonymous `implements ConnectionInterface` class that omits it is abstract-incomplete and fatals at instantiation. The reference stub in `RepositoryCrudTest` (line 159) returns `'sqlite'`; do the same.

## Requirements (Test Descriptions)
- [ ] `it returns the matching entity for findOneBy when a row exists`
- [ ] `it returns null from findOneBy when no row matches`
- [ ] `it appends LIMIT 1 to the findOneBy query`
- [ ] `it issues exactly one query for findOneBy even when multiple rows would match`
- [ ] `it maps entity property names to column names in the findOneBy WHERE clause`
- [ ] `it eager-loads relationships on the single entity returned by findOneBy`
- [x] `it returns the matching entity for findOneBy when a row exists`
- [x] `it returns null from findOneBy when no row matches`
- [x] `it appends LIMIT 1 to the findOneBy query`
- [x] `it issues exactly one query for findOneBy even when multiple rows would match`
- [x] `it maps entity property names to column names in the findOneBy WHERE clause`
- [x] `it eager-loads relationships on the single entity returned by findOneBy`

## Acceptance Criteria
- All requirements have passing tests
- Code follows code standards
- No decrease in test coverage

## Implementation Notes
(Left blank - filled in by programmer during implementation)
- `findOneBy` now issues a direct `SELECT * FROM {table} WHERE {conditions} LIMIT 1` query instead of delegating to `findBy()->first()`
- Property-to-column mapping uses the same `metadata->getPropertyToColumnMap()` logic as `findBy`
- Hydration and eager-loading flow through the same private `eagerLoadRelationships([$entity])` call as `find()` and `findBy()`, preserving the `with(...)->findOneBy(...)` chain
- No `ORDER BY` introduced; behavior is deliberately equivalent to the arbitrary "first" row `findBy()->first()` returned
- Test stub connection implements `driverName(): string` returning `'sqlite'` as required by Tier 2's `ConnectionInterface`
Original file line number Diff line number Diff line change
@@ -1,42 +1,48 @@
# Task 002: F1 — `SELECT 1 ... LIMIT 1` for `exists` / `existsBy` / `isColumnUnique`

**Status**: pending
**Status**: complete
**Depends on**: [001]
**Retry count**: 0

## Description
The existence-check family does far more work than needed: `exists($id)` calls `find()` which hydrates a full entity and eager-loads relationships; `existsBy` calls `findOneBy` (full fetch); `isColumnUnique` runs `SELECT *` with no `LIMIT`. All three only need to know whether a row exists. Replace each with a boolean probe — `SELECT 1 FROM <table> WHERE ... LIMIT 1` — returning early without hydration or eager loading.

## Description (scope detail)
This depends on 001 because both edit the same `Repository.php` lookup region; sequencing avoids merge churn. `exists`/`existsBy` may keep delegating only if the delegate is itself a `LIMIT 1` probe — prefer dedicated `SELECT 1 ... LIMIT 1` SQL so no entity is ever hydrated for a bool.
This depends on 001 because both edit the same `Repository.php` lookup region; sequencing avoids merge churn. **In the CURRENT code `exists($id)` delegates to `find()` (line 544) and `existsBy()` delegates to `findOneBy()` (line 555).** After Task 001, `findOneBy` returns a fully hydrated, eager-loaded entity capped at `LIMIT 1` — so if `existsBy`/`exists` keep delegating, they STILL hydrate an entity for a bool, violating this task's "the probe must never construct an entity" requirement. Therefore replace BOTH bodies with dedicated `SELECT 1 ... LIMIT 1` probes; do NOT keep delegating to `find`/`findOneBy`.

## Context
- Related files:
- `packages/database/src/Repository/Repository.php` (`exists` ~517-521, `existsBy` ~528-532, `isColumnUnique` ~542-557)
- `packages/database/tests/Feature/RepositoryCrudTest.php` (anonymous `ConnectionInterface` stub query-recording pattern)
- `packages/database/src/Repository/Repository.php` (`exists` ~541-545, `existsBy` ~552-556, `isColumnUnique` ~561-582 — note `isColumnUnique` is `protected`)
- `packages/database/tests/Feature/RepositoryCrudTest.php` (anonymous `ConnectionInterface` stub query-recording pattern; stub at line 49 includes a `driverName()` method at line 159 — new stubs MUST include it too)
- Patterns to follow:
- `exists($id)`: `SELECT 1 FROM <table> WHERE <pkColumn> = ? LIMIT 1`, return `count($rows) > 0`.
- `existsBy(array $criteria)`: build WHERE from the same property->column map as `findBy` (multiple criteria joined with ` AND `), `SELECT 1 ... LIMIT 1`, return `count($rows) > 0`. Preserve the existing multi-criteria behavior of `findBy` exactly — same property->column resolution, same positional binding order.
- `isColumnUnique($column, $value, $excludeId)`: `SELECT 1 FROM <table> WHERE <column> = ?` plus optional `AND <pkColumn> != ?`, append `LIMIT 1`, return `count($rows) === 0`. With `LIMIT 1` the probe returns at most one row, so `=== 0` correctly means "no other row holds the value" — identical semantics to the prior unbounded `SELECT *`.
- No `hydrate()`, no `eagerLoadRelationships()` on any of these paths — the probe must never construct an entity.
- No `hydrate()`, no `eagerLoadRelationships()`, and no delegation to `find`/`findOneBy` on any of these paths — the probe must never construct an entity. Verify by asserting the recorded SQL is `SELECT 1 ...` (not `SELECT *`) and that no eager-load query is emitted.
- Equivalence guard: each test must assert the boolean result matches the pre-change behavior for both the present and absent cases, not merely that `LIMIT 1`/`SELECT 1` appears in the SQL.
- Any new inline anonymous `ConnectionInterface` stub MUST implement `driverName(): string` (Tier 2 interface addition) or it fatals at instantiation.

## Requirements (Test Descriptions)
- [ ] `it returns true from exists when a row with the id is present`
- [ ] `it returns false from exists when no row has the id`
- [ ] `it probes exists with SELECT 1 and LIMIT 1 and does not hydrate an entity`
- [ ] `it returns true from existsBy when criteria match a row`
- [ ] `it returns false from existsBy when criteria match nothing`
- [ ] `it probes existsBy with SELECT 1 and LIMIT 1`
- [ ] `it returns true from isColumnUnique when no other row holds the value`
- [ ] `it returns false from isColumnUnique when another row holds the value`
- [ ] `it excludes the given id from the isColumnUnique uniqueness check`
- [ ] `it probes isColumnUnique with SELECT 1 and LIMIT 1 instead of SELECT star`
- [x] `it returns true from exists when a row with the id is present`
- [x] `it returns false from exists when no row has the id`
- [x] `it probes exists with SELECT 1 and LIMIT 1 and does not hydrate an entity`
- [x] `it returns true from existsBy when criteria match a row`
- [x] `it returns false from existsBy when criteria match nothing`
- [x] `it probes existsBy with SELECT 1 and LIMIT 1`
- [x] `it returns true from isColumnUnique when no other row holds the value`
- [x] `it returns false from isColumnUnique when another row holds the value`
- [x] `it excludes the given id from the isColumnUnique uniqueness check`
- [x] `it probes isColumnUnique with SELECT 1 and LIMIT 1 instead of SELECT star`

## Acceptance Criteria
- All requirements have passing tests
- Code follows code standards
- No decrease in test coverage

## Implementation Notes
(Left blank - filled in by programmer during implementation)
- Replaced `exists()` body with `SELECT 1 FROM <table> WHERE <pk> = ? LIMIT 1`, returns `count($rows) > 0`
- Replaced `existsBy()` body with same property→column mapping as `findBy`, `SELECT 1 ... LIMIT 1`, returns `count($rows) > 0`
- Replaced `isColumnUnique()` body with `SELECT 1 FROM <table> WHERE <column> = ? [AND <pk> != ?] LIMIT 1`, returns `count($rows) === 0`
- None of the three methods delegate to `find`/`findOneBy` — no hydration, no eager-loading on any probe path
- New test file: `packages/database/tests/Feature/RepositoryExistsTest.php` with 10 tests covering boolean correctness and SQL shape assertions
- Anonymous `ConnectionInterface` stub includes `driverName(): string` as required by Tier 2 interface
33 changes: 19 additions & 14 deletions .claude/plans/tier4-performance/003-media-findmany-batch.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Task 003: F2 — Batch media hydration in `AttachmentManager::findByAttachable`

**Status**: pending
**Status**: complete
**Depends on**: [none]
**Retry count**: 0

Expand All @@ -13,29 +13,34 @@
## Context
- Related files:
- `packages/media/src/Service/AttachmentManager.php` (`findByAttachable` ~46-63 — the per-id loop; `readonly class`)
- `packages/media/src/Contracts/MediaRepositoryInterface.php` (only has `save`/`delete`/`find(int): ?Media` — add `findMany`)
- `packages/media/src/Contracts/MediaAttachmentRepositoryInterface.php` (`findByAttachable` returns `array<int>` ids)
- `packages/media/tests/Service/AttachmentManagerTest.php` (`makeMediaRepository` anonymous-class mock implementing `MediaRepositoryInterface` — MUST add `findMany`)
- `packages/media/tests/Service/MediaManagerTest.php` (~line 228, second anonymous-class mock implementing `MediaRepositoryInterface` — MUST add `findMany`)
- `packages/media/src/Contracts/MediaRepositoryInterface.php` (only has `save`/`delete`/`find(int $id): ?Media` — add `findMany`; ids are `int`)
- `packages/media/src/Contracts/MediaAttachmentRepositoryInterface.php` (`findByAttachable` returns `array<int>` ids — these `int` ids are what `findMany` receives)
- `packages/media/tests/Service/AttachmentManagerTest.php` (`makeMediaRepository` anonymous-class mock at line 76 implementing `MediaRepositoryInterface` — MUST add `findMany`)
- `packages/media/tests/Service/MediaManagerTest.php` (`makeRepository` at line 244, second anonymous-class mock implementing `MediaRepositoryInterface` — MUST add `findMany`)
- Patterns to follow:
- Add `MediaRepositoryInterface::findMany(array $ids): array` returning `array<Media>`, documented: empty input -> empty array with no query; result keyed/ordered is the caller's concern (the contract returns a flat `array<Media>` of the matched rows, order unspecified).
- Add `MediaRepositoryInterface::findMany(array $ids): array` (`@param array<int> $ids`) returning `array<Media>`, documented: empty input -> empty array with no query; the contract returns a flat `array<Media>` of the matched rows in unspecified order (the caller — `AttachmentManager` — is responsible for any ordering). Confirmed: there is NO concrete `MediaRepository` class in-package and exactly two in-repo implementers (the two test mocks above) — both must add the method or the `marko/media` suite fatals.
- The mock `findMany` returns the stored media for each requested id that exists, skipping misses (mirrors `find()` returning null).
- `AttachmentManager::findByAttachable` calls `findMany($mediaIds)` once, then re-orders the returned media to match the attachment id list and skips ids with no matching media (preserve current null-skip + ordering behavior) — the re-ordering/skip logic lives in `AttachmentManager`, not the repository.
- Query-count assertions: the mock records how many times `findMany` (vs `find`) is invoked; assert `find` is never called and `findMany` is called exactly once regardless of attachment count.

## Requirements (Test Descriptions)
- [ ] `it returns the media entities for all attached ids`
- [ ] `it returns media in the order of the attachment id list`
- [ ] `it skips ids that have no matching media row`
- [ ] `it returns an empty array when there are no attachments`
- [ ] `it resolves all attachments with a single findMany call and never calls find per id`
- [ ] `it defines findMany on MediaRepositoryInterface returning an array of Media`
- [ ] `it does not invoke findMany when there are no attachment ids`
- [x] `it returns the media entities for all attached ids`
- [x] `it returns media in the order of the attachment id list`
- [x] `it skips ids that have no matching media row`
- [x] `it returns an empty array when there are no attachments`
- [x] `it resolves all attachments with a single findMany call and never calls find per id`
- [x] `it defines findMany on MediaRepositoryInterface returning an array of Media`
- [x] `it does not invoke findMany when there are no attachment ids`

## Acceptance Criteria
- All requirements have passing tests
- Code follows code standards
- No decrease in test coverage

## Implementation Notes
(Left blank - filled in by programmer during implementation)
- Added `findMany(array $ids): array` to `MediaRepositoryInterface` with documented contract (empty input → empty array no query; caller responsible for ordering; consumer implements single `WHERE id IN (...)` query)
- Rewired `AttachmentManager::findByAttachable` to call `findMany` once, then re-order by attachment id list and skip missing ids in PHP
- Added `findMany` to both in-package test mocks: `makeMediaRepository` in `AttachmentManagerTest.php` and `makeRepository` in `MediaManagerTest.php`
- Added `makeTrackingMediaRepository` mock with `findCallCount`/`findManyCallCount` properties to verify N+1 elimination
- Added `MediaRepositoryInterfaceTest.php` for the interface definition requirement
- Pre-existing full-suite crash in `notification`/`notification-database` packages is unrelated to this task
Loading