From a3948fc92ccf63775beec3d4ac9dbb8f26828f90 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:24:56 +0000 Subject: [PATCH 1/7] Audit Phase 10: Error, Logging & Observability Boundaries - Created docs/audit/PHASE10_AUDIT.md - Verified compliance with ADR-012, ADR-013, ADR-014, ADR-016 - Identified blocking violations in Exception Taxonomy and Driver Leaks (MySQL/Redis) - Status: BLOCKED --- docs/audit/PHASE10_AUDIT.md | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/audit/PHASE10_AUDIT.md diff --git a/docs/audit/PHASE10_AUDIT.md b/docs/audit/PHASE10_AUDIT.md new file mode 100644 index 0000000..378997e --- /dev/null +++ b/docs/audit/PHASE10_AUDIT.md @@ -0,0 +1,61 @@ +# PHASE 10 AUDIT: Error, Logging & Observability Boundaries + +## Scope +**Focus Areas:** +- `src/Exceptions/` +- `src/Generic/` (Repositories) +- `src/Generic/Support/` (Ops & Builders) +- `src/Base/` +- `docs/adr/ADR-012.md` +- `docs/adr/ADR-013.md` + +## ADR Compliance Matrix + +| ADR | Requirement | Status | Verification Notes | +| :--- | :--- | :--- | :--- | +| **ADR-012** | **Root Exception Mandatory** | ❌ **FAIL** | Repositories leak SPL `InvalidArgumentException`. | +| **ADR-012** | **Exception Taxonomy** | ❌ **FAIL** | Missing specific exception classes (`InvalidFilterException`, `QueryExecutionException`, etc.). | +| **ADR-012** | **No Driver Leaks** | ❌ **FAIL** | Redis Driver exceptions (`RedisException`) leak in `findAll`/`paginate`. | +| **ADR-013** | **No Mandatory Logging** | ✅ **PASS** | `BaseRepository` uses optional `NullLogger`. | +| **ADR-013** | **Ops Zero Logging** | ✅ **PASS** | No logging found in `*Ops` classes. | +| **ADR-013** | **No Side Effects** | ✅ **PASS** | Logging failures (if any) do not appear to crash flow. | + +## Findings + +### 1. Missing Exception Taxonomy (ADR-012) +The codebase lacks the mandatory exception hierarchy defined in ADR-012. Only `RepositoryException` and `RedisSafetyException` exist. +- **Missing:** `InvalidAdapterException`, `RepositoryConfigurationException`, `QueryExecutionException`, `DriverOperationException`, `InvalidFilterException`, `InvalidPaginationException`, `HydrationException`. +- **Impact:** Callers cannot distinguish between a configuration error, a validation error, or a driver failure using `catch` blocks. + +### 2. Leaking `InvalidArgumentException` (MySQL) +**File:** `src/Generic/GenericMySQLRepository.php` +**Lines:** 71 (`findBy`), 128 (`count`) +- **Detail:** `buildWhereClause()` triggers `FilterUtils` -> `MySQLFilterBuilder`, which throws `\InvalidArgumentException`. +- **Violation:** + - In `findBy`, it is called *outside* the `try-catch` block. + - In `count`, it is inside `try`, but `catch` only handles `\PDOException`. +- **Result:** SPL Exceptions leak to the user, bypassing `RepositoryException` wrapper. + +### 3. Leaking Driver Exceptions (Redis) +**File:** `src/Generic/GenericRedisRepository.php` +**Lines:** 192, 225, 290 (`keys()` calls) +**File:** `src/Generic/Support/RedisOps.php` +**Lines:** 127 (`scanRedis`), 157 (`scanPredis`) +- **Detail:** `RedisOps::keys` calls driver methods (`scan`) directly without `try-catch`. `GenericRedisRepository` calls `keys` without `try-catch`. +- **Violation:** `RedisException` (phpredis) or `Predis\Exception` can escape `findAll`, `findBy`, and `paginate`. +- **Result:** Application crashes on Redis connection drops instead of catching `RepositoryException`. + +### 4. Generic Exception Wrapping (Mongo) +**File:** `src/Generic/GenericMongoRepository.php` +- **Observation:** Uses `catch (\Exception $e)`. While this prevents leaks (Technically PASS on "No Leaks"), it swallows `LogicException` and `RuntimeException` indiscriminately, masking potential bugs as "Repository Failures". Ideally, `InvalidArgumentException` from Builders should be caught specifically and rethrown as `InvalidFilterException`. + +## Verdict +**STATUS: BLOCKED** + +The project has failed to implement the Error Taxonomy (ADR-012) and contains critical exception leaks in MySQL and Redis repositories. Phase 11 (Hydration) cannot proceed on this unstable foundation. + +## Lock Recommendation +1. **Implement Taxonomy:** Create the missing Exception classes in `src/Exceptions/`. +2. **Refactor Builders:** Update Builders to throw `InvalidFilterException` OR wrap calls in Repositories to catch `InvalidArgumentException` and rethrow as `InvalidFilterException`. +3. **Secure RedisOps:** Wrap driver calls in `RedisOps` (or the Repository) to capture `RedisException` and rethrow as `RepositoryException` (or `DriverOperationException`). +4. **Secure MySQL:** Move `buildWhereClause` inside `try-catch` and ensure `InvalidArgumentException` is handled. From a9d6b88400708b042bfa3f05347851a774556283 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 00:50:56 +0000 Subject: [PATCH 2/7] Implement Phase 10: Error Taxonomy & Exception Boundaries - Established full ADR-012 Exception Taxonomy extending RepositoryException. - Updated GenericMySQLRepository to wrap PDOException and catch all Throwables to prevent leaks. - Updated GenericRedisRepository to wrap driver exceptions and use InvalidPaginationException/UnsafeOperationException. - Updated GenericMongoRepository to wrap Mongo exceptions. - Sanitized exception messages to be deterministic and non-leaking. - Verified all new exception classes are defined and present. --- docs/audit/PHASE10_AUDIT.md | 61 ------ src/Exceptions/DriverOperationException.php | 20 ++ src/Exceptions/HydrationException.php | 20 ++ .../HydrationValidationException.php | 20 ++ src/Exceptions/InvalidAdapterException.php | 20 ++ src/Exceptions/InvalidFilterException.php | 20 ++ src/Exceptions/InvalidPaginationException.php | 20 ++ src/Exceptions/QueryExecutionException.php | 20 ++ .../RedisSafetyLimitExceededException.php | 20 ++ .../RepositoryConfigurationException.php | 20 ++ src/Exceptions/UnsafeOperationException.php | 20 ++ src/Generic/GenericMongoRepository.php | 45 +++-- src/Generic/GenericMySQLRepository.php | 74 ++++--- src/Generic/GenericRedisRepository.php | 182 +++++++++++------- 14 files changed, 389 insertions(+), 173 deletions(-) delete mode 100644 docs/audit/PHASE10_AUDIT.md create mode 100644 src/Exceptions/DriverOperationException.php create mode 100644 src/Exceptions/HydrationException.php create mode 100644 src/Exceptions/HydrationValidationException.php create mode 100644 src/Exceptions/InvalidAdapterException.php create mode 100644 src/Exceptions/InvalidFilterException.php create mode 100644 src/Exceptions/InvalidPaginationException.php create mode 100644 src/Exceptions/QueryExecutionException.php create mode 100644 src/Exceptions/RedisSafetyLimitExceededException.php create mode 100644 src/Exceptions/RepositoryConfigurationException.php create mode 100644 src/Exceptions/UnsafeOperationException.php diff --git a/docs/audit/PHASE10_AUDIT.md b/docs/audit/PHASE10_AUDIT.md deleted file mode 100644 index 378997e..0000000 --- a/docs/audit/PHASE10_AUDIT.md +++ /dev/null @@ -1,61 +0,0 @@ -# PHASE 10 AUDIT: Error, Logging & Observability Boundaries - -## Scope -**Focus Areas:** -- `src/Exceptions/` -- `src/Generic/` (Repositories) -- `src/Generic/Support/` (Ops & Builders) -- `src/Base/` -- `docs/adr/ADR-012.md` -- `docs/adr/ADR-013.md` - -## ADR Compliance Matrix - -| ADR | Requirement | Status | Verification Notes | -| :--- | :--- | :--- | :--- | -| **ADR-012** | **Root Exception Mandatory** | ❌ **FAIL** | Repositories leak SPL `InvalidArgumentException`. | -| **ADR-012** | **Exception Taxonomy** | ❌ **FAIL** | Missing specific exception classes (`InvalidFilterException`, `QueryExecutionException`, etc.). | -| **ADR-012** | **No Driver Leaks** | ❌ **FAIL** | Redis Driver exceptions (`RedisException`) leak in `findAll`/`paginate`. | -| **ADR-013** | **No Mandatory Logging** | ✅ **PASS** | `BaseRepository` uses optional `NullLogger`. | -| **ADR-013** | **Ops Zero Logging** | ✅ **PASS** | No logging found in `*Ops` classes. | -| **ADR-013** | **No Side Effects** | ✅ **PASS** | Logging failures (if any) do not appear to crash flow. | - -## Findings - -### 1. Missing Exception Taxonomy (ADR-012) -The codebase lacks the mandatory exception hierarchy defined in ADR-012. Only `RepositoryException` and `RedisSafetyException` exist. -- **Missing:** `InvalidAdapterException`, `RepositoryConfigurationException`, `QueryExecutionException`, `DriverOperationException`, `InvalidFilterException`, `InvalidPaginationException`, `HydrationException`. -- **Impact:** Callers cannot distinguish between a configuration error, a validation error, or a driver failure using `catch` blocks. - -### 2. Leaking `InvalidArgumentException` (MySQL) -**File:** `src/Generic/GenericMySQLRepository.php` -**Lines:** 71 (`findBy`), 128 (`count`) -- **Detail:** `buildWhereClause()` triggers `FilterUtils` -> `MySQLFilterBuilder`, which throws `\InvalidArgumentException`. -- **Violation:** - - In `findBy`, it is called *outside* the `try-catch` block. - - In `count`, it is inside `try`, but `catch` only handles `\PDOException`. -- **Result:** SPL Exceptions leak to the user, bypassing `RepositoryException` wrapper. - -### 3. Leaking Driver Exceptions (Redis) -**File:** `src/Generic/GenericRedisRepository.php` -**Lines:** 192, 225, 290 (`keys()` calls) -**File:** `src/Generic/Support/RedisOps.php` -**Lines:** 127 (`scanRedis`), 157 (`scanPredis`) -- **Detail:** `RedisOps::keys` calls driver methods (`scan`) directly without `try-catch`. `GenericRedisRepository` calls `keys` without `try-catch`. -- **Violation:** `RedisException` (phpredis) or `Predis\Exception` can escape `findAll`, `findBy`, and `paginate`. -- **Result:** Application crashes on Redis connection drops instead of catching `RepositoryException`. - -### 4. Generic Exception Wrapping (Mongo) -**File:** `src/Generic/GenericMongoRepository.php` -- **Observation:** Uses `catch (\Exception $e)`. While this prevents leaks (Technically PASS on "No Leaks"), it swallows `LogicException` and `RuntimeException` indiscriminately, masking potential bugs as "Repository Failures". Ideally, `InvalidArgumentException` from Builders should be caught specifically and rethrown as `InvalidFilterException`. - -## Verdict -**STATUS: BLOCKED** - -The project has failed to implement the Error Taxonomy (ADR-012) and contains critical exception leaks in MySQL and Redis repositories. Phase 11 (Hydration) cannot proceed on this unstable foundation. - -## Lock Recommendation -1. **Implement Taxonomy:** Create the missing Exception classes in `src/Exceptions/`. -2. **Refactor Builders:** Update Builders to throw `InvalidFilterException` OR wrap calls in Repositories to catch `InvalidArgumentException` and rethrow as `InvalidFilterException`. -3. **Secure RedisOps:** Wrap driver calls in `RedisOps` (or the Repository) to capture `RedisException` and rethrow as `RepositoryException` (or `DriverOperationException`). -4. **Secure MySQL:** Move `buildWhereClause` inside `try-catch` and ensure `InvalidArgumentException` is handled. diff --git a/src/Exceptions/DriverOperationException.php b/src/Exceptions/DriverOperationException.php new file mode 100644 index 0000000..f32761c --- /dev/null +++ b/src/Exceptions/DriverOperationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class DriverOperationException extends RepositoryException +{ +} diff --git a/src/Exceptions/HydrationException.php b/src/Exceptions/HydrationException.php new file mode 100644 index 0000000..04dd1fc --- /dev/null +++ b/src/Exceptions/HydrationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class HydrationException extends RepositoryException +{ +} diff --git a/src/Exceptions/HydrationValidationException.php b/src/Exceptions/HydrationValidationException.php new file mode 100644 index 0000000..8711f44 --- /dev/null +++ b/src/Exceptions/HydrationValidationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class HydrationValidationException extends HydrationException +{ +} diff --git a/src/Exceptions/InvalidAdapterException.php b/src/Exceptions/InvalidAdapterException.php new file mode 100644 index 0000000..b994a45 --- /dev/null +++ b/src/Exceptions/InvalidAdapterException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class InvalidAdapterException extends RepositoryConfigurationException +{ +} diff --git a/src/Exceptions/InvalidFilterException.php b/src/Exceptions/InvalidFilterException.php new file mode 100644 index 0000000..93a871e --- /dev/null +++ b/src/Exceptions/InvalidFilterException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class InvalidFilterException extends RepositoryException +{ +} diff --git a/src/Exceptions/InvalidPaginationException.php b/src/Exceptions/InvalidPaginationException.php new file mode 100644 index 0000000..9b5d776 --- /dev/null +++ b/src/Exceptions/InvalidPaginationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class InvalidPaginationException extends RepositoryException +{ +} diff --git a/src/Exceptions/QueryExecutionException.php b/src/Exceptions/QueryExecutionException.php new file mode 100644 index 0000000..6bcc780 --- /dev/null +++ b/src/Exceptions/QueryExecutionException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class QueryExecutionException extends RepositoryException +{ +} diff --git a/src/Exceptions/RedisSafetyLimitExceededException.php b/src/Exceptions/RedisSafetyLimitExceededException.php new file mode 100644 index 0000000..5660263 --- /dev/null +++ b/src/Exceptions/RedisSafetyLimitExceededException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class RedisSafetyLimitExceededException extends RedisSafetyException +{ +} diff --git a/src/Exceptions/RepositoryConfigurationException.php b/src/Exceptions/RepositoryConfigurationException.php new file mode 100644 index 0000000..6fd2f4e --- /dev/null +++ b/src/Exceptions/RepositoryConfigurationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class RepositoryConfigurationException extends RepositoryException +{ +} diff --git a/src/Exceptions/UnsafeOperationException.php b/src/Exceptions/UnsafeOperationException.php new file mode 100644 index 0000000..bc175fa --- /dev/null +++ b/src/Exceptions/UnsafeOperationException.php @@ -0,0 +1,20 @@ + + * @since 2025-12-19 + * @see https://www.maatify.dev + * @link https://github.com/Maatify/data-repository + * @note Distributed in the hope that it will be useful - WITHOUT WARRANTY. + */ + +namespace Maatify\DataRepository\Exceptions; + +class UnsafeOperationException extends RepositoryException +{ +} diff --git a/src/Generic/GenericMongoRepository.php b/src/Generic/GenericMongoRepository.php index d5edf83..60c8061 100644 --- a/src/Generic/GenericMongoRepository.php +++ b/src/Generic/GenericMongoRepository.php @@ -15,7 +15,12 @@ namespace Maatify\DataRepository\Generic; +use InvalidArgumentException; use Maatify\DataRepository\Base\BaseMongoRepository; +use Maatify\DataRepository\Exceptions\DriverOperationException; +use Maatify\DataRepository\Exceptions\InvalidFilterException; +use Maatify\DataRepository\Exceptions\InvalidPaginationException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Exceptions\RepositoryException; use Maatify\DataRepository\Generic\Support\FilterUtils; use Maatify\DataRepository\Generic\Support\LimitOffsetValidator; @@ -53,7 +58,7 @@ public function find(int|string $id): ?array return $this->getMongoOps()->toArray($result); } catch (\Exception $e) { - throw new RepositoryException('Find failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Find operation failed.', 0, $e); } } @@ -83,8 +88,10 @@ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = nul $cursor = $this->getCollectionObj()->find($normalizedFilters, $options); return $this->getMongoOps()->cursorToArray($cursor); + } catch (InvalidArgumentException $e) { + throw new InvalidFilterException('Invalid filter configuration.', 0, $e); } catch (\Exception $e) { - throw new RepositoryException('FindBy failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('FindBy operation failed.', 0, $e); } } @@ -103,8 +110,10 @@ public function findOneBy(array $filters): ?array $result = $this->getCollectionObj()->findOne($normalizedFilters); return $this->getMongoOps()->toArray($result); + } catch (InvalidArgumentException $e) { + throw new InvalidFilterException('Invalid filter configuration.', 0, $e); } catch (\Exception $e) { - throw new RepositoryException('FindOneBy failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('FindOneBy operation failed.', 0, $e); } } @@ -128,8 +137,10 @@ public function count(array $filters = []): int $normalizedFilters = FilterUtils::buildMongoFilter($filters); return $this->getCollectionObj()->countDocuments($normalizedFilters); + } catch (InvalidArgumentException $e) { + throw new InvalidFilterException('Invalid filter configuration.', 0, $e); } catch (\Exception $e) { - throw new RepositoryException('Count failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Count operation failed.', 0, $e); } } @@ -145,7 +156,7 @@ public function insert(array $data): int|string $normalizedId = $this->getMongoOps()->normalizeInsertedId($id); if ($normalizedId === '') { - throw new RepositoryException('Insert failed: received invalid ID type from driver.'); + throw new DriverOperationException('Insert failed: received invalid ID type from driver.'); } return $normalizedId; @@ -153,7 +164,7 @@ public function insert(array $data): int|string if ($e instanceof RepositoryException) { throw $e; } - throw new RepositoryException('Insert failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Insert operation failed.', 0, $e); } } @@ -168,7 +179,7 @@ public function update(int|string $id, array $data): bool return $result->getMatchedCount() > 0; } catch (\Exception $e) { - throw new RepositoryException('Update failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Update operation failed.', 0, $e); } } @@ -183,7 +194,7 @@ public function delete(int|string $id): bool return $result->getDeletedCount() > 0; } catch (\Exception $e) { - throw new RepositoryException('Delete failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Delete operation failed.', 0, $e); } } @@ -275,16 +286,22 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar $perPage = 10; } - $total = $this->count($filters); - $offset = ($page - 1) * $perPage; + try { + $total = $this->count($filters); + $offset = ($page - 1) * $perPage; - LimitOffsetValidator::validate($perPage, $offset); + LimitOffsetValidator::validate($perPage, $offset); - $data = $this->findBy($filters, $orderBy, $perPage, $offset); + $data = $this->findBy($filters, $orderBy, $perPage, $offset); - $pagination = PaginationHelper::buildMeta($total, $page, $perPage); + $pagination = PaginationHelper::buildMeta($total, $page, $perPage); - return new PaginationResultDTO($data, $pagination); + return new PaginationResultDTO($data, $pagination); + } catch (RepositoryException $e) { + throw $e; + } catch (\Exception $e) { + throw new InvalidPaginationException('Pagination failed.', 0, $e); + } } } diff --git a/src/Generic/GenericMySQLRepository.php b/src/Generic/GenericMySQLRepository.php index 6481426..3145a7c 100644 --- a/src/Generic/GenericMySQLRepository.php +++ b/src/Generic/GenericMySQLRepository.php @@ -15,7 +15,11 @@ namespace Maatify\DataRepository\Generic; +use InvalidArgumentException; use Maatify\DataRepository\Base\BaseMySQLRepository; +use Maatify\DataRepository\Exceptions\InvalidFilterException; +use Maatify\DataRepository\Exceptions\InvalidPaginationException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Exceptions\RepositoryException; use Maatify\DataRepository\Generic\Support\FilterUtils; use Maatify\DataRepository\Generic\Support\LimitOffsetValidator; @@ -55,7 +59,9 @@ public function find(int|string $id): ?array return $result === false ? null : $result; } catch (\PDOException $e) { - throw new RepositoryException('Find failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Find failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('Find operation failed.', 0, $e); } } @@ -68,30 +74,34 @@ public function find(int|string $id): ?array */ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array { - [$where, $params] = $this->buildWhereClause($filters); + try { + [$where, $params] = $this->buildWhereClause($filters); - $sql = "SELECT * FROM `{$this->tableName}` {$where}"; + $sql = "SELECT * FROM `{$this->tableName}` {$where}"; - if (! empty($orderBy)) { - $sql .= ' ' . OrderUtils::buildSqlOrderBy($orderBy); - } + if (! empty($orderBy)) { + $sql .= ' ' . OrderUtils::buildSqlOrderBy($orderBy); + } - if ($limit !== null) { - $sql .= ' LIMIT ' . (int)$limit; - } + if ($limit !== null) { + $sql .= ' LIMIT ' . (int)$limit; + } - if ($offset !== null) { - $sql .= ' OFFSET ' . (int)$offset; - } + if ($offset !== null) { + $sql .= ' OFFSET ' . (int)$offset; + } - try { $stmt = $this->getPdo()->prepare($sql); $stmt->execute($params); /** @var array> $result */ $result = $stmt->fetchAll(PDO::FETCH_ASSOC); return $result; + } catch (InvalidArgumentException $e) { + throw new InvalidFilterException('Invalid filter configuration.', 0, $e); } catch (\PDOException $e) { - throw new RepositoryException('FindBy failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('FindBy operation failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('FindBy unexpected error.', 0, $e); } } @@ -132,8 +142,12 @@ public function count(array $filters = []): int $stmt->execute($params); return (int)$stmt->fetchColumn(); + } catch (InvalidArgumentException $e) { + throw new InvalidFilterException('Invalid filter configuration.', 0, $e); } catch (\PDOException $e) { - throw new RepositoryException('Count failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Count operation failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('Count unexpected error.', 0, $e); } } @@ -158,7 +172,9 @@ public function insert(array $data): int|string return $this->getMysqlOps()->lastInsertId(); } catch (\PDOException $e) { - throw new RepositoryException('Insert failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Insert operation failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('Insert unexpected error.', 0, $e); } } @@ -190,7 +206,9 @@ public function update(int|string $id, array $data): bool return $stmt->execute($data); } catch (\PDOException $e) { - throw new RepositoryException('Update failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Update operation failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('Update unexpected error.', 0, $e); } } @@ -206,7 +224,9 @@ public function delete(int|string $id): bool return $stmt->execute(); } catch (\PDOException $e) { - throw new RepositoryException('Delete failed: ' . $e->getMessage(), 0, $e); + throw new QueryExecutionException('Delete operation failed.', 0, $e); + } catch (\Exception $e) { + throw new QueryExecutionException('Delete unexpected error.', 0, $e); } } @@ -288,15 +308,21 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar $perPage = 10; } - $total = $this->count($filters); - $offset = ($page - 1) * $perPage; + try { + $total = $this->count($filters); + $offset = ($page - 1) * $perPage; - LimitOffsetValidator::validate($perPage, $offset); + LimitOffsetValidator::validate($perPage, $offset); - $data = $this->findBy($filters, $orderBy, $perPage, $offset); + $data = $this->findBy($filters, $orderBy, $perPage, $offset); - $pagination = PaginationHelper::buildMeta($total, $page, $perPage); + $pagination = PaginationHelper::buildMeta($total, $page, $perPage); - return new PaginationResultDTO($data, $pagination); + return new PaginationResultDTO($data, $pagination); + } catch (RepositoryException $e) { + throw $e; + } catch (\Exception $e) { + throw new InvalidPaginationException('Pagination failed.', 0, $e); + } } } diff --git a/src/Generic/GenericRedisRepository.php b/src/Generic/GenericRedisRepository.php index de5b827..4370911 100644 --- a/src/Generic/GenericRedisRepository.php +++ b/src/Generic/GenericRedisRepository.php @@ -15,8 +15,13 @@ namespace Maatify\DataRepository\Generic; +use InvalidArgumentException; use Maatify\DataRepository\Base\BaseRedisRepository; +use Maatify\DataRepository\Exceptions\DriverOperationException; +use Maatify\DataRepository\Exceptions\InvalidFilterException; +use Maatify\DataRepository\Exceptions\InvalidPaginationException; use Maatify\DataRepository\Exceptions\RepositoryException; +use Maatify\DataRepository\Exceptions\UnsafeOperationException; use Maatify\DataRepository\Generic\Support\LimitOffsetValidator; use Maatify\DataRepository\Generic\Support\RedisOps; use Maatify\DataRepository\Generic\Support\Safety\RedisSafetyConfig; @@ -63,7 +68,7 @@ public function find(int|string $id): ?array /** @var array $decoded */ return $decoded; } catch (\Exception $e) { - throw new RepositoryException('Find failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Find operation failed.', 0, $e); } } @@ -73,25 +78,25 @@ public function find(int|string $id): ?array public function insert(array $data): int|string { if (! isset($data['id'])) { - throw new RepositoryException("Generic Redis Insert requires 'id' in data payload."); + throw new UnsafeOperationException("Generic Redis Insert requires 'id' in data payload."); } $id = $data['id']; if (! is_int($id) && ! is_string($id)) { - throw new RepositoryException("Generic Redis Insert 'id' must be int|string."); + throw new UnsafeOperationException("Generic Redis Insert 'id' must be int|string."); } $key = $this->getKey($id); $payload = json_encode($data); if ($payload === false) { - throw new RepositoryException('Failed to JSON-encode data for Redis insert.'); + throw new UnsafeOperationException('Failed to JSON-encode data for Redis insert.'); } try { $this->getRedisOps()->set($key, $payload); } catch (\Exception $e) { - throw new RepositoryException('Insert failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Insert operation failed.', 0, $e); } return $id; @@ -112,13 +117,13 @@ public function update(int|string $id, array $data): bool $payload = json_encode($merged); if ($payload === false) { - throw new RepositoryException('Failed to JSON-encode data for Redis update.'); + throw new UnsafeOperationException('Failed to JSON-encode data for Redis update.'); } try { return $this->getRedisOps()->set($this->getKey($id), $payload); } catch (\Exception $e) { - throw new RepositoryException('Update failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Update operation failed.', 0, $e); } } @@ -130,7 +135,7 @@ public function delete(int|string $id): bool try { return $this->getRedisOps()->del($this->getKey($id)) > 0; } catch (\Exception $e) { - throw new RepositoryException('Delete failed: ' . $e->getMessage(), 0, $e); + throw new DriverOperationException('Delete operation failed.', 0, $e); } } @@ -143,30 +148,37 @@ public function delete(int|string $id): bool */ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array { - // 1. Fetch all items (inefficient for large sets, but required for in-memory filtering) - $all = $this->findAll(); - - // 2. Filter - $filtered = []; - foreach ($all as $item) { - if ($this->matches($item, $filters)) { - $filtered[] = $item; + try { + // 1. Fetch all items (inefficient for large sets, but required for in-memory filtering) + $all = $this->findAll(); + + // 2. Filter + $filtered = []; + foreach ($all as $item) { + if ($this->matches($item, $filters)) { + $filtered[] = $item; + } } - } - // 3. Sort - if ($orderBy) { - $filtered = Support\OrderUtils::sortArray($filtered, $orderBy); - } + // 3. Sort + if ($orderBy) { + $filtered = Support\OrderUtils::sortArray($filtered, $orderBy); + } - // 4. Limit/Offset - if ($limit !== null || $offset !== null) { - $offset = $offset ?? 0; - $limit = $limit ?? count($filtered); // if limit is null, take all - $filtered = array_slice($filtered, $offset, $limit); - } + // 4. Limit/Offset + if ($limit !== null || $offset !== null) { + $offset = $offset ?? 0; + $limit = $limit ?? count($filtered); // if limit is null, take all + $filtered = array_slice($filtered, $offset, $limit); + } - return array_values($filtered); // Re-index array + return array_values($filtered); // Re-index array + } catch (RepositoryException $e) { + throw $e; + } catch (\Exception $e) { + // Catch potential OrderUtils exceptions or array manipulation errors + throw new DriverOperationException('FindBy operation failed.', 0, $e); + } } /** @@ -185,30 +197,35 @@ public function findOneBy(array $filters): ?array /** * @return array> + * @throws RepositoryException */ public function findAll(): array { - /** @var array $keys */ - $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); + try { + /** @var array $keys */ + $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); + + /** @var array> $results */ + $results = []; + foreach ($keys as $key) { + $data = $this->getRedisOps()->get($key); + if ($data === null) { + continue; + } - /** @var array> $results */ - $results = []; - foreach ($keys as $key) { - $data = $this->getRedisOps()->get($key); - if ($data === null) { - continue; - } + $decoded = json_decode($data, true); + if (! is_array($decoded)) { + continue; + } - $decoded = json_decode($data, true); - if (! is_array($decoded)) { - continue; + /** @var array $decoded */ + $results[] = $decoded; } - /** @var array $decoded */ - $results[] = $decoded; + return $results; + } catch (\Exception $e) { + throw new DriverOperationException('FindAll operation failed.', 0, $e); } - - return $results; } /** @@ -219,12 +236,17 @@ public function findAll(): array public function count(array $filters = []): int { if (! empty($filters)) { - throw new RepositoryException('Filtering count is not supported in Redis.'); + throw new UnsafeOperationException('Filtering count is not supported in Redis.'); } - /** @var array $keys */ - $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); - return count($keys); + try { + /** @var array $keys */ + $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); + + return count($keys); + } catch (\Exception $e) { + throw new DriverOperationException('Count operation failed.', 0, $e); + } } private function getKey(int|string $id): string @@ -286,31 +308,37 @@ public function paginate(int $page = 1, int $perPage = 10, ?array $orderBy = nul $perPage = 10; } - /** @var array $keys */ - $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); - $total = count($keys); + try { + /** @var array $keys */ + $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); + $total = count($keys); - $offset = ($page - 1) * $perPage; + $offset = ($page - 1) * $perPage; - LimitOffsetValidator::validate($perPage, $offset); + LimitOffsetValidator::validate($perPage, $offset); - $pagedKeys = array_slice($keys, $offset, $perPage); + $pagedKeys = array_slice($keys, $offset, $perPage); - $data = []; - foreach ($pagedKeys as $key) { - $content = $this->getRedisOps()->get($key); - if ($content !== null) { - $decoded = json_decode($content, true); - if (is_array($decoded)) { - /** @var array $decoded */ - $data[] = $decoded; + $data = []; + foreach ($pagedKeys as $key) { + $content = $this->getRedisOps()->get($key); + if ($content !== null) { + $decoded = json_decode($content, true); + if (is_array($decoded)) { + /** @var array $decoded */ + $data[] = $decoded; + } } } - } - $pagination = PaginationHelper::buildMeta($total, $page, $perPage); + $pagination = PaginationHelper::buildMeta($total, $page, $perPage); - return new PaginationResultDTO($data, $pagination); + return new PaginationResultDTO($data, $pagination); + } catch (RepositoryException $e) { + throw $e; + } catch (\Exception $e) { + throw new InvalidPaginationException('Paginate operation failed.', 0, $e); + } } /** @@ -331,20 +359,26 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar $perPage = 10; } - // Get all filtered items - $allFiltered = $this->findBy($filters, $orderBy); - $total = count($allFiltered); + try { + // Get all filtered items + $allFiltered = $this->findBy($filters, $orderBy); + $total = count($allFiltered); - // Slice for pagination - $offset = ($page - 1) * $perPage; + // Slice for pagination + $offset = ($page - 1) * $perPage; - LimitOffsetValidator::validate($perPage, $offset); + LimitOffsetValidator::validate($perPage, $offset); - $data = array_slice($allFiltered, $offset, $perPage); + $data = array_slice($allFiltered, $offset, $perPage); - $pagination = PaginationHelper::buildMeta($total, $page, $perPage); + $pagination = PaginationHelper::buildMeta($total, $page, $perPage); - return new PaginationResultDTO($data, $pagination); + return new PaginationResultDTO($data, $pagination); + } catch (RepositoryException $e) { + throw $e; + } catch (\Exception $e) { + throw new InvalidPaginationException('PaginateBy operation failed.', 0, $e); + } } /** From 1265df7a491af5f56d94a0c5c95591dc02b6142c Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 19:24:03 +0000 Subject: [PATCH 3/7] Finalize Phase 10: Strict Error Taxonomy & Pagination Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactored `paginate`/`paginateBy` in all Generic Repositories to catch `\Throwable` for strict no-leak guarantee. - Implemented specific exception mapping: `InvalidArgumentException` → `InvalidPaginationException`, Driver failures → `QueryExecutionException`/`DriverOperationException`. - Verified all exception classes exist and inherit correctly from `RepositoryException`. - Updated Audit file to PASS. --- docs/audit/PHASE10_AUDIT.md | 39 ++++++++++++++++++++++++++ src/Generic/GenericMongoRepository.php | 23 +++++++++------ src/Generic/GenericMySQLRepository.php | 30 +++++++------------- src/Generic/GenericRedisRepository.php | 26 +++++++++-------- 4 files changed, 78 insertions(+), 40 deletions(-) create mode 100644 docs/audit/PHASE10_AUDIT.md diff --git a/docs/audit/PHASE10_AUDIT.md b/docs/audit/PHASE10_AUDIT.md new file mode 100644 index 0000000..37e4939 --- /dev/null +++ b/docs/audit/PHASE10_AUDIT.md @@ -0,0 +1,39 @@ +# PHASE 10 AUDIT: Error, Logging & Observability Boundaries + +## Scope +**Focus Areas:** +- `src/Exceptions/` +- `src/Generic/` (Repositories) +- `src/Generic/Support/` (Ops & Builders) +- `src/Base/` +- `docs/adr/ADR-012.md` +- `docs/adr/ADR-013.md` + +## ADR Compliance Matrix + +| ADR | Requirement | Status | Verification Notes | +| :--- | :--- | :--- | :--- | +| **ADR-012** | **Root Exception Mandatory** | ✅ **PASS** | `RepositoryException` is the root of all exceptions. | +| **ADR-012** | **Exception Taxonomy** | ✅ **PASS** | Specific exceptions (`QueryExecutionException`, `InvalidFilterException`, etc.) are implemented and used. | +| **ADR-012** | **No Driver Leaks** | ✅ **PASS** | All repositories wrap driver exceptions in `try-catch` blocks using `\Throwable`. | +| **ADR-013** | **No Mandatory Logging** | ✅ **PASS** | `BaseRepository` uses optional `NullLogger`. | +| **ADR-013** | **Ops Zero Logging** | ✅ **PASS** | No logging found in `*Ops` classes. | +| **ADR-013** | **No Side Effects** | ✅ **PASS** | Logging failures (if any) do not appear to crash flow. | +| **ADR-012** | **Deterministic Messages** | ✅ **PASS** | Exception messages are static and do not leak internal driver details. | + +## Findings + +### Post-Remediation Status +All initial findings have been addressed. +- **Taxonomy:** Full exception hierarchy from ADR-012 is implemented. +- **Leaks:** Repositories now catch `\Throwable` (or `\Exception` where appropriate and covered) to prevent leaks from drivers and helper classes. +- **Messages:** Error messages are sanitized (e.g., "Find operation failed.") while preserving the original exception chain. +- **Redis Safety:** `RedisSafetyException` and `UnsafeOperationException` are properly utilized. + +## Verdict +**STATUS: PASS** + +The codebase now strictly adheres to Phase 10 requirements regarding Error Taxonomy and Logging Boundaries. + +## Lock Recommendation +None. Proceed to Phase 11. diff --git a/src/Generic/GenericMongoRepository.php b/src/Generic/GenericMongoRepository.php index 60c8061..0d53dee 100644 --- a/src/Generic/GenericMongoRepository.php +++ b/src/Generic/GenericMongoRepository.php @@ -57,7 +57,7 @@ public function find(int|string $id): ?array $result = $this->getCollectionObj()->findOne($filter); return $this->getMongoOps()->toArray($result); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Find operation failed.', 0, $e); } } @@ -90,7 +90,7 @@ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = nul return $this->getMongoOps()->cursorToArray($cursor); } catch (InvalidArgumentException $e) { throw new InvalidFilterException('Invalid filter configuration.', 0, $e); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('FindBy operation failed.', 0, $e); } } @@ -112,7 +112,7 @@ public function findOneBy(array $filters): ?array return $this->getMongoOps()->toArray($result); } catch (InvalidArgumentException $e) { throw new InvalidFilterException('Invalid filter configuration.', 0, $e); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('FindOneBy operation failed.', 0, $e); } } @@ -139,7 +139,7 @@ public function count(array $filters = []): int return $this->getCollectionObj()->countDocuments($normalizedFilters); } catch (InvalidArgumentException $e) { throw new InvalidFilterException('Invalid filter configuration.', 0, $e); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Count operation failed.', 0, $e); } } @@ -160,7 +160,7 @@ public function insert(array $data): int|string } return $normalizedId; - } catch (\Exception $e) { + } catch (\Throwable $e) { if ($e instanceof RepositoryException) { throw $e; } @@ -178,7 +178,7 @@ public function update(int|string $id, array $data): bool $result = $this->getCollectionObj()->updateOne($filter, ['$set' => $data]); return $result->getMatchedCount() > 0; - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Update operation failed.', 0, $e); } } @@ -193,7 +193,7 @@ public function delete(int|string $id): bool $result = $this->getCollectionObj()->deleteOne($filter); return $result->getDeletedCount() > 0; - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Delete operation failed.', 0, $e); } } @@ -298,9 +298,14 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar return new PaginationResultDTO($data, $pagination); } catch (RepositoryException $e) { + // Re-throw RepositoryException subclasses (like InvalidPaginationException from Validator) as-is + // to avoid misclassification. throw $e; - } catch (\Exception $e) { - throw new InvalidPaginationException('Pagination failed.', 0, $e); + } catch (InvalidArgumentException $e) { + throw new InvalidPaginationException('Invalid pagination parameters.', 0, $e); + } catch (\Throwable $e) { + // Wrap unexpected errors (Driver/Query) in QueryExecutionException + throw new QueryExecutionException('Pagination failed.', 0, $e); } } diff --git a/src/Generic/GenericMySQLRepository.php b/src/Generic/GenericMySQLRepository.php index 3145a7c..5713b9e 100644 --- a/src/Generic/GenericMySQLRepository.php +++ b/src/Generic/GenericMySQLRepository.php @@ -58,9 +58,7 @@ public function find(int|string $id): ?array $result = $stmt->fetch(PDO::FETCH_ASSOC); return $result === false ? null : $result; - } catch (\PDOException $e) { - throw new QueryExecutionException('Find failed.', 0, $e); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Find operation failed.', 0, $e); } } @@ -98,10 +96,8 @@ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = nul return $result; } catch (InvalidArgumentException $e) { throw new InvalidFilterException('Invalid filter configuration.', 0, $e); - } catch (\PDOException $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('FindBy operation failed.', 0, $e); - } catch (\Exception $e) { - throw new QueryExecutionException('FindBy unexpected error.', 0, $e); } } @@ -144,10 +140,8 @@ public function count(array $filters = []): int return (int)$stmt->fetchColumn(); } catch (InvalidArgumentException $e) { throw new InvalidFilterException('Invalid filter configuration.', 0, $e); - } catch (\PDOException $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Count operation failed.', 0, $e); - } catch (\Exception $e) { - throw new QueryExecutionException('Count unexpected error.', 0, $e); } } @@ -171,10 +165,8 @@ public function insert(array $data): int|string $pdo->prepare($sql)->execute($data); return $this->getMysqlOps()->lastInsertId(); - } catch (\PDOException $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Insert operation failed.', 0, $e); - } catch (\Exception $e) { - throw new QueryExecutionException('Insert unexpected error.', 0, $e); } } @@ -205,10 +197,8 @@ public function update(int|string $id, array $data): bool $stmt = $this->getPdo()->prepare($sql); return $stmt->execute($data); - } catch (\PDOException $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Update operation failed.', 0, $e); - } catch (\Exception $e) { - throw new QueryExecutionException('Update unexpected error.', 0, $e); } } @@ -223,10 +213,8 @@ public function delete(int|string $id): bool $stmt->bindValue(':id', $id); return $stmt->execute(); - } catch (\PDOException $e) { + } catch (\Throwable $e) { throw new QueryExecutionException('Delete operation failed.', 0, $e); - } catch (\Exception $e) { - throw new QueryExecutionException('Delete unexpected error.', 0, $e); } } @@ -321,8 +309,10 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar return new PaginationResultDTO($data, $pagination); } catch (RepositoryException $e) { throw $e; - } catch (\Exception $e) { - throw new InvalidPaginationException('Pagination failed.', 0, $e); + } catch (InvalidArgumentException $e) { + throw new InvalidPaginationException('Invalid pagination parameters.', 0, $e); + } catch (\Throwable $e) { + throw new QueryExecutionException('Pagination failed.', 0, $e); } } } diff --git a/src/Generic/GenericRedisRepository.php b/src/Generic/GenericRedisRepository.php index 4370911..3ff2fa5 100644 --- a/src/Generic/GenericRedisRepository.php +++ b/src/Generic/GenericRedisRepository.php @@ -67,7 +67,7 @@ public function find(int|string $id): ?array /** @var array $decoded */ return $decoded; - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Find operation failed.', 0, $e); } } @@ -95,7 +95,7 @@ public function insert(array $data): int|string try { $this->getRedisOps()->set($key, $payload); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Insert operation failed.', 0, $e); } @@ -122,7 +122,7 @@ public function update(int|string $id, array $data): bool try { return $this->getRedisOps()->set($this->getKey($id), $payload); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Update operation failed.', 0, $e); } } @@ -134,7 +134,7 @@ public function delete(int|string $id): bool { try { return $this->getRedisOps()->del($this->getKey($id)) > 0; - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Delete operation failed.', 0, $e); } } @@ -175,7 +175,7 @@ public function findBy(array $filters, ?array $orderBy = null, ?int $limit = nul return array_values($filtered); // Re-index array } catch (RepositoryException $e) { throw $e; - } catch (\Exception $e) { + } catch (\Throwable $e) { // Catch potential OrderUtils exceptions or array manipulation errors throw new DriverOperationException('FindBy operation failed.', 0, $e); } @@ -223,7 +223,7 @@ public function findAll(): array } return $results; - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('FindAll operation failed.', 0, $e); } } @@ -244,7 +244,7 @@ public function count(array $filters = []): int $keys = $this->getRedisOps()->keys($this->keyPrefix . '*'); return count($keys); - } catch (\Exception $e) { + } catch (\Throwable $e) { throw new DriverOperationException('Count operation failed.', 0, $e); } } @@ -336,8 +336,10 @@ public function paginate(int $page = 1, int $perPage = 10, ?array $orderBy = nul return new PaginationResultDTO($data, $pagination); } catch (RepositoryException $e) { throw $e; - } catch (\Exception $e) { - throw new InvalidPaginationException('Paginate operation failed.', 0, $e); + } catch (InvalidArgumentException $e) { + throw new InvalidPaginationException('Invalid pagination parameters.', 0, $e); + } catch (\Throwable $e) { + throw new DriverOperationException('Paginate operation failed.', 0, $e); } } @@ -376,8 +378,10 @@ public function paginateBy(array $filters, int $page = 1, int $perPage = 10, ?ar return new PaginationResultDTO($data, $pagination); } catch (RepositoryException $e) { throw $e; - } catch (\Exception $e) { - throw new InvalidPaginationException('PaginateBy operation failed.', 0, $e); + } catch (InvalidArgumentException $e) { + throw new InvalidPaginationException('Invalid pagination parameters.', 0, $e); + } catch (\Throwable $e) { + throw new DriverOperationException('PaginateBy operation failed.', 0, $e); } } From 071fce7d3c79ddf50819663611f60950903fac23 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 20:10:26 +0000 Subject: [PATCH 4/7] Fix Phase 10 test failures by aligning assertions with new exception taxonomy. - Updated GenericMySQLRepositoryExceptionTest to assert QueryExecutionException. - Updated GenericMongoRepositoryExceptionTest to assert QueryExecutionException and DriverOperationException. - Updated GenericMySQLCoverageTest and GenericMongoCoverageTest to align with new exception hierarchy and messages. - Updated GenericRedisCoverageTest to assert UnsafeOperationException. - Updated GenericMySQLRepositoryFakeTest to expect InvalidFilterException instead of InvalidArgumentException. - All tests now assert strictly typed exceptions and deterministic messages as per ADR-012. --- .../Coverage/GenericMongoCoverageTest.php | 33 +++++++++-------- .../Coverage/GenericMySQLCoverageTest.php | 37 ++++++++++--------- .../Coverage/GenericRedisCoverageTest.php | 7 ++-- .../GenericMongoRepositoryExceptionTest.php | 31 ++++++++-------- .../GenericMySQLRepositoryExceptionTest.php | 26 ++++++------- .../Fake/GenericMySQLRepositoryFakeTest.php | 4 +- 6 files changed, 71 insertions(+), 67 deletions(-) diff --git a/tests/Generic/Coverage/GenericMongoCoverageTest.php b/tests/Generic/Coverage/GenericMongoCoverageTest.php index 02337d4..7d8fb56 100644 --- a/tests/Generic/Coverage/GenericMongoCoverageTest.php +++ b/tests/Generic/Coverage/GenericMongoCoverageTest.php @@ -16,6 +16,8 @@ namespace Maatify\DataRepository\Tests\Generic\Coverage; use Maatify\Common\Contracts\Adapter\AdapterInterface; +use Maatify\DataRepository\Exceptions\DriverOperationException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Exceptions\RepositoryException; use Maatify\DataRepository\Generic\GenericMongoRepository; use MongoDB\Collection; @@ -109,8 +111,7 @@ protected function getCollection(string $name): mixed } }; - $this->expectException(RepositoryException::class); - // It might be "Insert failed" or a specific message from MongoOps + $this->expectException(DriverOperationException::class); $this->expectExceptionMessage('Insert failed'); $repo->insert(['a' => 1]); @@ -148,56 +149,56 @@ protected function getCollection(string $name): mixed try { $repo->find('123'); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Find failed', $e->getMessage()); + } catch (QueryExecutionException $e) { + $this->assertSame('Find operation failed.', $e->getMessage()); } // FindBy try { $repo->findBy([]); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('FindBy failed', $e->getMessage()); + } catch (QueryExecutionException $e) { + $this->assertSame('FindBy operation failed.', $e->getMessage()); } // FindOneBy try { $repo->findOneBy([]); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('FindOneBy failed', $e->getMessage()); + } catch (QueryExecutionException $e) { + $this->assertSame('FindOneBy operation failed.', $e->getMessage()); } // Count try { $repo->count(); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Count failed', $e->getMessage()); + } catch (QueryExecutionException $e) { + $this->assertSame('Count operation failed.', $e->getMessage()); } // Insert try { $repo->insert(['a' => 1]); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Insert failed', $e->getMessage()); + } catch (DriverOperationException $e) { + $this->assertSame('Insert operation failed.', $e->getMessage()); } // Update try { $repo->update('123', ['a' => 1]); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Update failed', $e->getMessage()); + } catch (DriverOperationException $e) { + $this->assertSame('Update operation failed.', $e->getMessage()); } // Delete try { $repo->delete('123'); $this->fail('Expected exception'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Delete failed', $e->getMessage()); + } catch (DriverOperationException $e) { + $this->assertSame('Delete operation failed.', $e->getMessage()); } } } diff --git a/tests/Generic/Coverage/GenericMySQLCoverageTest.php b/tests/Generic/Coverage/GenericMySQLCoverageTest.php index 37144b3..dfa74a9 100644 --- a/tests/Generic/Coverage/GenericMySQLCoverageTest.php +++ b/tests/Generic/Coverage/GenericMySQLCoverageTest.php @@ -17,6 +17,7 @@ use Maatify\Common\Contracts\Adapter\AdapterInterface; use Maatify\DataRepository\Exceptions\RepositoryException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Generic\GenericMySQLRepository; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -112,49 +113,49 @@ public function testCrudMethodsThrowRepositoryExceptionOnPdoFailure(): void // Test Find try { $this->repository->find(1); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Find failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('Find operation failed.', $e->getMessage()); } // Test FindBy try { $this->repository->findBy(['col' => 'val']); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('FindBy failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('FindBy operation failed.', $e->getMessage()); } // Test Count try { $this->repository->count(); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Count failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('Count operation failed.', $e->getMessage()); } // Test Insert try { $this->repository->insert(['col' => 'val']); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Insert failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('Insert operation failed.', $e->getMessage()); } // Test Update try { $this->repository->update(1, ['col' => 'val']); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Update failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('Update operation failed.', $e->getMessage()); } // Test Delete try { $this->repository->delete(1); - $this->fail('Expected RepositoryException not thrown'); - } catch (RepositoryException $e) { - $this->assertStringContainsString('Delete failed', $e->getMessage()); + $this->fail('Expected QueryExecutionException not thrown'); + } catch (QueryExecutionException $e) { + $this->assertSame('Delete operation failed.', $e->getMessage()); } } } diff --git a/tests/Generic/Coverage/GenericRedisCoverageTest.php b/tests/Generic/Coverage/GenericRedisCoverageTest.php index 3d442e8..ccf3976 100644 --- a/tests/Generic/Coverage/GenericRedisCoverageTest.php +++ b/tests/Generic/Coverage/GenericRedisCoverageTest.php @@ -17,6 +17,7 @@ use Maatify\Common\Contracts\Adapter\AdapterInterface; use Maatify\DataRepository\Exceptions\RepositoryException; +use Maatify\DataRepository\Exceptions\UnsafeOperationException; use Maatify\DataRepository\Generic\GenericRedisRepository; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -56,21 +57,21 @@ public function testCountWithFiltersThrowsException(): void // Phase 19 implementation only touched findBy/paginateBy logic. // Let's check the code: GenericRedisRepository::count() still has check: // if (! empty($filters)) { throw new RepositoryException... } - $this->expectException(RepositoryException::class); + $this->expectException(UnsafeOperationException::class); $this->expectExceptionMessage('Filtering count is not supported'); $this->repository->count(['a' => 1]); } public function testInsertThrowsExceptionIfIdMissing(): void { - $this->expectException(RepositoryException::class); + $this->expectException(UnsafeOperationException::class); $this->expectExceptionMessage("Generic Redis Insert requires 'id'"); $this->repository->insert(['name' => 'test']); } public function testInsertThrowsExceptionIfIdInvalidType(): void { - $this->expectException(RepositoryException::class); + $this->expectException(UnsafeOperationException::class); $this->expectExceptionMessage("Generic Redis Insert 'id' must be int|string"); $this->repository->insert(['id' => 1.5]); } diff --git a/tests/Generic/Exceptions/GenericMongoRepositoryExceptionTest.php b/tests/Generic/Exceptions/GenericMongoRepositoryExceptionTest.php index 10abc6b..8c1146e 100644 --- a/tests/Generic/Exceptions/GenericMongoRepositoryExceptionTest.php +++ b/tests/Generic/Exceptions/GenericMongoRepositoryExceptionTest.php @@ -16,7 +16,8 @@ namespace Maatify\DataRepository\Tests\Generic\Exceptions; use Maatify\Common\Contracts\Adapter\AdapterInterface; -use Maatify\DataRepository\Exceptions\RepositoryException; +use Maatify\DataRepository\Exceptions\DriverOperationException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Generic\GenericMongoRepository; use MongoDB\Collection; use PHPUnit\Framework\MockObject\MockObject; @@ -97,8 +98,8 @@ public function testFindThrowsRepositoryException(): void { $this->collection->method('findOne')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Find failed: Simulated Mongo Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Find operation failed.'); $this->repository->find('507f1f77bcf86cd799439011'); } @@ -107,8 +108,8 @@ public function testFindByThrowsRepositoryException(): void { $this->collection->method('find')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('FindBy failed: Simulated Mongo Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('FindBy operation failed.'); $this->repository->findBy(['status' => 1]); } @@ -117,8 +118,8 @@ public function testFindOneByThrowsRepositoryException(): void { $this->collection->method('findOne')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('FindOneBy failed: Simulated Mongo Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('FindOneBy operation failed.'); $this->repository->findOneBy(['status' => 1]); } @@ -127,8 +128,8 @@ public function testCountThrowsRepositoryException(): void { $this->collection->method('countDocuments')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Count failed: Simulated Mongo Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Count operation failed.'); $this->repository->count(); } @@ -137,8 +138,8 @@ public function testInsertThrowsRepositoryException(): void { $this->collection->method('insertOne')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Insert failed: Simulated Mongo Error'); + $this->expectException(DriverOperationException::class); + $this->expectExceptionMessage('Insert operation failed.'); $this->repository->insert(['col' => 'val']); } @@ -147,8 +148,8 @@ public function testUpdateThrowsRepositoryException(): void { $this->collection->method('updateOne')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Update failed: Simulated Mongo Error'); + $this->expectException(DriverOperationException::class); + $this->expectExceptionMessage('Update operation failed.'); $this->repository->update('507f1f77bcf86cd799439011', ['col' => 'val']); } @@ -157,8 +158,8 @@ public function testDeleteThrowsRepositoryException(): void { $this->collection->method('deleteOne')->willThrowException(new \Exception('Simulated Mongo Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Delete failed: Simulated Mongo Error'); + $this->expectException(DriverOperationException::class); + $this->expectExceptionMessage('Delete operation failed.'); $this->repository->delete('507f1f77bcf86cd799439011'); } diff --git a/tests/Generic/Exceptions/GenericMySQLRepositoryExceptionTest.php b/tests/Generic/Exceptions/GenericMySQLRepositoryExceptionTest.php index 8f05df4..73641cc 100644 --- a/tests/Generic/Exceptions/GenericMySQLRepositoryExceptionTest.php +++ b/tests/Generic/Exceptions/GenericMySQLRepositoryExceptionTest.php @@ -16,7 +16,7 @@ namespace Maatify\DataRepository\Tests\Generic\Exceptions; use Maatify\Common\Contracts\Adapter\AdapterInterface; -use Maatify\DataRepository\Exceptions\RepositoryException; +use Maatify\DataRepository\Exceptions\QueryExecutionException; use Maatify\DataRepository\Generic\GenericMySQLRepository; use PDO; use PDOException; @@ -73,8 +73,8 @@ public function testFindThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Find failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Find operation failed.'); $this->repository->find(1); } @@ -83,8 +83,8 @@ public function testFindByThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('FindBy failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('FindBy operation failed.'); $this->repository->findBy(['id' => 1]); } @@ -93,8 +93,8 @@ public function testCountThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Count failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Count operation failed.'); $this->repository->count(); } @@ -103,8 +103,8 @@ public function testInsertThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Insert failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Insert operation failed.'); $this->repository->insert(['col' => 'val']); } @@ -113,8 +113,8 @@ public function testUpdateThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Update failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Update operation failed.'); $this->repository->update(1, ['col' => 'val']); } @@ -123,8 +123,8 @@ public function testDeleteThrowsRepositoryException(): void { $this->pdo->method('prepare')->willThrowException(new PDOException('Simulated PDO Error')); - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Delete failed: Simulated PDO Error'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Delete operation failed.'); $this->repository->delete(1); } diff --git a/tests/Generic/Fake/GenericMySQLRepositoryFakeTest.php b/tests/Generic/Fake/GenericMySQLRepositoryFakeTest.php index 324fa7c..765ac0d 100644 --- a/tests/Generic/Fake/GenericMySQLRepositoryFakeTest.php +++ b/tests/Generic/Fake/GenericMySQLRepositoryFakeTest.php @@ -18,7 +18,7 @@ use Maatify\Common\Contracts\Adapter\AdapterInterface; use Maatify\DataRepository\Generic\GenericMySQLRepository; use PHPUnit\Framework\TestCase; -use InvalidArgumentException; +use Maatify\DataRepository\Exceptions\InvalidFilterException; class GenericMySQLRepositoryFakeTest extends TestCase { @@ -128,7 +128,7 @@ public function testCountAndUpdateEmptyPayload(): void public function testFindByThrowsOnInvalidColumns(): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidFilterException::class); $this->repo->findBy(['role' => 'admin', 'invalid column' => 'x']); } From b7adc2c3949c06e900dba2eeea846ffd654909d1 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 20:20:51 +0000 Subject: [PATCH 5/7] Fix remaining Phase 10 configuration tests. Updated: - GenericMongoCoverageTest::testGetCollectionObjThrowsExceptionIfNameMissing - GenericMongoCoverageTest::testGetCollectionObjThrowsExceptionIfDriverFails - GenericMySQLCoverageTest::testGetPdoThrowsExceptionWhenDriverIsNotPdo All tests now assert QueryExecutionException and the deterministic 'Find operation failed.' message, reflecting the strict no-leak boundaries of Phase 10. --- tests/Generic/Coverage/GenericMongoCoverageTest.php | 8 ++++---- tests/Generic/Coverage/GenericMySQLCoverageTest.php | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Generic/Coverage/GenericMongoCoverageTest.php b/tests/Generic/Coverage/GenericMongoCoverageTest.php index 7d8fb56..976363e 100644 --- a/tests/Generic/Coverage/GenericMongoCoverageTest.php +++ b/tests/Generic/Coverage/GenericMongoCoverageTest.php @@ -54,8 +54,8 @@ public function testFind(): void } }; - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Collection name not defined'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Find operation failed.'); $repo->testFind(); } @@ -81,8 +81,8 @@ public function testFind(): void } }; - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('Failed to retrieve MongoDB Collection'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Find operation failed.'); $repo->testFind(); } diff --git a/tests/Generic/Coverage/GenericMySQLCoverageTest.php b/tests/Generic/Coverage/GenericMySQLCoverageTest.php index dfa74a9..de5b5f4 100644 --- a/tests/Generic/Coverage/GenericMySQLCoverageTest.php +++ b/tests/Generic/Coverage/GenericMySQLCoverageTest.php @@ -65,8 +65,8 @@ public function testGetPdoThrowsExceptionWhenDriverIsNotPdo(): void ->method('getDriver') ->willReturn(new \stdClass()); // Not PDO - $this->expectException(RepositoryException::class); - $this->expectExceptionMessage('GenericMySQLRepository requires a PDO driver or compatible wrapper.'); + $this->expectException(QueryExecutionException::class); + $this->expectExceptionMessage('Find operation failed.'); $this->repository->find(1); } From 28f0cd846ee3f83d11135614991d28c23b49c8bf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 23:04:47 +0000 Subject: [PATCH 6/7] Fix PHPStan Level Max Errors in Tests - Updated `RepositoryInterfaceSignatureTest.php` with correct `ReflectionClass` generics and `ReflectionType` handling. - Updated `MongoCastingRegressionTest.php` to use strict `MockObject` types and avoid `object::class` mocking. - Removed dead catch block in `GenericRedisRepositoryFakeTest.php`. - No source code changes. All tests align with strict typing requirements. --- .../RepositoryInterfaceSignatureTest.php | 22 +++++++++++------- .../NoSQL/MongoCastingRegressionTest.php | 18 ++++++++++----- .../Redis/GenericRedisRepositoryFakeTest.php | 23 ++++++------------- 3 files changed, 33 insertions(+), 30 deletions(-) diff --git a/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php b/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php index d447f2c..32430c3 100644 --- a/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php +++ b/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php @@ -21,10 +21,13 @@ use ReflectionMethod; use ReflectionParameter; use ReflectionType; +use ReflectionNamedType; use ReflectionUnionType; +use ReflectionIntersectionType; class RepositoryInterfaceSignatureTest extends TestCase { + /** @var ReflectionClass */ private ReflectionClass $ref; protected function setUp(): void @@ -157,16 +160,19 @@ private function paramNames(ReflectionMethod $method): array */ private function unionTypeNames(?ReflectionType $type): array { - if ($type instanceof ReflectionUnionType) { - $names = array_map( - static fn(ReflectionType $t): string => $t->getName(), - $type->getTypes() - ); - } else { - $names = [$type?->getName() ?? '']; + $names = []; + + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if ($t instanceof ReflectionNamedType) { + $names[] = $t->getName(); + } + } + } elseif ($type instanceof ReflectionNamedType) { + $names[] = $type->getName(); } - sort($names); // ← FIX + sort($names); return $names; } diff --git a/tests/Generic/NoSQL/MongoCastingRegressionTest.php b/tests/Generic/NoSQL/MongoCastingRegressionTest.php index 61b4388..6976597 100644 --- a/tests/Generic/NoSQL/MongoCastingRegressionTest.php +++ b/tests/Generic/NoSQL/MongoCastingRegressionTest.php @@ -28,8 +28,11 @@ */ class MongoCastingRegressionTest extends TestCase { - private object $collectionMock; - private AdapterInterface $adapterMock; + /** @var MockObject&\MongoDB\Collection */ + private $collectionMock; + /** @var MockObject&AdapterInterface */ + private $adapterMock; + /** @var GenericMongoRepository */ private GenericMongoRepository $repo; protected function setUp(): void @@ -39,13 +42,14 @@ protected function setUp(): void } // Mock Collection needed for instanceof check - /** @var MockObject&\MongoDB\Collection $collectionMock */ - $collectionMock = $this->createMock(\MongoDB\Collection::class); - $this->collectionMock = $collectionMock; + $this->collectionMock = $this->createMock(\MongoDB\Collection::class); // Mock Adapter $this->adapterMock = $this->createMock(AdapterInterface::class); $this->adapterMock->method('getDriver')->willReturn(new class ($this->collectionMock) { + /** + * @param \MongoDB\Collection&MockObject $collection + */ public function __construct(private object $collection) {} // Use variadic args to match any signature requirement for selectCollection public function selectCollection(mixed ...$args): object @@ -55,10 +59,12 @@ public function selectCollection(mixed ...$args): object }); // Instantiate Repo - $this->repo = new class ($this->adapterMock) extends GenericMongoRepository { + /** @var GenericMongoRepository $repo */ + $repo = new class ($this->adapterMock) extends GenericMongoRepository { protected string $collectionName = 'test_col'; protected function validateAdapter(): void {} // bypass }; + $this->repo = $repo; } /** diff --git a/tests/Legacy/Redis/GenericRedisRepositoryFakeTest.php b/tests/Legacy/Redis/GenericRedisRepositoryFakeTest.php index 5199ea2..ea5add8 100644 --- a/tests/Legacy/Redis/GenericRedisRepositoryFakeTest.php +++ b/tests/Legacy/Redis/GenericRedisRepositoryFakeTest.php @@ -257,24 +257,15 @@ public function __construct() } }; - // create a proxy that forces ReflectionException - $ops = new class ($driver) extends RedisOps { - public function keys(string $pattern): array - { - throw new \ReflectionException('forced'); - } - }; + // actual RedisOps implementation returns [] if reflection fails + // We cannot force ReflectionException on ReflectionObject constructor easily in a test + // without hacking standard library. + // Instead we rely on the fact that if a property doesn't exist, it returns empty. - try { - $result = $ops->keys('*'); - $this->fail('Exception should have been thrown inside overridden keys()'); - } catch (\ReflectionException) { - // actual RedisOps implementation returns [] if reflection fails - $driver2 = new class () {}; - $ops2 = new \Maatify\DataRepository\Generic\Support\RedisOps($driver2); + $driver2 = new class () {}; + $ops2 = new \Maatify\DataRepository\Generic\Support\RedisOps($driver2); - $this->assertSame([], $ops2->keys('*')); - } + $this->assertSame([], $ops2->keys('*')); } From fde4a57370f71db2e68113654a47a1cbc659a895 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 23:35:37 +0000 Subject: [PATCH 7/7] Fix final Phase 10 PHPStan errors in tests. - Updated RepositoryInterfaceSignatureTest to use ReflectionNamedType safely and fixed union type sorting. - Refactored MongoCastingRegressionTest to use a typed factory method for the anonymous repository class, resolving generic type mismatches. - All changes are strictly within the test suite. --- .../Repository/RepositoryInterfaceSignatureTest.php | 4 +++- tests/Generic/NoSQL/MongoCastingRegressionTest.php | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php b/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php index 32430c3..03b51a4 100644 --- a/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php +++ b/tests/Contracts/Repository/RepositoryInterfaceSignatureTest.php @@ -138,9 +138,11 @@ public function testSetAdapterSignature(): void $this->assertSame(['adapter'], $this->paramNames($method)); $this->assertSame('static', (string) $method->getReturnType()); + $paramType = $method->getParameters()[0]->getType(); + $this->assertInstanceOf(ReflectionNamedType::class, $paramType); $this->assertSame( AdapterInterface::class, - $method->getParameters()[0]->getType()?->getName() + $paramType->getName() ); } diff --git a/tests/Generic/NoSQL/MongoCastingRegressionTest.php b/tests/Generic/NoSQL/MongoCastingRegressionTest.php index 6976597..be87ffd 100644 --- a/tests/Generic/NoSQL/MongoCastingRegressionTest.php +++ b/tests/Generic/NoSQL/MongoCastingRegressionTest.php @@ -59,12 +59,18 @@ public function selectCollection(mixed ...$args): object }); // Instantiate Repo - /** @var GenericMongoRepository $repo */ - $repo = new class ($this->adapterMock) extends GenericMongoRepository { + $this->repo = $this->createGenericMongoRepository($this->adapterMock); + } + + /** + * @return GenericMongoRepository + */ + private function createGenericMongoRepository(AdapterInterface $adapter): GenericMongoRepository + { + return new class ($adapter) extends GenericMongoRepository { protected string $collectionName = 'test_col'; protected function validateAdapter(): void {} // bypass }; - $this->repo = $repo; } /**