From e9def4cfc11332e7bdc7a21c6adc158a894a20ed Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sat, 11 Jul 2026 23:23:49 -0400 Subject: [PATCH 01/26] feat(cache): canonical row cache keys, alias entries, and table generation tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row now has exactly one cache entry, keyed by its table identity derived from row data — never from the model's getIdentity() and never from the shape of the caller's lookup. Business-key lookups resolve through alias entries (pointers to the canonical identity) that self-heal when stale. Writers can therefore always name the keys readers used: updateCompound invalidates the row entry and alias as free byproducts of its existing pre-read, with no extra query. Every context is additionally keyed under a per-table generation token (the WordPress last_changed pattern) that writers replace on every create/update/delete. This closes the cache-aside race where a slow reader writes a stale row back after invalidation — without requiring transactions, which WordPress cannot use — and gives the package O(1) whole-table invalidation (estimatedCount now invalidates correctly). Tables can opt out via shouldUseTableGenerations(). deleteWhere now deletes by the full table identity instead of the model's identity, which on tables where the model identity is a subset of the table's could previously reach rows outside the matched set. Co-Authored-By: Claude Fable 5 --- lib/Traits/WithDatastoreHandlerMethods.php | 413 +++++++++++-- tests/Unit/Traits/CanonicalRowCacheTest.php | 566 ++++++++++++++++++ .../WithDatastoreHandlerMethodsTest.php | 28 +- 3 files changed, 939 insertions(+), 68 deletions(-) create mode 100644 tests/Unit/Traits/CanonicalRowCacheTest.php diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index dbd3ac6..ac2724b 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -3,6 +3,7 @@ namespace PHPNomad\Database\Traits; use PHPNomad\Cache\Enums\Operation; +use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Datastore\Events\RecordUpdated; @@ -37,7 +38,7 @@ trait WithDatastoreHandlerMethods public function getEstimatedCount(): int { return $this->serviceProvider->cacheableService - ->getWithCache('estimatedCount', ['type' => $this->model], function () { + ->getWithCache('estimatedCount', $this->withTableGeneration(['type' => $this->model]), function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -156,12 +157,18 @@ public function create(array $attributes): DataModel $ids = $this->serviceProvider->queryStrategy->insert($this->table, $attributes); - $result = $this->modelAdapter->toModel(Arr::merge($attributes, $ids)); + $row = Arr::merge($attributes, $ids); + $result = $this->modelAdapter->toModel($row); + + // Bump BEFORE pre-warming so the row entry lands under the new + // generation and stays readable; set-level caches (estimatedCount) + // keyed under the old generation become unreachable. + $this->bumpTableGeneration(); // Pre-warm the cache so subsequent reads of this record don't have to - // round-trip the DB at all. Same key the framework's getModels() flow - // would have written, so existing read paths transparently pick it up. - $this->cacheItems([$result]); + // round-trip the DB at all. Same canonical key every read path uses, + // so existing read paths transparently pick it up. + $this->cacheRow($row, $result); $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -201,14 +208,33 @@ protected function applyPhpDefaults(array $attributes): array */ public function deleteWhere(array $conditions): void { - $items = $this->andWhere($conditions); + try { + $identityRows = $this->findIds([['type' => 'AND', 'clauses' => $conditions]]); + } catch (RecordNotFoundException $e) { + return; + } + + $deleted = false; + + foreach ($identityRows as $identityRow) { + // Delete by the full table identity. The previous implementation + // deleted by the MODEL's identity, which can be a subset of the + // table's — on such tables that SQL delete could reach rows + // outside the matched set. + $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); + + $identity = $this->getRowIdentity($identityRow); + + if ($identity !== null) { + $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity)); + $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identity)); + } - foreach ($items as $item) { - $identity = $item->getIdentity(); + $deleted = true; + } - $this->serviceProvider->queryStrategy->delete($this->table, $identity); - $this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($identity)); - $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($item::class, $identity)); + if ($deleted) { + $this->bumpTableGeneration(); } } @@ -297,41 +323,214 @@ protected function buildConditions(array $groups) } /** - * Gets the cache context for the given ID. + * Extracts the canonical identity from row data: the table's identity + * fields, in the table's declared order, with scalar values normalized to + * strings (an int identity from a hydrated write and a string identity + * from the query strategy must be the same identity). * - * Scalar identity values are normalized to strings so that an int identity - * coming from a hydrated model and a string identity coming back from the - * query strategy (MySQL returns identity columns as strings) hash to the - * same cache key. Without this, the same record produces two cache entries - * — one keyed by int, one by string — and updateCompound() only invalidates - * one of them, leaving the other to serve stale reads. + * Returns null (and logs) when the row is missing an identity field — + * a partial context must never be used as a cache key. * - * @param array $identities list of identities keyed by the field name for the identity. - * @return array + * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). + * @return array|null + */ + protected function getRowIdentity(array $row): ?array + { + $identityFields = $this->table->getFieldsForIdentity(); + + if (empty($identityFields)) { + return null; + } + + $identity = []; + + foreach ($identityFields as $field) { + if (!array_key_exists($field, $row)) { + $this->serviceProvider->loggerStrategy->warning( + 'Cannot derive a canonical cache identity — row is missing an identity field.', + ['table' => $this->table->getName(), 'missingField' => $field, 'rowFields' => array_keys($row)] + ); + + return null; + } + + $value = $row[$field]; + $identity[$field] = is_scalar($value) ? (string) $value : $value; + } + + return $identity; + } + + /** + * Builds the ONE cache context a row is stored under, derived from row + * data rather than from whatever shape the caller asked for. Every read + * and every invalidation goes through this context, so writers can always + * name the key readers used. + * + * @param array $row + * @return array|null Null when the row cannot produce a full identity. + */ + protected function getCanonicalRowContext(array $row): ?array + { + $identity = $this->getRowIdentity($row); + + return $identity === null ? null : $this->getCanonicalIdentityContext($identity); + } + + /** + * Wraps an already-canonical identity (from getRowIdentity()) in the row + * cache context. + * + * @param array $identity + */ + protected function getCanonicalIdentityContext(array $identity): array + { + return $this->withTableGeneration(['type' => $this->model, 'identities' => $identity]); + } + + /** + * Builds the cache context for an alias entry: a pointer from a + * business-key lookup (any compound key that is not the table identity) + * to the row's canonical identity. Aliases store identities, never row + * data, so a stale alias self-heals: the row read it points to misses and + * falls through to the database. + * + * @param array $ids The caller's lookup key. */ - protected function getCacheContextForItem(array $identities): array + protected function getAliasContext(array $ids): array { $normalized = []; - foreach ($identities as $key => $value) { - $normalized[$key] = is_scalar($value) ? (string) $value : $value; + + foreach ($ids as $field => $value) { + $normalized[$field] = is_scalar($value) ? (string) $value : $value; } - return ['identities' => Arr::merge($normalized), 'type' => $this->model]; + ksort($normalized); + + return $this->withTableGeneration(['type' => $this->model, 'alias' => $normalized]); } /** - * Caches items in-batch + * True when the given compound key is exactly the table's identity field + * set (order-insensitive). * - * @param DataModel[] $models + * @param array $ids + */ + protected function isTableIdentity(array $ids): bool + { + $identityFields = $this->table->getFieldsForIdentity(); + + return !empty($identityFields) + && count($ids) === count($identityFields) + && !array_diff(array_keys($ids), $identityFields); + } + + /** + * Caches a single row's model under its canonical row context. Skips + * silently (already logged by getRowIdentity()) when the row cannot + * produce a full identity. * - * @return void + * @param array $row + * @param DataModel $model + */ + protected function cacheRow(array $row, DataModel $model): void + { + $context = $this->getCanonicalRowContext($row); + + if ($context !== null) { + $this->serviceProvider->cacheableService->set($context, $model); + } + } + + /** + * Whether this table's cache contexts are keyed under a per-table + * generation token. On by default; override to opt a write-hot table out + * (accepting TTL-bounded staleness on the read-back race in exchange for + * a higher hit rate). + */ + protected function shouldUseTableGenerations(): bool + { + return true; + } + + /** + * Folds the current table generation into a cache context. The generation + * is an opaque token every writer replaces, which makes ALL previously + * written contexts for this table unreachable at once — O(1) table-wide + * invalidation with no transactions required, and the close for the + * cache-aside race where a slow reader SETs a stale row back after a + * writer invalidated it (the stale SET lands under the old generation). + * + * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin + */ + protected function withTableGeneration(array $context): array + { + if (!$this->shouldUseTableGenerations()) { + return $context; + } + + $context['gen'] = $this->getTableGeneration(); + + return $context; + } + + /** + * Reads the current generation token for this table, minting one when + * absent (first read, or after eviction — both simply start a new + * generation with a cold table cache). + */ + protected function getTableGeneration(): string + { + $context = ['type' => $this->model, 'generation' => true]; + + $token = $this->getCachedValue($context); + + if (!is_string($token) || $token === '') { + $token = $this->mintTableGeneration(); + $this->serviceProvider->cacheableService->set($context, $token); + } + + return $token; + } + + /** + * Replaces the table's generation token. Called after every successful + * write. A no-op when generations are disabled for this table. + */ + protected function bumpTableGeneration(): void + { + if (!$this->shouldUseTableGenerations()) { + return; + } + + $this->serviceProvider->cacheableService->set( + ['type' => $this->model, 'generation' => true], + $this->mintTableGeneration() + ); + } + + /** + * Mints an opaque, unique generation token. + */ + protected function mintTableGeneration(): string + { + return bin2hex(random_bytes(8)); + } + + /** + * Cache read that treats "not found" as null instead of an exception. + * + * @return mixed|null */ - protected function cacheItems(array $models): void + protected function getCachedValue(array $context) { - Arr::map($models, fn(DataModel $model) => $this->serviceProvider->cacheableService->set( - $this->getCacheContextForItem($model->getIdentity()), - $model - )); + try { + $value = $this->serviceProvider->cacheableService->get($context); + } catch (CachedItemNotFoundException $e) { + return null; + } + + return $value; } /** @@ -384,7 +583,11 @@ protected function getModels(array $ids): array // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, - fn(array $ids) => !$this->serviceProvider->cacheableService->exists($this->getCacheContextForItem($ids)) + function (array $identityRow) { + $context = $this->getCanonicalRowContext($identityRow); + + return $context === null || !$this->serviceProvider->cacheableService->exists($context); + } ); if (!empty($idsToQuery)) { @@ -397,8 +600,12 @@ protected function getModels(array $ids): array ->where($clauseBuilder->andWhere($this->table->getFieldsForIdentity(), 'IN', ...$idsToQuery)) ); - // Cache those items. - $this->cacheItems($this->hydrateItems($data)); + // Cache those items under their canonical row contexts, keyed + // from the ROW's identity values — never the model's getIdentity(), + // which is not necessarily the table's identity. + foreach ($data as $row) { + $this->cacheRow($row, $this->modelAdapter->toModel($row)); + } } // Now, use the cache to get all the posts in the proper order. @@ -417,50 +624,134 @@ protected function findFromCompound(array $ids) throw new RecordNotFoundException('Record cannot be found, no IDs provided.'); } - return $this->serviceProvider->cacheableService->getWithCache( - Operation::Read, - $this->getCacheContextForItem($ids), - function () use ($ids) { - $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); + // Canonical lookup: the caller's key IS the table identity, so the + // row entry can be addressed directly (after normalizing order/types). + if ($this->isTableIdentity($ids)) { + $identity = $this->getRowIdentity($ids); - foreach($ids as $key => $id){ - $clauseBuilder->andWhere($key, '=', $id); - } + return $this->serviceProvider->cacheableService->getWithCache( + Operation::Read, + $this->getCanonicalIdentityContext($identity), + fn () => $this->queryRowAndModel($ids)[1] + ); + } - $items = $this->serviceProvider->queryStrategy->query( - $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->where($clauseBuilder) - ->limit(1) - ); + // Business-key lookup: resolve through an alias entry so the row is + // still cached exactly once, under its canonical identity. + $aliasContext = $this->getAliasContext($ids); + $aliasedIdentity = $this->getCachedValue($aliasContext); + + if (is_array($aliasedIdentity) && $this->isTableIdentity($aliasedIdentity)) { + try { + return $this->findFromCompound($aliasedIdentity); + } catch (RecordNotFoundException $e) { + // Stale alias — the row it points at moved or died. Drop it + // and re-resolve from the database. + $this->serviceProvider->cacheableService->delete($aliasContext); + } + } - $item = Arr::get($items, 0); + [$row, $model] = $this->queryRowAndModel($ids); - if (!$item) { - throw new RecordNotFoundException(sprintf( - 'Record not found in table "%s" using identity %s.', - $this->table->getName(), - $this->encodeExceptionContext($ids) - )); - } + $identity = $this->getRowIdentity($row); - return $this->modelAdapter->toModel($item); - } + if ($identity !== null) { + $this->serviceProvider->cacheableService->set($aliasContext, $identity); + $this->cacheRow($row, $model); + } + + return $model; + } + + /** + * Queries a single row by the given compound key and hydrates it. + * + * @param array $ids + * @return array{0: array, 1: DataModel} The raw row and its model. + * @throws RecordNotFoundException When no row matches. + * @throws DatastoreErrorException + */ + protected function queryRowAndModel(array $ids): array + { + $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); + + foreach ($ids as $key => $id) { + $clauseBuilder->andWhere($key, '=', $id); + } + + $items = $this->serviceProvider->queryStrategy->query( + $this->serviceProvider->queryBuilder + ->select('*') + ->from($this->table) + ->where($clauseBuilder) + ->limit(1) ); + + $item = Arr::get($items, 0); + + if (!$item) { + throw new RecordNotFoundException(sprintf( + 'Record not found in table "%s" using identity %s.', + $this->table->getName(), + $this->encodeExceptionContext($ids) + )); + } + + return [$item, $this->modelAdapter->toModel($item)]; } /** @inheritDoc */ public function updateCompound($ids, array $attributes): void { + // The pre-read warms the alias (business-key callers) or the row + // entry (canonical callers) — which is what makes the canonical + // identity resolvable below without an extra query. $record = $this->findFromCompound($ids); $this->maybeThrowForDuplicateUniqueFields($attributes, $ids); + $identity = $this->resolveTableIdentity($ids); + $this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes); - $this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($ids)); + + // Precise invalidation first, under the CURRENT generation (the one + // readers wrote their entries with), then bump. The precise deletes + // carry tables that opt out of generations; the bump closes the + // cache-aside race for everyone else. + if ($identity !== null) { + $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity)); + } + + if (!$this->isTableIdentity($ids)) { + // Drop the alias too: the update may have moved the row's + // business key or identity out from under it. + $this->serviceProvider->cacheableService->delete($this->getAliasContext($ids)); + } + + $this->bumpTableGeneration(); + $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes)); } + /** + * Resolves the caller's compound key to the row's canonical table + * identity: directly when the key IS the table identity, via the alias + * entry (warmed by the pre-read) otherwise. Returns null when it cannot + * be resolved — invalidation then falls to the generation bump. + * + * @param array $ids + * @return array|null + */ + protected function resolveTableIdentity(array $ids): ?array + { + if ($this->isTableIdentity($ids)) { + return $this->getRowIdentity($ids); + } + + $aliased = $this->getCachedValue($this->getAliasContext($ids)); + + return (is_array($aliased) && $this->isTableIdentity($aliased)) ? $aliased : null; + } + /** * @param array $attributes * @param array $fields diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php new file mode 100644 index 0000000..e2421a9 --- /dev/null +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -0,0 +1,566 @@ +cacheStrategy = new ArrayCacheStrategy(); + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $this->cacheStrategy, + new SerializingCachePolicy() + ); + $this->queryStrategy = new ScriptedQueryStrategy(); + } + + private function makeHandler(array $identityFields, string $tableName = 'test_records', bool $useGenerations = false): CanonicalHandler + { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn($tableName); + $table->method('getFieldsForIdentity')->willReturn($identityFields); + $table->method('getColumns')->willReturn([]); + + $tableSchemaService = $this->createMock(TableSchemaService::class); + $tableSchemaService->method('getUniqueColumns')->willReturn([]); + + $serviceProvider = new DatabaseServiceProvider( + $this->createMock(LoggerStrategy::class), + $this->queryStrategy, + new FakeQueryBuilder(), + new FakeClauseBuilder(), + $this->cacheableService, + new NullEventStrategy() + ); + + return new CanonicalHandler( + $serviceProvider, + $table, + $tableSchemaService, + AttrModel::class, + new ArrayModelAdapter(), + $useGenerations + ); + } + + public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(): void + { + $handler = $this->makeHandler(['orgId', 'id']); + + // findIds → identity rows; then SELECT * for the uncached row. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => 1, 'id' => 42, 'name' => 'first']]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'first']]]]); + + $this->assertCount(1, $models); + + // Cached under the TABLE identity (orgId + id, stringified, table order)… + $this->assertTrue( + $this->cacheableService->exists($handler->exposeRowContext(['orgId' => 1, 'id' => 42])), + 'Row is not cached under its canonical table-identity context.' + ); + // …and NOT under the model's own (subset) identity. + $this->assertFalse( + $this->cacheableService->exists(['type' => AttrModel::class, 'identities' => ['id' => '42']]), + 'Row leaked a cache entry keyed by the MODEL identity.' + ); + + // A second identical read: findIds queries again (list SQL is not + // cached), but the row itself is served from cache — no SELECT *. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'first']]]]); + + $this->assertSame(3, $this->queryStrategy->queryCount, 'Cached row was re-queried.'); + } + + public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(): void + { + $handler = $this->makeHandler(['id'], 'test_api_keys'); + + // Prime the row cache the way production reads do (list hydration). + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'keyHash', 'operator' => '=', 'value' => 'abc']]]]); + + // Update by BUSINESS KEY (not the table identity). The pre-read + // resolves the row (one query), then the write must invalidate the + // id-keyed entry the read above created. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); + + $this->assertSame([['keyHash' => 'abc'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); + $this->assertFalse( + $this->cacheableService->exists($handler->exposeRowContext(['id' => '7'])), + 'Business-key update left the canonical row entry to serve stale reads.' + ); + + // The next read round-trips the DB and sees the new value. + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'revoked']]); + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'keyHash', 'operator' => '=', 'value' => 'abc']]]]); + + $this->assertSame('revoked', $models[0]->get('status')); + } + + public function testBusinessKeyLookupIsServedByAliasWithoutRequery(): void + { + $handler = $this->makeHandler(['id']); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + + $first = $handler->findByCompound(['keyHash' => 'abc']); + $second = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $first->get('id')); + $this->assertSame('7', $second->get('id')); + $this->assertSame(1, $this->queryStrategy->queryCount, 'Alias-resolved lookup hit the database again.'); + } + + public function testStaleAliasSelfHeals(): void + { + $handler = $this->makeHandler(['id']); + + // Seed: keyHash abc → id 7. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // The row dies out from under the alias (deleted / re-keyed outside + // this process). Drop the row entry to simulate; the alias remains. + $this->cacheableService->delete($handler->exposeRowContext(['id' => '7'])); + + // Alias → id 7 → miss → DB says id 7 is gone… + $this->queryStrategy->queueQueryResult([]); + // …so the alias is dropped and the business key re-resolves to id 9. + $this->queryStrategy->queueQueryResult([['id' => '9', 'keyHash' => 'abc', 'status' => 'active']]); + + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('9', $model->get('id'), 'Stale alias did not self-heal.'); + + // And the healed alias serves the next lookup without a query. + $before = $this->queryStrategy->queryCount; + $handler->findByCompound(['keyHash' => 'abc']); + $this->assertSame($before, $this->queryStrategy->queryCount); + } + + public function testGenerationBumpMakesLateStaleWriteUnreachable(): void + { + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Reader caches the row under the current generation. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $stale = $handler->findByCompound(['id' => '7']); + + // A slow reader computed its context BEFORE the write… + $staleContext = $handler->exposeRowContext(['id' => '7']); + + // …the writer updates and bumps the generation (its pre-read is + // served from the cache — no query)… + $handler->updateCompound(['id' => '7'], ['status' => 'revoked']); + + // …and the slow reader SETs its stale row back using the OLD context. + $this->cacheableService->set($staleContext, $stale); + + // The stale entry physically exists, but no new reader can compute + // its key: the next read misses and round-trips the DB. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'revoked']]); + $fresh = $handler->findByCompound(['id' => '7']); + + $this->assertSame('revoked', $fresh->get('status'), 'Late stale write-back was served after the generation bump.'); + } + + public function testDeleteWhereDeletesByTableIdentityAndInvalidates(): void + { + $handler = $this->makeHandler(['orgId', 'id']); + + // Prime the cache. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'name' => 'doomed']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'doomed']]]]); + + // deleteWhere resolves identity rows and deletes by the FULL table + // identity — not the model's subset identity. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); + $this->assertFalse( + $this->cacheableService->exists($handler->exposeRowContext(['orgId' => '1', 'id' => '42'])), + 'deleteWhere left the canonical row entry behind.' + ); + } +} + +class CanonicalHandler +{ + use WithDatastoreHandlerMethods; + + private bool $useGenerations; + + public function __construct( + DatabaseServiceProvider $serviceProvider, + Table $table, + TableSchemaService $tableSchemaService, + string $model, + ModelAdapter $modelAdapter, + bool $useGenerations = false + ) { + $this->serviceProvider = $serviceProvider; + $this->table = $table; + $this->tableSchemaService = $tableSchemaService; + $this->model = $model; + $this->modelAdapter = $modelAdapter; + $this->useGenerations = $useGenerations; + } + + protected function shouldUseTableGenerations(): bool + { + return $this->useGenerations; + } + + public function findByCompound(array $ids) + { + return $this->findFromCompound($ids); + } + + public function exposeRowContext(array $row): ?array + { + return $this->getCanonicalRowContext($row); + } +} + +/** + * Model whose own identity is deliberately a SUBSET of the table identity + * (like a model that omits a tenant column) — the trait must never key the + * cache off it. + */ +class AttrModel implements DataModel +{ + public function __construct(private array $row = []) + { + } + + public function get(string $field) + { + return $this->row[$field] ?? null; + } + + public function getIdentity(): array + { + return ['id' => $this->row['id'] ?? null]; + } +} + +class ArrayModelAdapter implements ModelAdapter +{ + public function toModel(array $array): DataModel + { + return new AttrModel($array); + } + + public function toArray(DataModel $model): array + { + return []; + } +} + +class ScriptedQueryStrategy implements QueryStrategy +{ + /** @var array[] */ + private array $queryResults = []; + public int $queryCount = 0; + /** @var array[] */ + public array $updates = []; + /** @var array[] */ + public array $deletes = []; + + public function queueQueryResult(array $result): void + { + $this->queryResults[] = $result; + } + + public function query(QueryBuilder $builder): array + { + $this->queryCount++; + + if (empty($this->queryResults)) { + throw new \LogicException('ScriptedQueryStrategy ran out of queued query results — unexpected query #' . $this->queryCount); + } + + return array_shift($this->queryResults); + } + + public function insert(Table $table, array $data): array + { + return ['id' => 1]; + } + + public function delete(Table $table, array $ids): void + { + $this->deletes[] = $ids; + } + + public function update(Table $table, array $ids, array $data): void + { + $this->updates[] = [$ids, $data]; + } + + public function estimatedCount(Table $table): int + { + return 0; + } +} + +class ArrayCacheStrategy implements CacheStrategy +{ + /** @var array */ + public array $store = []; + + public function get(string $key) + { + if (!array_key_exists($key, $this->store)) { + throw new CachedItemNotFoundException('No cached item found for key ' . $key); + } + + return $this->store[$key]; + } + + public function set(string $key, $value, ?int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function exists(string $key): bool + { + return array_key_exists($key, $this->store); + } + + public function clear(): void + { + $this->store = []; + } +} + +/** + * Deliberately order-sensitive key function: proves the trait's contexts are + * canonical by construction rather than rescued by a normalizing policy. + */ +class SerializingCachePolicy implements CachePolicy +{ + public function shouldCache(string $operation, array $context = []): bool + { + return true; + } + + public function getCacheKey(array $context): string + { + return md5(serialize($context)); + } + + public function getTtl(array $context = []): ?int + { + return 60; + } + + public function shouldInvalidate(string $operation, array $context = []): bool + { + return true; + } +} + +class NullEventStrategy implements EventStrategy +{ + public function broadcast(Event $event): void + { + } +} + +class FakeQueryBuilder implements QueryBuilder +{ + public function useTable(Table $table) + { + return $this; + } + + public function select(string $field, string ...$fields) + { + return $this; + } + + public function from(Table $table) + { + return $this; + } + + public function where(?ClauseBuilder $clauseBuilder) + { + return $this; + } + + public function leftJoin(Table $table, string $column, string $onColumn) + { + return $this; + } + + public function rightJoin(Table $table, string $column, string $onColumn) + { + return $this; + } + + public function groupBy(string $column, string ...$columns) + { + return $this; + } + + public function sum(string $fieldToSum, ?string $alias = null) + { + return $this; + } + + public function count(string $fieldToCount, ?string $alias = null) + { + return $this; + } + + public function limit(int $limit) + { + return $this; + } + + public function offset(int $offset) + { + return $this; + } + + public function orderBy(string $field, string $order) + { + return $this; + } + + public function build(): string + { + return 'SELECT * FROM test_table'; + } + + public function reset() + { + return $this; + } + + public function resetClauses(string $clause, string ...$clauses) + { + return $this; + } +} + +class FakeClauseBuilder implements ClauseBuilder +{ + public function useTable(Table $table) + { + return $this; + } + + public function where($field, string $operator, ...$values) + { + return $this; + } + + public function andWhere($field, string $operator, ...$values) + { + return $this; + } + + public function orWhere($field, string $operator, ...$values) + { + return $this; + } + + public function group(string $logic, ClauseBuilder ...$clauses) + { + return $this; + } + + public function andGroup(string $logic, ClauseBuilder ...$clauses) + { + return $this; + } + + public function orGroup(string $logic, ClauseBuilder ...$clauses) + { + return $this; + } + + public function build(): string + { + return 'id = 123'; + } + + public function reset() + { + return $this; + } +} +} diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 8e3ce0c..f23412a 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -218,6 +218,7 @@ public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void $queryStrategy = $this->createMock(QueryStrategy::class); $cacheableService = $this->createMock(CacheableService::class); $table = $this->createMock(Table::class); + $table->method('getFieldsForIdentity')->willReturn(['id']); $tableSchemaService = $this->createMock(TableSchemaService::class); $modelAdapter = $this->createMock(ModelAdapter::class); @@ -238,9 +239,10 @@ public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void $modelAdapter ); - $intContext = $handler->exposeCacheContext(['id' => 123]); - $stringContext = $handler->exposeCacheContext(['id' => '123']); + $intContext = $handler->exposeRowContext(['id' => 123]); + $stringContext = $handler->exposeRowContext(['id' => '123']); + $this->assertNotNull($intContext); $this->assertSame($intContext, $stringContext); } @@ -270,6 +272,7 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $table = $this->createMock(Table::class); $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn(['id']); $tableSchemaService = $this->createMock(TableSchemaService::class); $tableSchemaService->method('getUniqueColumns')->willReturn([]); $modelAdapter = $this->createMock(ModelAdapter::class); @@ -295,9 +298,9 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // String identity (the shape MySQL returns). $handler->updateCompound(['id' => '42'], ['name' => 'new']); - // The deleted cache context must match what cacheItems wrote earlier, - // which used the int identity from the hydrated model. - $expected = $handler->exposeCacheContext(['id' => 42]); + // The deleted cache context must match what the read path wrote, + // which used the int identity from the hydrated row. + $expected = $handler->exposeRowContext(['id' => 42]); $this->assertCount(1, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); } @@ -319,6 +322,7 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( $table = $this->createMock(Table::class); $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn(['id']); $tableSchemaService = $this->createMock(TableSchemaService::class); $modelAdapter = $this->createMock(ModelAdapter::class); @@ -371,9 +375,19 @@ public function findByIdentity(array $ids) return $this->findFromCompound($ids); } - public function exposeCacheContext(array $ids): array + public function exposeRowContext(array $row): ?array + { + return $this->getCanonicalRowContext($row); + } + + /** + * Legacy tests assert precise per-key cache interactions against a mocked + * CacheableService; generations would add token get/set chatter that + * belongs to the dedicated generation tests. + */ + protected function shouldUseTableGenerations(): bool { - return $this->getCacheContextForItem($ids); + return false; } } From 5318b3c101de7e5c7cdf17e84a51f94c5606a988 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sat, 11 Jul 2026 23:36:08 -0400 Subject: [PATCH 02/26] =?UTF-8?q?fix(cache):=20address=20pr-audit=20findin?= =?UTF-8?q?gs=20=E2=80=94=20snapshot=20generations=20per=20operation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's key finding: cacheRow() re-fetched the generation token at SET time, so on the getModels() and business-key read-back paths a writer bumping between the reader's SELECT and its cache write handed the reader the NEW token — the stale row landed under the new generation and the race the tokens exist to close stayed open. Every operation now takes one generation snapshot BEFORE touching the database and threads it through all context builds, which also drops token fetches from two-per-row to one-per-operation. Also from the audit: - deleteWhere always broadcasts RecordDeleted with the raw identity row — a deletion listeners never hear about is a silent failure, and cache-key normalization must not leak into the event contract. - resolveTableIdentity falls back to a DB resolution for generation-disabled tables when the alias was evicted; for those tables the precise delete is the only invalidation there is. - phpnomad/event is now a dev dependency; the hand-rolled Events interface shims are gone (the real interface immediately caught the shim drift the reviewers predicted) and the no-op query/clause builders live in shared tests/Doubles. - estimatedCount passes Operation::Read like every other call site; dead hydrateItems() removed; stale docblocks on findFromCompound/ getModels/getRowIdentity corrected; shared stringifyScalars() replaces the duplicated normalization loop. - New tests: partial-identity rows never become cache keys (and warn), estimatedCount invalidates after a write, healed aliases serve lookups query-free. Co-Authored-By: Claude Fable 5 --- composer.json | 3 +- composer.lock | 42 +++- lib/Traits/WithDatastoreHandlerMethods.php | 214 ++++++++++++----- tests/Doubles/NoopClauseBuilder.php | 57 +++++ tests/Doubles/NoopQueryBuilder.php | 89 +++++++ tests/Unit/Traits/CanonicalRowCacheTest.php | 223 +++++------------- .../WithDatastoreHandlerMethodsTest.php | 173 ++------------ 7 files changed, 416 insertions(+), 385 deletions(-) create mode 100644 tests/Doubles/NoopClauseBuilder.php create mode 100644 tests/Doubles/NoopQueryBuilder.php diff --git a/composer.json b/composer.json index dd909d2..c442b2b 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "phpnomad/logger": "^1.0" }, "require-dev": { - "phpnomad/tests": "^0.1.0 || ^0.3.0" + "phpnomad/tests": "^0.1.0 || ^0.3.0", + "phpnomad/event": "*" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index 7e0ca1c..ac72d0e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bd34040462bfd6ac5f170bd83fa1d6b5", + "content-hash": "904e4413baf99cb4cb7a1a355b611cb3", "packages": [ { "name": "phpnomad/cache", @@ -1301,6 +1301,46 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpnomad/event", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpnomad/event.git", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpnomad/event/zipball/b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "shasum": "" + }, + "require-dev": { + "phpnomad/tests": "^0.1.0 || ^0.3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPNomad\\Events\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Standiford", + "email": "alex@standiford.us" + } + ], + "homepage": "https://github.com/phpnomad/core", + "support": { + "issues": "https://github.com/phpnomad/event/issues", + "source": "https://github.com/phpnomad/event/tree/1.0.1" + }, + "time": "2026-06-12T10:56:48+00:00" + }, { "name": "phpnomad/tests", "version": "0.3.0", diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index ac2724b..f7f2083 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -38,7 +38,7 @@ trait WithDatastoreHandlerMethods public function getEstimatedCount(): int { return $this->serviceProvider->cacheableService - ->getWithCache('estimatedCount', $this->withTableGeneration(['type' => $this->model]), function () { + ->getWithCache(Operation::Read, $this->withTableGeneration(['type' => $this->model]), function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -163,12 +163,12 @@ public function create(array $attributes): DataModel // Bump BEFORE pre-warming so the row entry lands under the new // generation and stays readable; set-level caches (estimatedCount) // keyed under the old generation become unreachable. - $this->bumpTableGeneration(); + $generation = $this->bumpTableGeneration(); // Pre-warm the cache so subsequent reads of this record don't have to // round-trip the DB at all. Same canonical key every read path uses, // so existing read paths transparently pick it up. - $this->cacheRow($row, $result); + $this->cacheRow($row, $result, $generation); $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -214,6 +214,7 @@ public function deleteWhere(array $conditions): void return; } + $generation = $this->snapshotTableGeneration(); $deleted = false; foreach ($identityRows as $identityRow) { @@ -226,10 +227,15 @@ public function deleteWhere(array $conditions): void $identity = $this->getRowIdentity($identityRow); if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity)); - $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identity)); + $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity, $generation)); } + // The broadcast carries the raw identity row (DB-typed values): + // the deletion HAPPENED, so listeners must hear about it even + // when a canonical cache identity could not be derived, and cache + // key normalization must not leak into the event contract. + $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identityRow)); + $deleted = true; } @@ -332,7 +338,7 @@ protected function buildConditions(array $groups) * a partial context must never be used as a cache key. * * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). - * @return array|null + * @return array|null The canonical identity (scalars stringified, table order), or null. */ protected function getRowIdentity(array $row): ?array { @@ -354,11 +360,27 @@ protected function getRowIdentity(array $row): ?array return null; } - $value = $row[$field]; - $identity[$field] = is_scalar($value) ? (string) $value : $value; + $identity[$field] = $row[$field]; + } + + return $this->stringifyScalars($identity); + } + + /** + * Normalizes scalar values to strings — the single normalization rule + * every cache key shape shares, so an int identity from a hydrated write + * and a string identity from the query strategy produce the same key. + * + * @param array $values + * @return array Same keys; scalar values stringified. + */ + protected function stringifyScalars(array $values): array + { + foreach ($values as $key => $value) { + $values[$key] = is_scalar($value) ? (string) $value : $value; } - return $identity; + return $values; } /** @@ -368,24 +390,26 @@ protected function getRowIdentity(array $row): ?array * name the key readers used. * * @param array $row + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. * @return array|null Null when the row cannot produce a full identity. */ - protected function getCanonicalRowContext(array $row): ?array + protected function getCanonicalRowContext(array $row, ?string $generation = null): ?array { $identity = $this->getRowIdentity($row); - return $identity === null ? null : $this->getCanonicalIdentityContext($identity); + return $identity === null ? null : $this->getCanonicalIdentityContext($identity, $generation); } /** * Wraps an already-canonical identity (from getRowIdentity()) in the row * cache context. * - * @param array $identity + * @param array $identity + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. */ - protected function getCanonicalIdentityContext(array $identity): array + protected function getCanonicalIdentityContext(array $identity, ?string $generation = null): array { - return $this->withTableGeneration(['type' => $this->model, 'identities' => $identity]); + return $this->withTableGeneration(['type' => $this->model, 'identities' => $identity], $generation); } /** @@ -396,18 +420,15 @@ protected function getCanonicalIdentityContext(array $identity): array * falls through to the database. * * @param array $ids The caller's lookup key. + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. */ - protected function getAliasContext(array $ids): array + protected function getAliasContext(array $ids, ?string $generation = null): array { - $normalized = []; - - foreach ($ids as $field => $value) { - $normalized[$field] = is_scalar($value) ? (string) $value : $value; - } + $normalized = $this->stringifyScalars($ids); ksort($normalized); - return $this->withTableGeneration(['type' => $this->model, 'alias' => $normalized]); + return $this->withTableGeneration(['type' => $this->model, 'alias' => $normalized], $generation); } /** @@ -430,12 +451,18 @@ protected function isTableIdentity(array $ids): bool * silently (already logged by getRowIdentity()) when the row cannot * produce a full identity. * + * Callers on read paths MUST pass the generation snapshot they took + * before querying the database: rebuilding the context here with a fresh + * token would let a stale row land under a generation minted AFTER a + * concurrent write — reopening the exact race generations exist to close. + * * @param array $row * @param DataModel $model + * @param string|null $generation Pre-query generation snapshot. */ - protected function cacheRow(array $row, DataModel $model): void + protected function cacheRow(array $row, DataModel $model, ?string $generation = null): void { - $context = $this->getCanonicalRowContext($row); + $context = $this->getCanonicalRowContext($row, $generation); if ($context !== null) { $this->serviceProvider->cacheableService->set($context, $model); @@ -461,23 +488,45 @@ protected function shouldUseTableGenerations(): bool * cache-aside race where a slow reader SETs a stale row back after a * writer invalidated it (the stale SET lands under the old generation). * + * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. + * * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin */ - protected function withTableGeneration(array $context): array + protected function withTableGeneration(array $context, ?string $generation = null): array { if (!$this->shouldUseTableGenerations()) { return $context; } - $context['gen'] = $this->getTableGeneration(); + $context['gen'] = $generation ?? $this->getTableGeneration(); return $context; } + /** + * Takes the generation snapshot a read or invalidation operation should + * key its contexts under — ONCE, at the start of the operation, before + * any database query. Null when generations are disabled for this table. + * + * One snapshot per operation is both the race fence (a stale write-back + * keyed with a pre-write snapshot can never collide with post-bump + * reader keys) and the round-trip bound (one token fetch per operation + * instead of one per row). + */ + protected function snapshotTableGeneration(): ?string + { + return $this->shouldUseTableGenerations() ? $this->getTableGeneration() : null; + } + /** * Reads the current generation token for this table, minting one when * absent (first read, or after eviction — both simply start a new * generation with a cold table cache). + * + * Deliberately NOT getWithCache(): a policy whose shouldCache() declines + * this context would re-mint a token on every read, silently defeating + * generation stability, and each re-mint would broadcast CacheMissed + * noise. The token must live outside policy discretion. */ protected function getTableGeneration(): string { @@ -496,17 +545,25 @@ protected function getTableGeneration(): string /** * Replaces the table's generation token. Called after every successful * write. A no-op when generations are disabled for this table. + * + * @return string|null The freshly minted token, so post-write cache + * writes (create()'s pre-warm) can key under it; null + * when generations are disabled. */ - protected function bumpTableGeneration(): void + protected function bumpTableGeneration(): ?string { if (!$this->shouldUseTableGenerations()) { - return; + return null; } + $token = $this->mintTableGeneration(); + $this->serviceProvider->cacheableService->set( ['type' => $this->model, 'generation' => true], - $this->mintTableGeneration() + $token ); + + return $token; } /** @@ -533,18 +590,6 @@ protected function getCachedValue(array $context) return $value; } - /** - * Converts the given dataset into model objects. - * - * @param array $data - * - * @return DataModel[] - */ - protected function hydrateItems(array $data): array - { - return Arr::map($data, [$this->modelAdapter, 'toModel']); - } - /** * @param array $conditions * @param int|null $limit @@ -578,13 +623,24 @@ public function findIds(array $conditions, ?int $limit = null, ?int $offset = nu * @param array[] $ids * @return array */ + /** + * Gets the models for the given identity rows, read-through cached. + * + * @param array[] $ids Identity rows from findIds() (values arrive DB-typed). + * @return DataModel[] + */ protected function getModels(array $ids): array { + // One generation snapshot for the whole operation, taken BEFORE any + // database read — see cacheRow() for why this must not be re-fetched + // at write-back time. + $generation = $this->snapshotTableGeneration(); + // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, - function (array $identityRow) { - $context = $this->getCanonicalRowContext($identityRow); + function (array $identityRow) use ($generation) { + $context = $this->getCanonicalRowContext($identityRow, $generation); return $context === null || !$this->serviceProvider->cacheableService->exists($context); } @@ -604,26 +660,33 @@ function (array $identityRow) { // from the ROW's identity values — never the model's getIdentity(), // which is not necessarily the table's identity. foreach ($data as $row) { - $this->cacheRow($row, $this->modelAdapter->toModel($row)); + $this->cacheRow($row, $this->modelAdapter->toModel($row), $generation); } } // Now, use the cache to get all the posts in the proper order. - return Arr::map($ids, fn(array $id) => $this->findFromCompound($id)); + return Arr::map($ids, fn(array $id) => $this->findFromCompound($id, $generation)); } /** - * @param non-empty-array $ids - * @return mixed + * Finds a single record by compound key. A key that IS the table + * identity addresses the canonical row entry directly; any other key (a + * business key) resolves through an alias entry first. + * + * @param non-empty-array $ids + * @param string|null $generation Pre-query generation snapshot; taken here when the caller has none. + * @return DataModel * @throws DatastoreErrorException * @throws RecordNotFoundException */ - protected function findFromCompound(array $ids) + protected function findFromCompound(array $ids, ?string $generation = null) { if (empty($ids)) { throw new RecordNotFoundException('Record cannot be found, no IDs provided.'); } + $generation = $generation ?? $this->snapshotTableGeneration(); + // Canonical lookup: the caller's key IS the table identity, so the // row entry can be addressed directly (after normalizing order/types). if ($this->isTableIdentity($ids)) { @@ -631,19 +694,19 @@ protected function findFromCompound(array $ids) return $this->serviceProvider->cacheableService->getWithCache( Operation::Read, - $this->getCanonicalIdentityContext($identity), + $this->getCanonicalIdentityContext($identity, $generation), fn () => $this->queryRowAndModel($ids)[1] ); } // Business-key lookup: resolve through an alias entry so the row is // still cached exactly once, under its canonical identity. - $aliasContext = $this->getAliasContext($ids); + $aliasContext = $this->getAliasContext($ids, $generation); $aliasedIdentity = $this->getCachedValue($aliasContext); if (is_array($aliasedIdentity) && $this->isTableIdentity($aliasedIdentity)) { try { - return $this->findFromCompound($aliasedIdentity); + return $this->findFromCompound($aliasedIdentity, $generation); } catch (RecordNotFoundException $e) { // Stale alias — the row it points at moved or died. Drop it // and re-resolve from the database. @@ -657,7 +720,7 @@ protected function findFromCompound(array $ids) if ($identity !== null) { $this->serviceProvider->cacheableService->set($aliasContext, $identity); - $this->cacheRow($row, $model); + $this->cacheRow($row, $model, $generation); } return $model; @@ -703,28 +766,30 @@ protected function queryRowAndModel(array $ids): array /** @inheritDoc */ public function updateCompound($ids, array $attributes): void { + $generation = $this->snapshotTableGeneration(); + // The pre-read warms the alias (business-key callers) or the row // entry (canonical callers) — which is what makes the canonical // identity resolvable below without an extra query. - $record = $this->findFromCompound($ids); + $record = $this->findFromCompound($ids, $generation); $this->maybeThrowForDuplicateUniqueFields($attributes, $ids); - $identity = $this->resolveTableIdentity($ids); + $identity = $this->resolveTableIdentity($ids, $generation); $this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes); - // Precise invalidation first, under the CURRENT generation (the one - // readers wrote their entries with), then bump. The precise deletes - // carry tables that opt out of generations; the bump closes the - // cache-aside race for everyone else. + // Precise invalidation first, under the pre-write generation (the + // one readers wrote their entries with), then bump. The precise + // deletes carry tables that opt out of generations; the bump closes + // the cache-aside race for everyone else. if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity)); + $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity, $generation)); } if (!$this->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's // business key or identity out from under it. - $this->serviceProvider->cacheableService->delete($this->getAliasContext($ids)); + $this->serviceProvider->cacheableService->delete($this->getAliasContext($ids, $generation)); } $this->bumpTableGeneration(); @@ -735,21 +800,40 @@ public function updateCompound($ids, array $attributes): void /** * Resolves the caller's compound key to the row's canonical table * identity: directly when the key IS the table identity, via the alias - * entry (warmed by the pre-read) otherwise. Returns null when it cannot - * be resolved — invalidation then falls to the generation bump. + * entry (warmed by the pre-read) otherwise. + * + * For generation-disabled tables the alias is the ONLY invalidation + * route — there is no bump to fall back on — so an alias miss (evicted + * between the pre-read and here) resolves from the database instead of + * silently skipping precise invalidation. * * @param array $ids - * @return array|null + * @param string|null $generation Generation snapshot for the alias lookup. + * @return array|null */ - protected function resolveTableIdentity(array $ids): ?array + protected function resolveTableIdentity(array $ids, ?string $generation = null): ?array { if ($this->isTableIdentity($ids)) { return $this->getRowIdentity($ids); } - $aliased = $this->getCachedValue($this->getAliasContext($ids)); + $aliased = $this->getCachedValue($this->getAliasContext($ids, $generation)); - return (is_array($aliased) && $this->isTableIdentity($aliased)) ? $aliased : null; + if (is_array($aliased) && $this->isTableIdentity($aliased)) { + return $aliased; + } + + if ($this->shouldUseTableGenerations()) { + return null; + } + + try { + [$row] = $this->queryRowAndModel($ids); + + return $this->getRowIdentity($row); + } catch (RecordNotFoundException $e) { + return null; + } } /** diff --git a/tests/Doubles/NoopClauseBuilder.php b/tests/Doubles/NoopClauseBuilder.php new file mode 100644 index 0000000..2bf3e06 --- /dev/null +++ b/tests/Doubles/NoopClauseBuilder.php @@ -0,0 +1,57 @@ +queryStrategy = new ScriptedQueryStrategy(); } - private function makeHandler(array $identityFields, string $tableName = 'test_records', bool $useGenerations = false): CanonicalHandler - { + private function makeHandler( + array $identityFields, + string $tableName = 'test_records', + bool $useGenerations = false, + ?LoggerStrategy $logger = null + ): CanonicalHandler { $table = $this->createMock(Table::class); $table->method('getName')->willReturn($tableName); $table->method('getFieldsForIdentity')->willReturn($identityFields); @@ -82,10 +72,10 @@ private function makeHandler(array $identityFields, string $tableName = 'test_re $tableSchemaService->method('getUniqueColumns')->willReturn([]); $serviceProvider = new DatabaseServiceProvider( - $this->createMock(LoggerStrategy::class), + $logger ?? $this->createMock(LoggerStrategy::class), $this->queryStrategy, - new FakeQueryBuilder(), - new FakeClauseBuilder(), + new NoopQueryBuilder(), + new NoopClauseBuilder(), $this->cacheableService, new NullEventStrategy() ); @@ -194,10 +184,24 @@ public function testStaleAliasSelfHeals(): void $model = $handler->findByCompound(['keyHash' => 'abc']); $this->assertSame('9', $model->get('id'), 'Stale alias did not self-heal.'); + } - // And the healed alias serves the next lookup without a query. + public function testHealedAliasServesNextLookupWithoutQuery(): void + { + $handler = $this->makeHandler(['id']); + + // Same healing sequence as testStaleAliasSelfHeals… + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + $this->cacheableService->delete($handler->exposeRowContext(['id' => '7'])); + $this->queryStrategy->queueQueryResult([]); + $this->queryStrategy->queueQueryResult([['id' => '9', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // …then the healed alias must serve the follow-up lookup query-free. $before = $this->queryStrategy->queryCount; $handler->findByCompound(['keyHash' => 'abc']); + $this->assertSame($before, $this->queryStrategy->queryCount); } @@ -227,25 +231,42 @@ public function testGenerationBumpMakesLateStaleWriteUnreachable(): void $this->assertSame('revoked', $fresh->get('status'), 'Late stale write-back was served after the generation bump.'); } - public function testDeleteWhereDeletesByTableIdentityAndInvalidates(): void + public function testEstimatedCountInvalidatesAfterWrite(): void { - $handler = $this->makeHandler(['orgId', 'id']); + $handler = $this->makeHandler(['id'], 'test_records', true); - // Prime the cache. - $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); - $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'name' => 'doomed']]); - $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'doomed']]]]); + $this->queryStrategy->estimatedCountValue = 5; + $this->assertSame(5, $handler->getEstimatedCount()); - // deleteWhere resolves identity rows and deletes by the FULL table - // identity — not the model's subset identity. - $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); - $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'doomed']]); + // Cached: a changed underlying count is not visible yet. + $this->queryStrategy->estimatedCountValue = 6; + $this->assertSame(5, $handler->getEstimatedCount()); - $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); - $this->assertFalse( - $this->cacheableService->exists($handler->exposeRowContext(['orgId' => '1', 'id' => '42'])), - 'deleteWhere left the canonical row entry behind.' - ); + // Any write bumps the table generation, orphaning the cached count. + $handler->create(['name' => 'new row']); + + $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write — generation did not invalidate it.'); + } + + public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(): void + { + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('missingField')); + + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', false, $logger); + + // The SELECT * row lacks orgId — a partial identity must never + // become a cache key. + $this->queryStrategy->queueQueryResult([['id' => '42']]); + $this->queryStrategy->queueQueryResult([['id' => 42, 'name' => 'incomplete']]); + // The read-back cannot be served from cache, so it queries again. + $this->queryStrategy->queueQueryResult([['id' => 42, 'name' => 'incomplete']]); + + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'incomplete']]]]); + + $this->assertSame([], $this->cacheStrategy->store, 'A partial-identity row produced a cache entry.'); } } @@ -327,6 +348,7 @@ class ScriptedQueryStrategy implements QueryStrategy /** @var array[] */ private array $queryResults = []; public int $queryCount = 0; + public int $estimatedCountValue = 0; /** @var array[] */ public array $updates = []; /** @var array[] */ @@ -365,7 +387,7 @@ public function update(Table $table, array $ids, array $data): void public function estimatedCount(Table $table): int { - return 0; + return $this->estimatedCountValue; } } @@ -436,131 +458,12 @@ class NullEventStrategy implements EventStrategy public function broadcast(Event $event): void { } -} - -class FakeQueryBuilder implements QueryBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function select(string $field, string ...$fields) - { - return $this; - } - public function from(Table $table) + public function attach(string $event, callable $action, ?int $priority = null): void { - return $this; } - public function where(?ClauseBuilder $clauseBuilder) + public function detach(string $event, callable $action, ?int $priority = null): void { - return $this; } - - public function leftJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function rightJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function groupBy(string $column, string ...$columns) - { - return $this; - } - - public function sum(string $fieldToSum, ?string $alias = null) - { - return $this; - } - - public function count(string $fieldToCount, ?string $alias = null) - { - return $this; - } - - public function limit(int $limit) - { - return $this; - } - - public function offset(int $offset) - { - return $this; - } - - public function orderBy(string $field, string $order) - { - return $this; - } - - public function build(): string - { - return 'SELECT * FROM test_table'; - } - - public function reset() - { - return $this; - } - - public function resetClauses(string $clause, string ...$clauses) - { - return $this; - } -} - -class FakeClauseBuilder implements ClauseBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function where($field, string $operator, ...$values) - { - return $this; - } - - public function andWhere($field, string $operator, ...$values) - { - return $this; - } - - public function orWhere($field, string $operator, ...$values) - { - return $this; - } - - public function group(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function andGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function orGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function build(): string - { - return 'id = 123'; - } - - public function reset() - { - return $this; - } -} } diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index f23412a..37b6d93 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -1,34 +1,18 @@ $this->id]; } } - -class DummyQueryBuilder implements QueryBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function select(string $field, string ...$fields) - { - return $this; - } - - public function from(Table $table) - { - return $this; - } - - public function where(?ClauseBuilder $clauseBuilder) - { - return $this; - } - - public function leftJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function rightJoin(Table $table, string $column, string $onColumn) - { - return $this; - } - - public function groupBy(string $column, string ...$columns) - { - return $this; - } - - public function sum(string $fieldToSum, ?string $alias = null) - { - return $this; - } - - public function count(string $fieldToCount, ?string $alias = null) - { - return $this; - } - - public function limit(int $limit) - { - return $this; - } - - public function offset(int $offset) - { - return $this; - } - - public function orderBy(string $field, string $order) - { - return $this; - } - - public function build(): string - { - return 'SELECT * FROM test_table'; - } - - public function reset() - { - return $this; - } - - public function resetClauses(string $clause, string ...$clauses) - { - return $this; - } -} - -class DummyClauseBuilder implements ClauseBuilder -{ - public function useTable(Table $table) - { - return $this; - } - - public function where($field, string $operator, ...$values) - { - return $this; - } - - public function andWhere($field, string $operator, ...$values) - { - return $this; - } - - public function orWhere($field, string $operator, ...$values) - { - return $this; - } - - public function group(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function andGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function orGroup(string $logic, ClauseBuilder ...$clauses) - { - return $this; - } - - public function build(): string - { - return 'id = 123'; - } - - public function reset() - { - return $this; - } -} -} From c79715de495ca9ce8e2feba2b22583eb772833ae Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sat, 11 Jul 2026 23:41:38 -0400 Subject: [PATCH 03/26] refactor(cache): extract DatastoreRowCache collaborator from the datastore trait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's remaining major: ~200 lines of cache-key policy (canonical identity derivation, alias contexts, generation mint/read/bump) had accreted directly into WithDatastoreHandlerMethods — the exact trait-as-method-dump failure mode the anti-patterns doc warns about. That vocabulary now lives in DatastoreRowCache, a per-table service the trait builds lazily via DatastoreRowCacheFactory on the DatabaseServiceProvider (exposed like cacheableService, so containers can swap row-cache behavior without touching the trait). The provider's new constructor parameter is optional and self-defaults, so existing six-argument construction keeps working. The trait keeps only its orchestration: shouldUseTableGenerations() as the per-handler override point, and cacheRow() as the one set-side write. Also from the audit: the four canonical-behavior tests now run under BOTH generation modes via a data provider — production defaults to generations on, and previously only the race test exercised that path. Co-Authored-By: Claude Fable 5 --- lib/Factories/DatastoreRowCacheFactory.php | 35 ++ lib/Providers/DatabaseServiceProvider.php | 8 +- lib/Services/DatastoreRowCache.php | 295 +++++++++++++++++ lib/Traits/WithDatastoreHandlerMethods.php | 307 +++--------------- tests/Unit/Traits/CanonicalRowCacheTest.php | 45 ++- .../WithDatastoreHandlerMethodsTest.php | 2 +- 6 files changed, 422 insertions(+), 270 deletions(-) create mode 100644 lib/Factories/DatastoreRowCacheFactory.php create mode 100644 lib/Services/DatastoreRowCache.php diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php new file mode 100644 index 0000000..747310d --- /dev/null +++ b/lib/Factories/DatastoreRowCacheFactory.php @@ -0,0 +1,35 @@ +cacheableService = $cacheableService; + $this->logger = $logger; + } + + /** + * @param class-string $model + */ + public function make(Table $table, string $model, bool $useGenerations = true): DatastoreRowCache + { + return new DatastoreRowCache($this->cacheableService, $this->logger, $table, $model, $useGenerations); + } +} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index 922f084..a85e60c 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -3,6 +3,7 @@ namespace PHPNomad\Database\Providers; use PHPNomad\Cache\Services\CacheableService; +use PHPNomad\Database\Factories\DatastoreRowCacheFactory; use PHPNomad\Database\Interfaces\ClauseBuilder; use PHPNomad\Database\Interfaces\QueryBuilder; use PHPNomad\Database\Interfaces\QueryStrategy; @@ -18,6 +19,7 @@ class DatabaseServiceProvider public QueryBuilder $queryBuilder; public ClauseBuilder $clauseBuilder; public EventStrategy $eventStrategy; + public DatastoreRowCacheFactory $rowCacheFactory; public function __construct( LoggerStrategy $loggerStrategy, @@ -25,7 +27,8 @@ public function __construct( QueryBuilder $queryBuilder, ClauseBuilder $clauseBuilder, CacheableService $cacheableService, - EventStrategy $eventStrategy + EventStrategy $eventStrategy, + ?DatastoreRowCacheFactory $rowCacheFactory = null ) { $this->clauseBuilder = $clauseBuilder; @@ -34,5 +37,8 @@ public function __construct( $this->queryBuilder = $queryBuilder; $this->cacheableService = $cacheableService; $this->eventStrategy = $eventStrategy; + // Optional so existing six-argument construction keeps working; the + // default factory composes from the same injected services. + $this->rowCacheFactory = $rowCacheFactory ?? new DatastoreRowCacheFactory($cacheableService, $loggerStrategy); } } \ No newline at end of file diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php new file mode 100644 index 0000000..073984e --- /dev/null +++ b/lib/Services/DatastoreRowCache.php @@ -0,0 +1,295 @@ +cacheableService = $cacheableService; + $this->logger = $logger; + $this->table = $table; + $this->model = $model; + $this->useGenerations = $useGenerations; + } + + /** + * True when the given compound key is exactly the table's identity field + * set (order-insensitive). + * + * @param array $ids + */ + public function isTableIdentity(array $ids): bool + { + $identityFields = $this->table->getFieldsForIdentity(); + + return !empty($identityFields) + && count($ids) === count($identityFields) + && !array_diff(array_keys($ids), $identityFields); + } + + /** + * Extracts the canonical identity from row data: the table's identity + * fields, in the table's declared order, with scalar values normalized to + * strings (an int identity from a hydrated write and a string identity + * from the query strategy must be the same identity). + * + * Returns null (and logs) when the row is missing an identity field — + * a partial context must never be used as a cache key. + * + * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). + * @return array|null The canonical identity (scalars stringified, table order), or null. + */ + public function rowIdentity(array $row): ?array + { + $identityFields = $this->table->getFieldsForIdentity(); + + if (empty($identityFields)) { + return null; + } + + $identity = []; + + foreach ($identityFields as $field) { + if (!array_key_exists($field, $row)) { + $this->logger->warning( + 'Cannot derive a canonical cache identity — row is missing an identity field.', + ['table' => $this->table->getName(), 'missingField' => $field, 'rowFields' => array_keys($row)] + ); + + return null; + } + + $identity[$field] = $row[$field]; + } + + return $this->stringifyScalars($identity); + } + + /** + * Builds the ONE cache context a row is stored under, derived from row + * data rather than from whatever shape the caller asked for. + * + * @param array $row + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + * @return array|null Null when the row cannot produce a full identity. + */ + public function rowContext(array $row, ?string $generation = null): ?array + { + $identity = $this->rowIdentity($row); + + return $identity === null ? null : $this->identityContext($identity, $generation); + } + + /** + * Wraps an already-canonical identity (from rowIdentity()) in the row + * cache context. + * + * @param array $identity + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + */ + public function identityContext(array $identity, ?string $generation = null): array + { + return $this->withGeneration(['type' => $this->model, 'identities' => $identity], $generation); + } + + /** + * Builds the cache context for an alias entry: a pointer from a + * business-key lookup (any compound key that is not the table identity) + * to the row's canonical identity. Aliases store identities, never row + * data, so a stale alias self-heals: the row read it points to misses and + * falls through to the database. + * + * @param array $ids The caller's lookup key. + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + */ + public function aliasContext(array $ids, ?string $generation = null): array + { + $normalized = $this->stringifyScalars($ids); + + ksort($normalized); + + return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); + } + + /** + * Reads the identity an alias entry points at, validated against the + * table's identity shape. Null on miss, cache failure, or malformed value. + * + * @param array $ids The caller's lookup key. + * @param string|null $generation Generation snapshot for the alias context. + * @return array|null + */ + public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array + { + try { + $aliased = $this->cacheableService->get($this->aliasContext($ids, $generation)); + } catch (CachedItemNotFoundException $e) { + return null; + } + + return (is_array($aliased) && $this->isTableIdentity($aliased)) ? $aliased : null; + } + + /** + * Folds the current table generation into a cache context. The generation + * is an opaque token every writer replaces, which makes ALL previously + * written contexts for this table unreachable at once — O(1) table-wide + * invalidation with no transactions required, and the close for the + * cache-aside race where a slow reader SETs a stale row back after a + * writer invalidated it (the stale SET lands under the old generation). + * + * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. + * + * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin + */ + public function withGeneration(array $context, ?string $generation = null): array + { + if (!$this->useGenerations) { + return $context; + } + + $context['gen'] = $generation ?? $this->currentGeneration(); + + return $context; + } + + /** + * Takes the generation snapshot a read or invalidation operation should + * key its contexts under — ONCE, at the start of the operation, before + * any database query. Null when generations are disabled for this table. + * + * One snapshot per operation is both the race fence (a stale write-back + * keyed with a pre-write snapshot can never collide with post-bump + * reader keys) and the round-trip bound (one token fetch per operation + * instead of one per row). + */ + public function snapshotGeneration(): ?string + { + return $this->useGenerations ? $this->currentGeneration() : null; + } + + /** + * Replaces the table's generation token. Called after every successful + * write. A no-op when generations are disabled for this table. + * + * @return string|null The freshly minted token, so post-write cache + * writes (create()'s pre-warm) can key under it; null + * when generations are disabled. + */ + public function bumpGeneration(): ?string + { + if (!$this->useGenerations) { + return null; + } + + $token = $this->mintGeneration(); + + $this->cacheableService->set($this->generationContext(), $token); + + return $token; + } + + /** + * Whether contexts built by this service carry a generation token. + */ + public function usesGenerations(): bool + { + return $this->useGenerations; + } + + /** + * Reads the current generation token for this table, minting one when + * absent (first read, or after eviction — both simply start a new + * generation with a cold table cache). + * + * Deliberately NOT getWithCache(): a policy whose shouldCache() declines + * this context would re-mint a token on every read, silently defeating + * generation stability, and each re-mint would broadcast CacheMissed + * noise. The token must live outside policy discretion. + */ + protected function currentGeneration(): string + { + try { + $token = $this->cacheableService->get($this->generationContext()); + } catch (CachedItemNotFoundException $e) { + $token = null; + } + + if (!is_string($token) || $token === '') { + $token = $this->mintGeneration(); + $this->cacheableService->set($this->generationContext(), $token); + } + + return $token; + } + + /** + * The context the generation token itself lives under. Never carries a + * generation — it IS the generation. + */ + protected function generationContext(): array + { + return ['type' => $this->model, 'generation' => true]; + } + + /** + * Mints an opaque, unique generation token. + */ + protected function mintGeneration(): string + { + return bin2hex(random_bytes(8)); + } + + /** + * Normalizes scalar values to strings — the single normalization rule + * every cache key shape shares, so an int identity from a hydrated write + * and a string identity from the query strategy produce the same key. + * + * @param array $values + * @return array Same keys; scalar values stringified. + */ + protected function stringifyScalars(array $values): array + { + foreach ($values as $key => $value) { + $values[$key] = is_scalar($value) ? (string) $value : $value; + } + + return $values; + } +} diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index f7f2083..4dd80dd 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -3,13 +3,13 @@ namespace PHPNomad\Database\Traits; use PHPNomad\Cache\Enums\Operation; -use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Datastore\Events\RecordUpdated; use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Providers\DatabaseServiceProvider; +use PHPNomad\Database\Services\DatastoreRowCache; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; use PHPNomad\Datastore\Exceptions\DuplicateEntryException; @@ -31,6 +31,7 @@ trait WithDatastoreHandlerMethods */ protected string $model; protected ModelAdapter $modelAdapter; + protected ?DatastoreRowCache $rowCacheService = null; /** * @inheritDoc @@ -38,7 +39,7 @@ trait WithDatastoreHandlerMethods public function getEstimatedCount(): int { return $this->serviceProvider->cacheableService - ->getWithCache(Operation::Read, $this->withTableGeneration(['type' => $this->model]), function () { + ->getWithCache(Operation::Read, $this->rowCache()->withGeneration(['type' => $this->model]), function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -163,7 +164,7 @@ public function create(array $attributes): DataModel // Bump BEFORE pre-warming so the row entry lands under the new // generation and stays readable; set-level caches (estimatedCount) // keyed under the old generation become unreachable. - $generation = $this->bumpTableGeneration(); + $generation = $this->rowCache()->bumpGeneration(); // Pre-warm the cache so subsequent reads of this record don't have to // round-trip the DB at all. Same canonical key every read path uses, @@ -214,7 +215,7 @@ public function deleteWhere(array $conditions): void return; } - $generation = $this->snapshotTableGeneration(); + $generation = $this->rowCache()->snapshotGeneration(); $deleted = false; foreach ($identityRows as $identityRow) { @@ -224,10 +225,10 @@ public function deleteWhere(array $conditions): void // outside the matched set. $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); - $identity = $this->getRowIdentity($identityRow); + $identity = $this->rowCache()->rowIdentity($identityRow); if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity, $generation)); + $this->serviceProvider->cacheableService->delete($this->rowCache()->identityContext($identity, $generation)); } // The broadcast carries the raw identity row (DB-typed values): @@ -240,7 +241,7 @@ public function deleteWhere(array $conditions): void } if ($deleted) { - $this->bumpTableGeneration(); + $this->rowCache()->bumpGeneration(); } } @@ -329,127 +330,36 @@ protected function buildConditions(array $groups) } /** - * Extracts the canonical identity from row data: the table's identity - * fields, in the table's declared order, with scalar values normalized to - * strings (an int identity from a hydrated write and a string identity - * from the query strategy must be the same identity). + * Lazily builds the per-table row-cache collaborator that owns every + * cache context this trait uses (canonical row identities, alias + * entries, generation tokens). * - * Returns null (and logs) when the row is missing an identity field — - * a partial context must never be used as a cache key. - * - * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). - * @return array|null The canonical identity (scalars stringified, table order), or null. - */ - protected function getRowIdentity(array $row): ?array - { - $identityFields = $this->table->getFieldsForIdentity(); - - if (empty($identityFields)) { - return null; - } - - $identity = []; - - foreach ($identityFields as $field) { - if (!array_key_exists($field, $row)) { - $this->serviceProvider->loggerStrategy->warning( - 'Cannot derive a canonical cache identity — row is missing an identity field.', - ['table' => $this->table->getName(), 'missingField' => $field, 'rowFields' => array_keys($row)] - ); - - return null; - } - - $identity[$field] = $row[$field]; - } - - return $this->stringifyScalars($identity); - } - - /** - * Normalizes scalar values to strings — the single normalization rule - * every cache key shape shares, so an int identity from a hydrated write - * and a string identity from the query strategy produce the same key. - * - * @param array $values - * @return array Same keys; scalar values stringified. - */ - protected function stringifyScalars(array $values): array - { - foreach ($values as $key => $value) { - $values[$key] = is_scalar($value) ? (string) $value : $value; - } - - return $values; - } - - /** - * Builds the ONE cache context a row is stored under, derived from row - * data rather than from whatever shape the caller asked for. Every read - * and every invalidation goes through this context, so writers can always - * name the key readers used. - * - * @param array $row - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - * @return array|null Null when the row cannot produce a full identity. - */ - protected function getCanonicalRowContext(array $row, ?string $generation = null): ?array - { - $identity = $this->getRowIdentity($row); - - return $identity === null ? null : $this->getCanonicalIdentityContext($identity, $generation); - } - - /** - * Wraps an already-canonical identity (from getRowIdentity()) in the row - * cache context. - * - * @param array $identity - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + * @see \PHPNomad\Database\Services\DatastoreRowCache */ - protected function getCanonicalIdentityContext(array $identity, ?string $generation = null): array + protected function rowCache(): DatastoreRowCache { - return $this->withTableGeneration(['type' => $this->model, 'identities' => $identity], $generation); - } - - /** - * Builds the cache context for an alias entry: a pointer from a - * business-key lookup (any compound key that is not the table identity) - * to the row's canonical identity. Aliases store identities, never row - * data, so a stale alias self-heals: the row read it points to misses and - * falls through to the database. - * - * @param array $ids The caller's lookup key. - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - */ - protected function getAliasContext(array $ids, ?string $generation = null): array - { - $normalized = $this->stringifyScalars($ids); - - ksort($normalized); - - return $this->withTableGeneration(['type' => $this->model, 'alias' => $normalized], $generation); + return $this->rowCacheService ??= $this->serviceProvider->rowCacheFactory->make( + $this->table, + $this->model, + $this->shouldUseTableGenerations() + ); } /** - * True when the given compound key is exactly the table's identity field - * set (order-insensitive). - * - * @param array $ids + * Whether this table's cache contexts are keyed under a per-table + * generation token. On by default; override to opt a write-hot table out + * (accepting TTL-bounded staleness on the read-back race in exchange for + * a higher hit rate). */ - protected function isTableIdentity(array $ids): bool + protected function shouldUseTableGenerations(): bool { - $identityFields = $this->table->getFieldsForIdentity(); - - return !empty($identityFields) - && count($ids) === count($identityFields) - && !array_diff(array_keys($ids), $identityFields); + return true; } /** * Caches a single row's model under its canonical row context. Skips - * silently (already logged by getRowIdentity()) when the row cannot - * produce a full identity. + * silently (already logged by DatastoreRowCache::rowIdentity()) when the + * row cannot produce a full identity. * * Callers on read paths MUST pass the generation snapshot they took * before querying the database: rebuilding the context here with a fresh @@ -462,134 +372,13 @@ protected function isTableIdentity(array $ids): bool */ protected function cacheRow(array $row, DataModel $model, ?string $generation = null): void { - $context = $this->getCanonicalRowContext($row, $generation); + $context = $this->rowCache()->rowContext($row, $generation); if ($context !== null) { $this->serviceProvider->cacheableService->set($context, $model); } } - /** - * Whether this table's cache contexts are keyed under a per-table - * generation token. On by default; override to opt a write-hot table out - * (accepting TTL-bounded staleness on the read-back race in exchange for - * a higher hit rate). - */ - protected function shouldUseTableGenerations(): bool - { - return true; - } - - /** - * Folds the current table generation into a cache context. The generation - * is an opaque token every writer replaces, which makes ALL previously - * written contexts for this table unreachable at once — O(1) table-wide - * invalidation with no transactions required, and the close for the - * cache-aside race where a slow reader SETs a stale row back after a - * writer invalidated it (the stale SET lands under the old generation). - * - * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. - * - * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin - */ - protected function withTableGeneration(array $context, ?string $generation = null): array - { - if (!$this->shouldUseTableGenerations()) { - return $context; - } - - $context['gen'] = $generation ?? $this->getTableGeneration(); - - return $context; - } - - /** - * Takes the generation snapshot a read or invalidation operation should - * key its contexts under — ONCE, at the start of the operation, before - * any database query. Null when generations are disabled for this table. - * - * One snapshot per operation is both the race fence (a stale write-back - * keyed with a pre-write snapshot can never collide with post-bump - * reader keys) and the round-trip bound (one token fetch per operation - * instead of one per row). - */ - protected function snapshotTableGeneration(): ?string - { - return $this->shouldUseTableGenerations() ? $this->getTableGeneration() : null; - } - - /** - * Reads the current generation token for this table, minting one when - * absent (first read, or after eviction — both simply start a new - * generation with a cold table cache). - * - * Deliberately NOT getWithCache(): a policy whose shouldCache() declines - * this context would re-mint a token on every read, silently defeating - * generation stability, and each re-mint would broadcast CacheMissed - * noise. The token must live outside policy discretion. - */ - protected function getTableGeneration(): string - { - $context = ['type' => $this->model, 'generation' => true]; - - $token = $this->getCachedValue($context); - - if (!is_string($token) || $token === '') { - $token = $this->mintTableGeneration(); - $this->serviceProvider->cacheableService->set($context, $token); - } - - return $token; - } - - /** - * Replaces the table's generation token. Called after every successful - * write. A no-op when generations are disabled for this table. - * - * @return string|null The freshly minted token, so post-write cache - * writes (create()'s pre-warm) can key under it; null - * when generations are disabled. - */ - protected function bumpTableGeneration(): ?string - { - if (!$this->shouldUseTableGenerations()) { - return null; - } - - $token = $this->mintTableGeneration(); - - $this->serviceProvider->cacheableService->set( - ['type' => $this->model, 'generation' => true], - $token - ); - - return $token; - } - - /** - * Mints an opaque, unique generation token. - */ - protected function mintTableGeneration(): string - { - return bin2hex(random_bytes(8)); - } - - /** - * Cache read that treats "not found" as null instead of an exception. - * - * @return mixed|null - */ - protected function getCachedValue(array $context) - { - try { - $value = $this->serviceProvider->cacheableService->get($context); - } catch (CachedItemNotFoundException $e) { - return null; - } - - return $value; - } - /** * @param array $conditions * @param int|null $limit @@ -634,13 +423,13 @@ protected function getModels(array $ids): array // One generation snapshot for the whole operation, taken BEFORE any // database read — see cacheRow() for why this must not be re-fetched // at write-back time. - $generation = $this->snapshotTableGeneration(); + $generation = $this->rowCache()->snapshotGeneration(); // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, function (array $identityRow) use ($generation) { - $context = $this->getCanonicalRowContext($identityRow, $generation); + $context = $this->rowCache()->rowContext($identityRow, $generation); return $context === null || !$this->serviceProvider->cacheableService->exists($context); } @@ -685,26 +474,26 @@ protected function findFromCompound(array $ids, ?string $generation = null) throw new RecordNotFoundException('Record cannot be found, no IDs provided.'); } - $generation = $generation ?? $this->snapshotTableGeneration(); + $generation = $generation ?? $this->rowCache()->snapshotGeneration(); // Canonical lookup: the caller's key IS the table identity, so the // row entry can be addressed directly (after normalizing order/types). - if ($this->isTableIdentity($ids)) { - $identity = $this->getRowIdentity($ids); + if ($this->rowCache()->isTableIdentity($ids)) { + $identity = $this->rowCache()->rowIdentity($ids); return $this->serviceProvider->cacheableService->getWithCache( Operation::Read, - $this->getCanonicalIdentityContext($identity, $generation), + $this->rowCache()->identityContext($identity, $generation), fn () => $this->queryRowAndModel($ids)[1] ); } // Business-key lookup: resolve through an alias entry so the row is // still cached exactly once, under its canonical identity. - $aliasContext = $this->getAliasContext($ids, $generation); - $aliasedIdentity = $this->getCachedValue($aliasContext); + $aliasContext = $this->rowCache()->aliasContext($ids, $generation); + $aliasedIdentity = $this->rowCache()->resolveAliasedIdentity($ids, $generation); - if (is_array($aliasedIdentity) && $this->isTableIdentity($aliasedIdentity)) { + if ($aliasedIdentity !== null) { try { return $this->findFromCompound($aliasedIdentity, $generation); } catch (RecordNotFoundException $e) { @@ -716,7 +505,7 @@ protected function findFromCompound(array $ids, ?string $generation = null) [$row, $model] = $this->queryRowAndModel($ids); - $identity = $this->getRowIdentity($row); + $identity = $this->rowCache()->rowIdentity($row); if ($identity !== null) { $this->serviceProvider->cacheableService->set($aliasContext, $identity); @@ -766,7 +555,7 @@ protected function queryRowAndModel(array $ids): array /** @inheritDoc */ public function updateCompound($ids, array $attributes): void { - $generation = $this->snapshotTableGeneration(); + $generation = $this->rowCache()->snapshotGeneration(); // The pre-read warms the alias (business-key callers) or the row // entry (canonical callers) — which is what makes the canonical @@ -783,16 +572,16 @@ public function updateCompound($ids, array $attributes): void // deletes carry tables that opt out of generations; the bump closes // the cache-aside race for everyone else. if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->getCanonicalIdentityContext($identity, $generation)); + $this->serviceProvider->cacheableService->delete($this->rowCache()->identityContext($identity, $generation)); } - if (!$this->isTableIdentity($ids)) { + if (!$this->rowCache()->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's // business key or identity out from under it. - $this->serviceProvider->cacheableService->delete($this->getAliasContext($ids, $generation)); + $this->serviceProvider->cacheableService->delete($this->rowCache()->aliasContext($ids, $generation)); } - $this->bumpTableGeneration(); + $this->rowCache()->bumpGeneration(); $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes)); } @@ -813,24 +602,24 @@ public function updateCompound($ids, array $attributes): void */ protected function resolveTableIdentity(array $ids, ?string $generation = null): ?array { - if ($this->isTableIdentity($ids)) { - return $this->getRowIdentity($ids); + if ($this->rowCache()->isTableIdentity($ids)) { + return $this->rowCache()->rowIdentity($ids); } - $aliased = $this->getCachedValue($this->getAliasContext($ids, $generation)); + $aliased = $this->rowCache()->resolveAliasedIdentity($ids, $generation); - if (is_array($aliased) && $this->isTableIdentity($aliased)) { + if ($aliased !== null) { return $aliased; } - if ($this->shouldUseTableGenerations()) { + if ($this->rowCache()->usesGenerations()) { return null; } try { [$row] = $this->queryRowAndModel($ids); - return $this->getRowIdentity($row); + return $this->rowCache()->rowIdentity($row); } catch (RecordNotFoundException $e) { return null; } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 6a82330..ebc30a6 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -90,9 +90,27 @@ private function makeHandler( ); } - public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(): void + /** + * Both generation modes must exhibit identical canonical-keying behavior; + * generations only change WHICH key a context hashes to, never the + * invariants. (Production default is generations ON.) + * + * @return array + */ + public function generationModes(): array { - $handler = $this->makeHandler(['orgId', 'id']); + return [ + 'generations on (production default)' => [true], + 'generations off (opt-out)' => [false], + ]; + } + + /** + * @dataProvider generationModes + */ + public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useGenerations): void + { + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations); // findIds → identity rows; then SELECT * for the uncached row. $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); @@ -121,9 +139,12 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(): void $this->assertSame(3, $this->queryStrategy->queryCount, 'Cached row was re-queried.'); } - public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(): void + /** + * @dataProvider generationModes + */ + public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $useGenerations): void { - $handler = $this->makeHandler(['id'], 'test_api_keys'); + $handler = $this->makeHandler(['id'], 'test_api_keys', $useGenerations); // Prime the row cache the way production reads do (list hydration). $this->queryStrategy->queueQueryResult([['id' => '7']]); @@ -150,9 +171,12 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(): void $this->assertSame('revoked', $models[0]->get('status')); } - public function testBusinessKeyLookupIsServedByAliasWithoutRequery(): void + /** + * @dataProvider generationModes + */ + public function testBusinessKeyLookupIsServedByAliasWithoutRequery(bool $useGenerations): void { - $handler = $this->makeHandler(['id']); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); @@ -164,9 +188,12 @@ public function testBusinessKeyLookupIsServedByAliasWithoutRequery(): void $this->assertSame(1, $this->queryStrategy->queryCount, 'Alias-resolved lookup hit the database again.'); } - public function testStaleAliasSelfHeals(): void + /** + * @dataProvider generationModes + */ + public function testStaleAliasSelfHeals(bool $useGenerations): void { - $handler = $this->makeHandler(['id']); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); // Seed: keyHash abc → id 7. $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); @@ -304,7 +331,7 @@ public function findByCompound(array $ids) public function exposeRowContext(array $row): ?array { - return $this->getCanonicalRowContext($row); + return $this->rowCache()->rowContext($row); } } diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 37b6d93..8ce56a8 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -361,7 +361,7 @@ public function findByIdentity(array $ids) public function exposeRowContext(array $row): ?array { - return $this->getCanonicalRowContext($row); + return $this->rowCache()->rowContext($row); } /** From de5a9b21c3e0454b513c22a3b73a6ca8772dda3e Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 00:19:13 -0400 Subject: [PATCH 04/26] fix(cache): verify alias-resolved rows against the lookup key; harden deleteWhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 audit findings. The major: an identity-keyed update can rotate a business key out from under its alias, and on generation-disabled tables nothing ever noticed — the alias kept serving fresh-looking rows for a key they no longer carried (the revoked-API-key bug class, reintroduced for opt-out tables). findFromCompound now verifies alias-resolved rows still carry the caller's lookup values via the model adapter; a mismatch drops the alias and re-resolves from the database. Fields the adapter does not expose are skipped rather than punished. Restored the deleteWhere coverage that was lost in the round-1 test rewrite, and extended it: full-table-identity deletes, RecordDeleted broadcast payloads (including when no cache identity can be derived), and the no-match path leaving the cache untouched. deleteWhere's generation bump now sits in a finally block so rows deleted before a mid-loop SQL failure still invalidate set-level caches. Also from the audit: - RowCache interface (lib/Interfaces) so the factory and trait depend on a contract; DatastoreRowCache is the default implementation and now owns ALL context mutations (storeRow/storeAlias/deleteRow/ deleteAlias/deleteTableContext) — the trait never hands a service- built context to the raw CacheableService. - Generation-disabled tables get precise set-level invalidation on every write (estimatedCount previously lived until TTL for them), and the opt-out docblock now states the full cost. - phpnomad/event moved to require with a caret constraint — the provider imports its interfaces at runtime. - Reusable test doubles promoted to tests/Doubles; stale/stacked docblocks removed; create()'s bump-then-pre-warm ordering pinned by a read-without-query test. Co-Authored-By: Claude Fable 5 --- composer.json | 6 +- composer.lock | 82 ++--- lib/Factories/DatastoreRowCacheFactory.php | 3 +- lib/Interfaces/RowCache.php | 142 ++++++++ lib/Providers/DatabaseServiceProvider.php | 4 +- lib/Services/DatastoreRowCache.php | 88 ++++- lib/Traits/WithDatastoreHandlerMethods.php | 175 ++++++---- tests/Doubles/ArrayCacheStrategy.php | 45 +++ tests/Doubles/NullEventStrategy.php | 25 ++ tests/Doubles/RecordingEventStrategy.php | 29 ++ tests/Doubles/ScriptedQueryStrategy.php | 61 ++++ tests/Doubles/SerializingCachePolicy.php | 33 ++ tests/Unit/Traits/CanonicalRowCacheTest.php | 316 ++++++++++-------- .../WithDatastoreHandlerMethodsTest.php | 12 +- 14 files changed, 760 insertions(+), 261 deletions(-) create mode 100644 lib/Interfaces/RowCache.php create mode 100644 tests/Doubles/ArrayCacheStrategy.php create mode 100644 tests/Doubles/NullEventStrategy.php create mode 100644 tests/Doubles/RecordingEventStrategy.php create mode 100644 tests/Doubles/ScriptedQueryStrategy.php create mode 100644 tests/Doubles/SerializingCachePolicy.php diff --git a/composer.json b/composer.json index c442b2b..5f4b9a8 100644 --- a/composer.json +++ b/composer.json @@ -30,11 +30,11 @@ "phpnomad/chrono": "^1.0", "phpnomad/datastore": "^2.0", "phpnomad/singleton": "^1.0", - "phpnomad/logger": "^1.0" + "phpnomad/logger": "^1.0", + "phpnomad/event": "^1.0" }, "require-dev": { - "phpnomad/tests": "^0.1.0 || ^0.3.0", - "phpnomad/event": "*" + "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index ac72d0e..2e10cd9 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "904e4413baf99cb4cb7a1a355b611cb3", + "content-hash": "e3f52915fd10478ce9e56c205ed6d38e", "packages": [ { "name": "phpnomad/cache", @@ -187,6 +187,46 @@ }, "time": "2025-01-13T11:51:33+00:00" }, + { + "name": "phpnomad/event", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpnomad/event.git", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpnomad/event/zipball/b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538", + "shasum": "" + }, + "require-dev": { + "phpnomad/tests": "^0.1.0 || ^0.3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPNomad\\Events\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Standiford", + "email": "alex@standiford.us" + } + ], + "homepage": "https://github.com/phpnomad/core", + "support": { + "issues": "https://github.com/phpnomad/event/issues", + "source": "https://github.com/phpnomad/event/tree/1.0.1" + }, + "time": "2026-06-12T10:56:48+00:00" + }, { "name": "phpnomad/logger", "version": "1.0.0", @@ -1301,46 +1341,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpnomad/event", - "version": "1.0.1", - "source": { - "type": "git", - "url": "https://github.com/phpnomad/event.git", - "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpnomad/event/zipball/b418d8fc90af18b7cb37e118795f9dd92ffeb538", - "reference": "b418d8fc90af18b7cb37e118795f9dd92ffeb538", - "shasum": "" - }, - "require-dev": { - "phpnomad/tests": "^0.1.0 || ^0.3.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPNomad\\Events\\": "lib/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Alex Standiford", - "email": "alex@standiford.us" - } - ], - "homepage": "https://github.com/phpnomad/core", - "support": { - "issues": "https://github.com/phpnomad/event/issues", - "source": "https://github.com/phpnomad/event/tree/1.0.1" - }, - "time": "2026-06-12T10:56:48+00:00" - }, { "name": "phpnomad/tests", "version": "0.3.0", diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php index 747310d..3b32af5 100644 --- a/lib/Factories/DatastoreRowCacheFactory.php +++ b/lib/Factories/DatastoreRowCacheFactory.php @@ -3,6 +3,7 @@ namespace PHPNomad\Database\Factories; use PHPNomad\Cache\Services\CacheableService; +use PHPNomad\Database\Interfaces\RowCache; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Services\DatastoreRowCache; use PHPNomad\Logger\Interfaces\LoggerStrategy; @@ -28,7 +29,7 @@ public function __construct(CacheableService $cacheableService, LoggerStrategy $ /** * @param class-string $model */ - public function make(Table $table, string $model, bool $useGenerations = true): DatastoreRowCache + public function make(Table $table, string $model, bool $useGenerations = true): RowCache { return new DatastoreRowCache($this->cacheableService, $this->logger, $table, $model, $useGenerations); } diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php new file mode 100644 index 0000000..f346e89 --- /dev/null +++ b/lib/Interfaces/RowCache.php @@ -0,0 +1,142 @@ + $ids + */ + public function isTableIdentity(array $ids): bool; + + /** + * Extracts the canonical identity from row data, or null (logged) when + * the row is missing an identity field. + * + * @param array $row + * @return array|null Scalars stringified, table order. + */ + public function rowIdentity(array $row): ?array; + + /** + * Builds the ONE cache context a row is stored under. + * + * @param array $row + * @param string|null $generation Generation snapshot; taken fresh when omitted. + */ + public function rowContext(array $row, ?string $generation = null): ?array; + + /** + * Wraps an already-canonical identity in the row cache context. + * + * @param array $identity + * @param string|null $generation Generation snapshot; taken fresh when omitted. + */ + public function identityContext(array $identity, ?string $generation = null): array; + + /** + * Builds the cache context for an alias entry (business-key lookup → + * canonical identity pointer). + * + * @param array $ids + * @param string|null $generation Generation snapshot; taken fresh when omitted. + */ + public function aliasContext(array $ids, ?string $generation = null): array; + + /** + * The set-level context for whole-table values (estimatedCount and any + * future query caches). + * + * @param string|null $generation Generation snapshot; taken fresh when omitted. + */ + public function tableContext(?string $generation = null): array; + + /** + * Reads the identity an alias entry points at, validated against the + * table's identity shape. Null on miss, cache failure, or malformed value. + * + * @param array $ids + * @param string|null $generation + * @return array|null + */ + public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array; + + /** + * Stores a row's model under its canonical row context. Skips (logged) + * when the row cannot produce a full identity. + * + * @param array $row + * @param mixed $model + * @param string|null $generation Pre-query generation snapshot — see the + * implementation for why read paths must + * pass the snapshot they queried under. + */ + public function storeRow(array $row, $model, ?string $generation = null): void; + + /** + * Stores an alias entry pointing a business key at a canonical identity. + * + * @param array $ids + * @param array $identity + * @param string|null $generation + */ + public function storeAlias(array $ids, array $identity, ?string $generation = null): void; + + /** + * Deletes the row entry for a canonical identity. + * + * @param array $identity + * @param string|null $generation The generation readers wrote under. + */ + public function deleteRow(array $identity, ?string $generation = null): void; + + /** + * Deletes an alias entry. + * + * @param array $ids + * @param string|null $generation + */ + public function deleteAlias(array $ids, ?string $generation = null): void; + + /** + * Deletes the set-level context. Used by writes on generation-disabled + * tables, where no bump exists to orphan it. + */ + public function deleteTableContext(): void; + + /** + * Folds the current table generation into a cache context. + */ + public function withGeneration(array $context, ?string $generation = null): array; + + /** + * Takes the generation snapshot an operation should key its contexts + * under — once, before any database query. Null when generations are + * disabled. + */ + public function snapshotGeneration(): ?string; + + /** + * Replaces the table's generation token after a successful write. + * + * @return string|null The fresh token, or null when generations are disabled. + */ + public function bumpGeneration(): ?string; + + /** + * Whether contexts built by this service carry a generation token. + */ + public function usesGenerations(): bool; +} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index a85e60c..6336ddd 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -38,7 +38,9 @@ public function __construct( $this->cacheableService = $cacheableService; $this->eventStrategy = $eventStrategy; // Optional so existing six-argument construction keeps working; the - // default factory composes from the same injected services. + // default factory composes from the same injected services. Prefer + // binding the factory at the container level — this parameter is + // slated to become required in the next major. $this->rowCacheFactory = $rowCacheFactory ?? new DatastoreRowCacheFactory($cacheableService, $loggerStrategy); } } \ No newline at end of file diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 073984e..8bd6f01 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -4,6 +4,7 @@ use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Cache\Services\CacheableService; +use PHPNomad\Database\Interfaces\RowCache; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Logger\Interfaces\LoggerStrategy; @@ -13,13 +14,14 @@ * * The design invariant this class enforces: a row has exactly ONE cache * entry, keyed by its table identity derived from row data — never from the - * model's own identity, never from the shape of a caller's lookup. Because - * every context is built here, writers can always name the keys readers - * used, which is what makes precise invalidation possible at all. + * model's own identity, never from the shape of a caller's lookup. Every + * row, alias, and set-level context is built (and every cache mutation on + * them performed) here, so writers can always name the keys readers used — + * which is what makes precise invalidation possible at all. * * @see \PHPNomad\Database\Traits\WithDatastoreHandlerMethods the consuming datastore flows */ -class DatastoreRowCache +class DatastoreRowCache implements RowCache { protected CacheableService $cacheableService; protected LoggerStrategy $logger; @@ -147,6 +149,84 @@ public function aliasContext(array $ids, ?string $generation = null): array return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); } + /** + * The set-level context for whole-table values (estimatedCount and any + * future query caches). + * + * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + */ + public function tableContext(?string $generation = null): array + { + return $this->withGeneration(['type' => $this->model], $generation); + } + + /** + * Stores a row's model under its canonical row context. Skips silently + * (already logged by rowIdentity()) when the row cannot produce a full + * identity. + * + * Read paths MUST pass the generation snapshot they took before querying + * the database: taking a fresh token here would let a stale row land + * under a generation minted AFTER a concurrent write — reopening the + * exact race generations exist to close. + * + * @param array $row + * @param mixed $model + * @param string|null $generation Pre-query generation snapshot. + */ + public function storeRow(array $row, $model, ?string $generation = null): void + { + $context = $this->rowContext($row, $generation); + + if ($context !== null) { + $this->cacheableService->set($context, $model); + } + } + + /** + * Stores an alias entry pointing a business key at a canonical identity. + * + * @param array $ids + * @param array $identity + * @param string|null $generation + */ + public function storeAlias(array $ids, array $identity, ?string $generation = null): void + { + $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); + } + + /** + * Deletes the row entry for a canonical identity. + * + * @param array $identity + * @param string|null $generation The generation readers wrote under. + */ + public function deleteRow(array $identity, ?string $generation = null): void + { + $this->cacheableService->delete($this->identityContext($identity, $generation)); + } + + /** + * Deletes an alias entry. + * + * @param array $ids + * @param string|null $generation + */ + public function deleteAlias(array $ids, ?string $generation = null): void + { + $this->cacheableService->delete($this->aliasContext($ids, $generation)); + } + + /** + * Deletes the set-level context. Writes on generation-disabled tables + * call this because they have no bump to orphan it; generation-enabled + * tables never need it. + */ + public function deleteTableContext(): void + { + $this->cacheableService->delete($this->tableContext()); + } + /** * Reads the identity an alias entry points at, validated against the * table's identity shape. Null on miss, cache failure, or malformed value. diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 4dd80dd..85b480e 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -9,7 +9,7 @@ use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Providers\DatabaseServiceProvider; -use PHPNomad\Database\Services\DatastoreRowCache; +use PHPNomad\Database\Interfaces\RowCache; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; use PHPNomad\Datastore\Exceptions\DuplicateEntryException; @@ -31,7 +31,7 @@ trait WithDatastoreHandlerMethods */ protected string $model; protected ModelAdapter $modelAdapter; - protected ?DatastoreRowCache $rowCacheService = null; + protected ?RowCache $rowCacheService = null; /** * @inheritDoc @@ -39,7 +39,7 @@ trait WithDatastoreHandlerMethods public function getEstimatedCount(): int { return $this->serviceProvider->cacheableService - ->getWithCache(Operation::Read, $this->rowCache()->withGeneration(['type' => $this->model]), function () { + ->getWithCache(Operation::Read, $this->rowCache()->tableContext(), function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -165,11 +165,12 @@ public function create(array $attributes): DataModel // generation and stays readable; set-level caches (estimatedCount) // keyed under the old generation become unreachable. $generation = $this->rowCache()->bumpGeneration(); + $this->invalidateSetCachesWithoutGenerations(); // Pre-warm the cache so subsequent reads of this record don't have to // round-trip the DB at all. Same canonical key every read path uses, // so existing read paths transparently pick it up. - $this->cacheRow($row, $result, $generation); + $this->rowCache()->storeRow($row, $result, $generation); $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -218,30 +219,50 @@ public function deleteWhere(array $conditions): void $generation = $this->rowCache()->snapshotGeneration(); $deleted = false; - foreach ($identityRows as $identityRow) { - // Delete by the full table identity. The previous implementation - // deleted by the MODEL's identity, which can be a subset of the - // table's — on such tables that SQL delete could reach rows - // outside the matched set. - $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); + // Alias entries pointing at deleted rows are left to self-heal: the + // row read they resolve to misses and falls through to the database. + // The finally block guarantees the generation bump lands even when a + // later row's SQL delete throws — rows already deleted (and + // broadcast) must not leave set-level caches serving stale data. + try { + foreach ($identityRows as $identityRow) { + // Delete by the full table identity. The previous implementation + // deleted by the MODEL's identity, which can be a subset of the + // table's — on such tables that SQL delete could reach rows + // outside the matched set. + $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); - $identity = $this->rowCache()->rowIdentity($identityRow); + $identity = $this->rowCache()->rowIdentity($identityRow); - if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->rowCache()->identityContext($identity, $generation)); - } + if ($identity !== null) { + $this->rowCache()->deleteRow($identity, $generation); + } - // The broadcast carries the raw identity row (DB-typed values): - // the deletion HAPPENED, so listeners must hear about it even - // when a canonical cache identity could not be derived, and cache - // key normalization must not leak into the event contract. - $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identityRow)); + // The broadcast carries the raw identity row (DB-typed values): + // the deletion HAPPENED, so listeners must hear about it even + // when a canonical cache identity could not be derived, and cache + // key normalization must not leak into the event contract. + $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identityRow)); - $deleted = true; + $deleted = true; + } + } finally { + if ($deleted) { + $this->rowCache()->bumpGeneration(); + $this->invalidateSetCachesWithoutGenerations(); + } } + } - if ($deleted) { - $this->rowCache()->bumpGeneration(); + /** + * Set-level caches (estimatedCount) are normally orphaned by the + * generation bump. Tables opted out of generations have no bump, so + * writes delete the set-level context precisely instead. + */ + protected function invalidateSetCachesWithoutGenerations(): void + { + if (!$this->rowCache()->usesGenerations()) { + $this->rowCache()->deleteTableContext(); } } @@ -334,9 +355,9 @@ protected function buildConditions(array $groups) * cache context this trait uses (canonical row identities, alias * entries, generation tokens). * - * @see \PHPNomad\Database\Services\DatastoreRowCache + * @see \PHPNomad\Database\Interfaces\RowCache */ - protected function rowCache(): DatastoreRowCache + protected function rowCache(): RowCache { return $this->rowCacheService ??= $this->serviceProvider->rowCacheFactory->make( $this->table, @@ -348,36 +369,20 @@ protected function rowCache(): DatastoreRowCache /** * Whether this table's cache contexts are keyed under a per-table * generation token. On by default; override to opt a write-hot table out - * (accepting TTL-bounded staleness on the read-back race in exchange for - * a higher hit rate). + * in exchange for a higher hit rate. + * + * What opting out costs: the cache-aside read-back race is only + * TTL-bounded (a slow reader can write a just-invalidated row back), and + * set-level caches plus rotated aliases fall to precise handling + * (invalidateSetCachesWithoutGenerations() and the read-time lookup + * verification in findFromCompound()) instead of being orphaned + * wholesale by the bump. */ protected function shouldUseTableGenerations(): bool { return true; } - /** - * Caches a single row's model under its canonical row context. Skips - * silently (already logged by DatastoreRowCache::rowIdentity()) when the - * row cannot produce a full identity. - * - * Callers on read paths MUST pass the generation snapshot they took - * before querying the database: rebuilding the context here with a fresh - * token would let a stale row land under a generation minted AFTER a - * concurrent write — reopening the exact race generations exist to close. - * - * @param array $row - * @param DataModel $model - * @param string|null $generation Pre-query generation snapshot. - */ - protected function cacheRow(array $row, DataModel $model, ?string $generation = null): void - { - $context = $this->rowCache()->rowContext($row, $generation); - - if ($context !== null) { - $this->serviceProvider->cacheableService->set($context, $model); - } - } /** * @param array $conditions @@ -406,12 +411,6 @@ public function findIds(array $conditions, ?int $limit = null, ?int $offset = nu return $this->serviceProvider->queryStrategy->query($this->serviceProvider->queryBuilder); } - /** - * Gets the models from the specified list of IDs. - * - * @param array[] $ids - * @return array - */ /** * Gets the models for the given identity rows, read-through cached. * @@ -421,8 +420,8 @@ public function findIds(array $conditions, ?int $limit = null, ?int $offset = nu protected function getModels(array $ids): array { // One generation snapshot for the whole operation, taken BEFORE any - // database read — see cacheRow() for why this must not be re-fetched - // at write-back time. + // database read — see RowCache::storeRow() for why this must not be + // re-fetched at write-back time. $generation = $this->rowCache()->snapshotGeneration(); // Filter out the items that are currently in the cache. @@ -449,11 +448,11 @@ function (array $identityRow) use ($generation) { // from the ROW's identity values — never the model's getIdentity(), // which is not necessarily the table's identity. foreach ($data as $row) { - $this->cacheRow($row, $this->modelAdapter->toModel($row), $generation); + $this->rowCache()->storeRow($row, $this->modelAdapter->toModel($row), $generation); } } - // Now, use the cache to get all the posts in the proper order. + // Now, use the cache to return the rows in the requested order. return Arr::map($ids, fn(array $id) => $this->findFromCompound($id, $generation)); } @@ -462,7 +461,7 @@ function (array $identityRow) use ($generation) { * identity addresses the canonical row entry directly; any other key (a * business key) resolves through an alias entry first. * - * @param non-empty-array $ids + * @param array $ids Must be non-empty; an empty key throws RecordNotFoundException. * @param string|null $generation Pre-query generation snapshot; taken here when the caller has none. * @return DataModel * @throws DatastoreErrorException @@ -490,16 +489,27 @@ protected function findFromCompound(array $ids, ?string $generation = null) // Business-key lookup: resolve through an alias entry so the row is // still cached exactly once, under its canonical identity. - $aliasContext = $this->rowCache()->aliasContext($ids, $generation); $aliasedIdentity = $this->rowCache()->resolveAliasedIdentity($ids, $generation); if ($aliasedIdentity !== null) { try { - return $this->findFromCompound($aliasedIdentity, $generation); + $model = $this->findFromCompound($aliasedIdentity, $generation); + + // Verify the resolved row still matches the caller's lookup + // values. An identity-keyed update can rotate a business key + // out from under its alias, and on generation-disabled + // tables nothing else would ever notice — the alias would + // keep serving fresh-looking rows for a key they no longer + // carry. + if ($this->modelMatchesLookup($model, $ids)) { + return $model; + } + + $this->rowCache()->deleteAlias($ids, $generation); } catch (RecordNotFoundException $e) { // Stale alias — the row it points at moved or died. Drop it // and re-resolve from the database. - $this->serviceProvider->cacheableService->delete($aliasContext); + $this->rowCache()->deleteAlias($ids, $generation); } } @@ -508,13 +518,45 @@ protected function findFromCompound(array $ids, ?string $generation = null) $identity = $this->rowCache()->rowIdentity($row); if ($identity !== null) { - $this->serviceProvider->cacheableService->set($aliasContext, $identity); - $this->cacheRow($row, $model, $generation); + $this->rowCache()->storeAlias($ids, $identity, $generation); + $this->rowCache()->storeRow($row, $model, $generation); } return $model; } + /** + * True when the model's serialized data still carries the caller's + * lookup values. Fields the adapter does not expose are skipped — they + * cannot be verified, and models with narrower serialization should not + * lose alias caching over it. + * + * @param DataModel $model + * @param array $ids The caller's lookup key. + */ + protected function modelMatchesLookup(DataModel $model, array $ids): bool + { + $data = $this->modelAdapter->toArray($model); + + foreach ($ids as $field => $value) { + if (!array_key_exists($field, $data)) { + continue; + } + + $actual = $data[$field]; + + if (is_scalar($actual) && is_scalar($value)) { + if ((string) $actual !== (string) $value) { + return false; + } + } elseif ($actual !== $value) { + return false; + } + } + + return true; + } + /** * Queries a single row by the given compound key and hydrates it. * @@ -572,16 +614,17 @@ public function updateCompound($ids, array $attributes): void // deletes carry tables that opt out of generations; the bump closes // the cache-aside race for everyone else. if ($identity !== null) { - $this->serviceProvider->cacheableService->delete($this->rowCache()->identityContext($identity, $generation)); + $this->rowCache()->deleteRow($identity, $generation); } if (!$this->rowCache()->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's // business key or identity out from under it. - $this->serviceProvider->cacheableService->delete($this->rowCache()->aliasContext($ids, $generation)); + $this->rowCache()->deleteAlias($ids, $generation); } $this->rowCache()->bumpGeneration(); + $this->invalidateSetCachesWithoutGenerations(); $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes)); } diff --git a/tests/Doubles/ArrayCacheStrategy.php b/tests/Doubles/ArrayCacheStrategy.php new file mode 100644 index 0000000..97f2eaf --- /dev/null +++ b/tests/Doubles/ArrayCacheStrategy.php @@ -0,0 +1,45 @@ + */ + public array $store = []; + + public function get(string $key) + { + if (!array_key_exists($key, $this->store)) { + throw new CachedItemNotFoundException('No cached item found for key ' . $key); + } + + return $this->store[$key]; + } + + public function set(string $key, $value, ?int $ttl): void + { + $this->store[$key] = $value; + } + + public function delete(string $key): void + { + unset($this->store[$key]); + } + + public function exists(string $key): bool + { + return array_key_exists($key, $this->store); + } + + public function clear(): void + { + $this->store = []; + } +} diff --git a/tests/Doubles/NullEventStrategy.php b/tests/Doubles/NullEventStrategy.php new file mode 100644 index 0000000..45f104f --- /dev/null +++ b/tests/Doubles/NullEventStrategy.php @@ -0,0 +1,25 @@ +broadcasts[] = $event; + } + + public function attach(string $event, callable $action, ?int $priority = null): void + { + } + + public function detach(string $event, callable $action, ?int $priority = null): void + { + } +} diff --git a/tests/Doubles/ScriptedQueryStrategy.php b/tests/Doubles/ScriptedQueryStrategy.php new file mode 100644 index 0000000..7bacf94 --- /dev/null +++ b/tests/Doubles/ScriptedQueryStrategy.php @@ -0,0 +1,61 @@ +queryResults[] = $result; + } + + public function query(QueryBuilder $builder): array + { + $this->queryCount++; + + if (empty($this->queryResults)) { + throw new LogicException('ScriptedQueryStrategy ran out of queued query results — unexpected query #' . $this->queryCount); + } + + return array_shift($this->queryResults); + } + + public function insert(Table $table, array $data): array + { + return ['id' => 1]; + } + + public function delete(Table $table, array $ids): void + { + $this->deletes[] = $ids; + } + + public function update(Table $table, array $ids, array $data): void + { + $this->updates[] = [$ids, $data]; + } + + public function estimatedCount(Table $table): int + { + return $this->estimatedCountValue; + } +} diff --git a/tests/Doubles/SerializingCachePolicy.php b/tests/Doubles/SerializingCachePolicy.php new file mode 100644 index 0000000..084e39b --- /dev/null +++ b/tests/Doubles/SerializingCachePolicy.php @@ -0,0 +1,33 @@ +createMock(Table::class); $table->method('getName')->willReturn($tableName); @@ -77,7 +80,7 @@ private function makeHandler( new NoopQueryBuilder(), new NoopClauseBuilder(), $this->cacheableService, - new NullEventStrategy() + $events ?? new NullEventStrategy() ); return new CanonicalHandler( @@ -213,9 +216,12 @@ public function testStaleAliasSelfHeals(bool $useGenerations): void $this->assertSame('9', $model->get('id'), 'Stale alias did not self-heal.'); } - public function testHealedAliasServesNextLookupWithoutQuery(): void + /** + * @dataProvider generationModes + */ + public function testHealedAliasServesNextLookupWithoutQuery(bool $useGenerations): void { - $handler = $this->makeHandler(['id']); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); // Same healing sequence as testStaleAliasSelfHeals… $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); @@ -232,6 +238,32 @@ public function testHealedAliasServesNextLookupWithoutQuery(): void $this->assertSame($before, $this->queryStrategy->queryCount); } + public function testRotatedBusinessKeyAliasDoesNotServeTheOldKey(): void + { + // Generations OFF: without the bump, ONLY read-time verification + // stands between a rotated business key and its orphaned alias + // serving fresh-looking rows for a key they no longer carry. + $handler = $this->makeHandler(['id'], 'test_api_keys', false); + + // Seed alias: keyHash abc → id 7. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // Rotate the key via an identity-keyed update — the alias for 'abc' + // is not directly addressable from ['id' => '7']. + $handler->updateCompound(['id' => '7'], ['keyHash' => 'xyz']); + + // Lookup by the OLD key: alias → id 7 → row entry was invalidated → + // DB re-read returns the rotated row (keyHash xyz) → verification + // rejects it → alias dropped → re-resolve by keyHash finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'xyz', 'status' => 'active']]); + $this->queryStrategy->queueQueryResult([]); + + $this->expectException(RecordNotFoundException::class); + + $handler->findByCompound(['keyHash' => 'abc']); + } + public function testGenerationBumpMakesLateStaleWriteUnreachable(): void { $handler = $this->makeHandler(['id'], 'test_records', true); @@ -275,14 +307,64 @@ public function testEstimatedCountInvalidatesAfterWrite(): void $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write — generation did not invalidate it.'); } - public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(): void + public function testEstimatedCountInvalidatesAfterWriteWithoutGenerations(): void + { + // Opt-out tables have no generation bump; writes must delete the + // set-level context precisely instead. + $handler = $this->makeHandler(['id'], 'test_records', false); + + $this->queryStrategy->estimatedCountValue = 5; + $this->assertSame(5, $handler->getEstimatedCount()); + + $this->queryStrategy->estimatedCountValue = 6; + $this->assertSame(5, $handler->getEstimatedCount()); + + $handler->create(['name' => 'new row']); + + $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write on a generation-disabled table.'); + } + + public function testCreatePreWarmsRowReadableWithoutQuery(): void + { + // Bump-then-pre-warm ordering: the created row's entry must land + // under the NEW generation, or every post-create read would miss. + $handler = $this->makeHandler(['id'], 'test_records', true); + + $created = $handler->create(['name' => 'warm']); + + // No query result is queued: a DB round-trip here would throw. + $read = $handler->findByCompound(['id' => 1]); + + $this->assertSame('warm', $read->get('name')); + $this->assertSame(0, $this->queryStrategy->queryCount); + $this->assertSame($created, $read); + } + + /** + * Cache-entry expectations differ only by the generation-token entry the + * ON mode keeps in the store. + * + * @return array + */ + public function generationModesWithExpectedEntries(): array + { + return [ + 'generations on (production default)' => [true, 1], + 'generations off (opt-out)' => [false, 0], + ]; + } + + /** + * @dataProvider generationModesWithExpectedEntries + */ + public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(bool $useGenerations, int $expectedEntries): void { $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce()) ->method('warning') ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('missingField')); - $handler = $this->makeHandler(['orgId', 'id'], 'test_records', false, $logger); + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations, $logger); // The SELECT * row lacks orgId — a partial identity must never // become a cache key. @@ -293,7 +375,79 @@ public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(): void $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'incomplete']]]]); - $this->assertSame([], $this->cacheStrategy->store, 'A partial-identity row produced a cache entry.'); + // Only the generation token (when enabled) may exist — no row entry, + // no alias entry. + $this->assertCount($expectedEntries, $this->cacheStrategy->store, 'A partial-identity row produced a cache entry.'); + } + + /** + * @dataProvider generationModes + */ + public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGenerations): void + { + $events = new RecordingEventStrategy(); + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations, null, $events); + + // Prime the cache. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'name' => 'doomed']]); + $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'doomed']]]]); + + // deleteWhere resolves identity rows and deletes by the FULL table + // identity — not the model's subset identity. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42']]); + $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); + $this->assertFalse( + $this->cacheableService->exists($handler->exposeRowContext(['orgId' => '1', 'id' => '42'])), + 'deleteWhere left the canonical row entry behind.' + ); + + // The deletion is broadcast with the raw identity row — DB-typed + // values, no cache normalization in the event contract. + $deletions = array_filter($events->broadcasts, fn($event) => $event instanceof RecordDeleted); + $this->assertCount(1, $deletions); + $deletion = array_values($deletions)[0]; + $this->assertSame(AttrModel::class, $deletion->getType()); + $this->assertSame(['orgId' => '1', 'id' => '42'], $deletion->getIdentity()); + } + + public function testDeleteWhereBroadcastsWhenIdentityCannotBeDerived(): void + { + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('warning'); + + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', false, $logger, $events); + + // The identity row is missing orgId, so no cache entry can be named + // — but the SQL delete still runs and listeners MUST hear about it. + $this->queryStrategy->queueQueryResult([['id' => '42']]); + $handler->deleteWhere([['column' => 'name', 'operator' => '=', 'value' => 'orphan']]); + + $this->assertSame([['id' => '42']], $this->queryStrategy->deletes); + + $deletions = array_filter($events->broadcasts, fn($event) => $event instanceof RecordDeleted); + $this->assertCount(1, $deletions); + $this->assertSame(['id' => '42'], array_values($deletions)[0]->getIdentity()); + } + + public function testDeleteWhereWithNoMatchesLeavesCacheUntouched(): void + { + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Prime a generation token + row entry via a read. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + // No rows match: nothing may be deleted, bumped, or re-minted. + $this->queryStrategy->queueQueryResult([]); + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'missing']]); + + $this->assertSame($storeBefore, $this->cacheStrategy->store); + $this->assertSame([], $this->queryStrategy->deletes); } } @@ -351,6 +505,11 @@ public function get(string $field) return $this->row[$field] ?? null; } + public function toRow(): array + { + return $this->row; + } + public function getIdentity(): array { return ['id' => $this->row['id'] ?? null]; @@ -366,131 +525,6 @@ public function toModel(array $array): DataModel public function toArray(DataModel $model): array { - return []; - } -} - -class ScriptedQueryStrategy implements QueryStrategy -{ - /** @var array[] */ - private array $queryResults = []; - public int $queryCount = 0; - public int $estimatedCountValue = 0; - /** @var array[] */ - public array $updates = []; - /** @var array[] */ - public array $deletes = []; - - public function queueQueryResult(array $result): void - { - $this->queryResults[] = $result; - } - - public function query(QueryBuilder $builder): array - { - $this->queryCount++; - - if (empty($this->queryResults)) { - throw new \LogicException('ScriptedQueryStrategy ran out of queued query results — unexpected query #' . $this->queryCount); - } - - return array_shift($this->queryResults); - } - - public function insert(Table $table, array $data): array - { - return ['id' => 1]; - } - - public function delete(Table $table, array $ids): void - { - $this->deletes[] = $ids; - } - - public function update(Table $table, array $ids, array $data): void - { - $this->updates[] = [$ids, $data]; - } - - public function estimatedCount(Table $table): int - { - return $this->estimatedCountValue; - } -} - -class ArrayCacheStrategy implements CacheStrategy -{ - /** @var array */ - public array $store = []; - - public function get(string $key) - { - if (!array_key_exists($key, $this->store)) { - throw new CachedItemNotFoundException('No cached item found for key ' . $key); - } - - return $this->store[$key]; - } - - public function set(string $key, $value, ?int $ttl): void - { - $this->store[$key] = $value; - } - - public function delete(string $key): void - { - unset($this->store[$key]); - } - - public function exists(string $key): bool - { - return array_key_exists($key, $this->store); - } - - public function clear(): void - { - $this->store = []; - } -} - -/** - * Deliberately order-sensitive key function: proves the trait's contexts are - * canonical by construction rather than rescued by a normalizing policy. - */ -class SerializingCachePolicy implements CachePolicy -{ - public function shouldCache(string $operation, array $context = []): bool - { - return true; - } - - public function getCacheKey(array $context): string - { - return md5(serialize($context)); - } - - public function getTtl(array $context = []): ?int - { - return 60; - } - - public function shouldInvalidate(string $operation, array $context = []): bool - { - return true; - } -} - -class NullEventStrategy implements EventStrategy -{ - public function broadcast(Event $event): void - { - } - - public function attach(string $event, callable $action, ?int $priority = null): void - { - } - - public function detach(string $event, callable $action, ?int $priority = null): void - { + return $model instanceof AttrModel ? $model->toRow() : []; } } diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 8ce56a8..0867101 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -232,8 +232,8 @@ public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): void { - // Drives the actual stale-read scenario end-to-end: cacheItems writes - // with int identity (from the hydrated model), updateCompound is called + // Drives the actual stale-read scenario end-to-end: the read path + // caches rows with int identity values, updateCompound is called // with the string identity that came back from queryStrategy->query() // — both must hit the same cache key for the invalidation to land. $loggerStrategy = $this->createMock(LoggerStrategy::class); @@ -244,7 +244,10 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $cacheableService = $this->createMock(CacheableService::class); $cacheableService->method('getWithCache') ->willReturnCallback(fn(string $operation, array $context, callable $callback) => $callback()); - $cacheableService->expects($this->once()) + // Two deletes: the canonical row entry, then the set-level context + // (this handler opts out of generations, so estimatedCount has no + // bump to orphan it and is deleted precisely). + $cacheableService->expects($this->exactly(2)) ->method('delete') ->willReturnCallback(function (array $context) use (&$deletedKeys) { $deletedKeys[] = $context; @@ -285,8 +288,9 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // The deleted cache context must match what the read path wrote, // which used the int identity from the hydrated row. $expected = $handler->exposeRowContext(['id' => 42]); - $this->assertCount(1, $deletedKeys); + $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); + $this->assertSame(['type' => TestModel::class], $deletedKeys[1]); } public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing(): void From 3e284a7b8c467b30344abbc0c48314f42e21f329 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 00:55:41 -0400 Subject: [PATCH 05/26] fix(cache): guard post-write invalidation on every write path; finish contract extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 audit findings. The major (reproduced by the reviewer with a throw-on-delete cache): updateCompound had no guard around its post-update cache work, so a transient cache failure after the DB write committed skipped both the generation bump and the RecordUpdated broadcast — stale reads plus a silent event drop on exactly the failure path deleteWhere already defended. All three write paths now treat post-commit cache work as best-effort: failures are logged, the bump runs in a finally (via invalidateAfterWriteSafely(), which never throws and cannot mask an in-flight exception), and event broadcasts always fire for committed writes. Contract completion, from the converged reviewer findings: - RowCache::readRow() read-through — the last cache write (getWithCache miss-path SET) moves behind the service contract. - RowCache::matchesLookup() — alias verification moves off the trait into the collaborator (which now takes the ModelAdapter); when generations are off and NO lookup field is verifiable, the alias is treated as stale with a warning instead of passing vacuously. - RowCache::invalidateAfterWrite() — the bump-or-precise-delete pairing becomes one service call instead of hand-repeated trait knowledge. - RowCacheFactory interface; the provider's factory parameter is now REQUIRED and interface-typed (two reviewers converged on the package's hard-break precedent over the self-defaulting shim). - RecordUpdated and RecordDeleted both broadcast $this->model as the event type; get-then-set generation minting documents its safety argument; docblock dedup via @inheritDoc; README covers the new provider member and the generations opt-out trade. Tests: estimatedCount twins merged into the generation-mode provider; RecordingEventStrategy::ofType() replaces inline filters; the two mock-only create tests assert the returned model. 38 tests, 99 assertions, green. Co-Authored-By: Claude Fable 5 --- README.md | 3 +- lib/Factories/DatastoreRowCacheFactory.php | 17 +- lib/Interfaces/RowCache.php | 56 ++++++- lib/Interfaces/RowCacheFactory.php | 26 +++ lib/Providers/DatabaseServiceProvider.php | 12 +- lib/Services/DatastoreRowCache.php | 139 ++++++++++------ lib/Traits/WithDatastoreHandlerMethods.php | 157 +++++++++--------- tests/Doubles/RecordingEventStrategy.php | 11 ++ tests/Unit/Traits/CanonicalRowCacheTest.php | 50 +++--- .../WithDatastoreHandlerMethodsTest.php | 26 ++- 10 files changed, 308 insertions(+), 189 deletions(-) create mode 100644 lib/Interfaces/RowCacheFactory.php diff --git a/README.md b/README.md index e96c53d..5f5f9f4 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,8 @@ The `QueryBuilder` turns that array into parameterized SQL and runs it through w - Extend `IdentifiableDatabaseDatastoreHandler` as the base for any handler keyed by a single `id` column - Use the `WithDatastoreHandlerMethods` trait for CRUD, cache reads, cache invalidation, and event dispatch - Build reads and writes through `QueryBuilder` and `ClauseBuilder` with condition arrays instead of raw SQL -- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, and `LoggerStrategy` +- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, `LoggerStrategy`, and the `RowCacheFactory` that builds each table's row cache +- Rows are cached under canonical table-identity keys with per-table generation tokens; override `shouldUseTableGenerations()` on a handler to trade the generation token's race protection for a higher hit rate on write-hot tables - Use column factories like `PrimaryKeyFactory`, `DateCreatedFactory`, `DateModifiedFactory`, and `ForeignKeyFactory` for common column patterns - Model many-to-many relationships with `JunctionTable`, which handles compound primary keys and foreign key constraints diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php index 3b32af5..edf9e81 100644 --- a/lib/Factories/DatastoreRowCacheFactory.php +++ b/lib/Factories/DatastoreRowCacheFactory.php @@ -4,18 +4,19 @@ use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\RowCache; +use PHPNomad\Database\Interfaces\RowCacheFactory; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Services\DatastoreRowCache; +use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Logger\Interfaces\LoggerStrategy; /** - * Builds the per-table DatastoreRowCache collaborator. Lives on the - * DatabaseServiceProvider (like CacheableService) so containers can swap the - * row-cache behavior without touching the datastore trait. + * Default RowCacheFactory: builds the per-table DatastoreRowCache + * collaborator from the provider's cache service and logger. * * @see \PHPNomad\Database\Services\DatastoreRowCache */ -class DatastoreRowCacheFactory +class DatastoreRowCacheFactory implements RowCacheFactory { protected CacheableService $cacheableService; protected LoggerStrategy $logger; @@ -26,11 +27,9 @@ public function __construct(CacheableService $cacheableService, LoggerStrategy $ $this->logger = $logger; } - /** - * @param class-string $model - */ - public function make(Table $table, string $model, bool $useGenerations = true): RowCache + /** @inheritDoc */ + public function make(Table $table, string $model, ModelAdapter $adapter, bool $useGenerations = true): RowCache { - return new DatastoreRowCache($this->cacheableService, $this->logger, $table, $model, $useGenerations); + return new DatastoreRowCache($this->cacheableService, $this->logger, $table, $model, $adapter, $useGenerations); } } diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index f346e89..5c48056 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -2,6 +2,8 @@ namespace PHPNomad\Database\Interfaces; +use PHPNomad\Datastore\Interfaces\DataModel; + /** * The cache-context contract for one table's datastore: canonical row * identities, alias entries, set-level contexts, and per-table generation @@ -65,7 +67,7 @@ public function tableContext(?string $generation = null): array; /** * Reads the identity an alias entry points at, validated against the - * table's identity shape. Null on miss, cache failure, or malformed value. + * table's identity shape. Null on miss or malformed value. * * @param array $ids * @param string|null $generation @@ -73,15 +75,53 @@ public function tableContext(?string $generation = null): array; */ public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array; + /** + * Read-through for a canonical row: serves the cached model or runs the + * fallback and caches its result — every row cache read AND the miss-path + * write happen behind this contract. + * + * @param array $identity Canonical identity (from rowIdentity()). + * @param string|null $generation Pre-query generation snapshot. + * @param callable $fallback Loads the model on miss; its result is cached. + * @return mixed The cached or freshly loaded model. + */ + public function readRow(array $identity, ?string $generation, callable $fallback); + + /** + * True when the model still carries the caller's lookup values — + * the guard against an alias whose business key was rotated out from + * under it by an identity-keyed update. For generation-disabled tables + * an unverifiable lookup (no field exposed by the adapter) is treated + * as stale, because this check is their ONLY rotation defense. + * + * @param DataModel $model + * @param array $ids The caller's lookup key. + */ + public function matchesLookup(DataModel $model, array $ids): bool; + + /** + * The one post-write invalidation call: bumps the generation when + * generations are on, precisely deletes the set-level context when they + * are off. Every successful write must end with this. + * + * @return string|null The fresh generation token (for post-write cache + * writes), or null when generations are disabled. + */ + public function invalidateAfterWrite(): ?string; + /** * Stores a row's model under its canonical row context. Skips (logged) * when the row cannot produce a full identity. * + * Contract-level invariant for ANY generation-aware implementation: read + * paths MUST pass the generation snapshot they took before querying the + * database. A token fetched at store time can postdate a concurrent + * write's bump, landing a stale row under the new generation — the exact + * race generations exist to close. + * * @param array $row * @param mixed $model - * @param string|null $generation Pre-query generation snapshot — see the - * implementation for why read paths must - * pass the snapshot they queried under. + * @param string|null $generation Pre-query generation snapshot. */ public function storeRow(array $row, $model, ?string $generation = null): void; @@ -117,7 +157,13 @@ public function deleteAlias(array $ids, ?string $generation = null): void; public function deleteTableContext(): void; /** - * Folds the current table generation into a cache context. + * Folds the current table generation into a cache context. The token is + * replaced on every write, which orphans all previously written contexts + * for the table at once — the transaction-free invalidation primitive + * the whole design leans on. + * + * @param array $context The context to fold the token into. + * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. */ public function withGeneration(array $context, ?string $generation = null): array; diff --git a/lib/Interfaces/RowCacheFactory.php b/lib/Interfaces/RowCacheFactory.php new file mode 100644 index 0000000..32db61f --- /dev/null +++ b/lib/Interfaces/RowCacheFactory.php @@ -0,0 +1,26 @@ + $model The model class cache contexts are typed under. + * @param ModelAdapter $adapter Used to verify alias-resolved rows against lookup values. + * @param bool $useGenerations Whether contexts carry a per-table generation token + * — see WithDatastoreHandlerMethods::shouldUseTableGenerations() + * for what opting out costs. + */ + public function make(Table $table, string $model, ModelAdapter $adapter, bool $useGenerations = true): RowCache; +} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index 6336ddd..4a3ab47 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -3,10 +3,10 @@ namespace PHPNomad\Database\Providers; use PHPNomad\Cache\Services\CacheableService; -use PHPNomad\Database\Factories\DatastoreRowCacheFactory; use PHPNomad\Database\Interfaces\ClauseBuilder; use PHPNomad\Database\Interfaces\QueryBuilder; use PHPNomad\Database\Interfaces\QueryStrategy; +use PHPNomad\Database\Interfaces\RowCacheFactory; use PHPNomad\Events\Interfaces\EventStrategy; use PHPNomad\Logger\Interfaces\LoggerStrategy; @@ -19,7 +19,7 @@ class DatabaseServiceProvider public QueryBuilder $queryBuilder; public ClauseBuilder $clauseBuilder; public EventStrategy $eventStrategy; - public DatastoreRowCacheFactory $rowCacheFactory; + public RowCacheFactory $rowCacheFactory; public function __construct( LoggerStrategy $loggerStrategy, @@ -28,7 +28,7 @@ public function __construct( ClauseBuilder $clauseBuilder, CacheableService $cacheableService, EventStrategy $eventStrategy, - ?DatastoreRowCacheFactory $rowCacheFactory = null + RowCacheFactory $rowCacheFactory ) { $this->clauseBuilder = $clauseBuilder; @@ -37,10 +37,6 @@ public function __construct( $this->queryBuilder = $queryBuilder; $this->cacheableService = $cacheableService; $this->eventStrategy = $eventStrategy; - // Optional so existing six-argument construction keeps working; the - // default factory composes from the same injected services. Prefer - // binding the factory at the container level — this parameter is - // slated to become required in the next major. - $this->rowCacheFactory = $rowCacheFactory ?? new DatastoreRowCacheFactory($cacheableService, $loggerStrategy); + $this->rowCacheFactory = $rowCacheFactory; } } \ No newline at end of file diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 8bd6f01..505351f 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -2,10 +2,13 @@ namespace PHPNomad\Database\Services; +use PHPNomad\Cache\Enums\Operation; use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\RowCache; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Datastore\Interfaces\DataModel; +use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Logger\Interfaces\LoggerStrategy; /** @@ -28,34 +31,32 @@ class DatastoreRowCache implements RowCache protected Table $table; /** - * @var class-string + * @var class-string */ protected string $model; + protected ModelAdapter $modelAdapter; protected bool $useGenerations; /** - * @param class-string $model + * @param class-string $model */ public function __construct( CacheableService $cacheableService, LoggerStrategy $logger, Table $table, string $model, + ModelAdapter $modelAdapter, bool $useGenerations = true ) { $this->cacheableService = $cacheableService; $this->logger = $logger; $this->table = $table; $this->model = $model; + $this->modelAdapter = $modelAdapter; $this->useGenerations = $useGenerations; } - /** - * True when the given compound key is exactly the table's identity field - * set (order-insensitive). - * - * @param array $ids - */ + /** @inheritDoc */ public function isTableIdentity(array $ids): bool { $identityFields = $this->table->getFieldsForIdentity(); @@ -149,12 +150,7 @@ public function aliasContext(array $ids, ?string $generation = null): array return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); } - /** - * The set-level context for whole-table values (estimatedCount and any - * future query caches). - * - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - */ + /** @inheritDoc */ public function tableContext(?string $generation = null): array { return $this->withGeneration(['type' => $this->model], $generation); @@ -183,50 +179,96 @@ public function storeRow(array $row, $model, ?string $generation = null): void } } - /** - * Stores an alias entry pointing a business key at a canonical identity. - * - * @param array $ids - * @param array $identity - * @param string|null $generation - */ + /** @inheritDoc */ public function storeAlias(array $ids, array $identity, ?string $generation = null): void { $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); } - /** - * Deletes the row entry for a canonical identity. - * - * @param array $identity - * @param string|null $generation The generation readers wrote under. - */ + /** @inheritDoc */ public function deleteRow(array $identity, ?string $generation = null): void { $this->cacheableService->delete($this->identityContext($identity, $generation)); } - /** - * Deletes an alias entry. - * - * @param array $ids - * @param string|null $generation - */ + /** @inheritDoc */ public function deleteAlias(array $ids, ?string $generation = null): void { $this->cacheableService->delete($this->aliasContext($ids, $generation)); } - /** - * Deletes the set-level context. Writes on generation-disabled tables - * call this because they have no bump to orphan it; generation-enabled - * tables never need it. - */ + /** @inheritDoc */ public function deleteTableContext(): void { $this->cacheableService->delete($this->tableContext()); } + /** @inheritDoc */ + public function readRow(array $identity, ?string $generation, callable $fallback) + { + return $this->cacheableService->getWithCache( + Operation::Read, + $this->identityContext($identity, $generation), + $fallback + ); + } + + /** + * @inheritDoc + * + * Fields the adapter does not expose are skipped — they cannot be + * verified, and models with narrower serialization should not lose + * alias caching over it. But when generations are disabled and NO field + * was verifiable, the alias is treated as stale (with a warning): + * read-time verification is the only rotation defense those tables have, + * and a vacuous pass would silently remove it. + */ + public function matchesLookup(DataModel $model, array $ids): bool + { + $data = $this->modelAdapter->toArray($model); + $verified = 0; + + foreach ($ids as $field => $value) { + if (!array_key_exists($field, $data)) { + continue; + } + + $verified++; + $actual = $data[$field]; + + if (is_scalar($actual) && is_scalar($value)) { + if ((string) $actual !== (string) $value) { + return false; + } + } elseif ($actual !== $value) { + return false; + } + } + + if ($verified === 0 && !$this->useGenerations) { + $this->logger->warning( + 'Alias lookup could not be verified — the model adapter exposes none of the lookup fields; treating the alias as stale.', + ['table' => $this->table->getName(), 'lookupFields' => array_keys($ids)] + ); + + return false; + } + + return true; + } + + /** @inheritDoc */ + public function invalidateAfterWrite(): ?string + { + if (!$this->useGenerations) { + $this->deleteTableContext(); + + return null; + } + + return $this->bumpGeneration(); + } + /** * Reads the identity an alias entry points at, validated against the * table's identity shape. Null on miss, cache failure, or malformed value. @@ -269,16 +311,7 @@ public function withGeneration(array $context, ?string $generation = null): arra return $context; } - /** - * Takes the generation snapshot a read or invalidation operation should - * key its contexts under — ONCE, at the start of the operation, before - * any database query. Null when generations are disabled for this table. - * - * One snapshot per operation is both the race fence (a stale write-back - * keyed with a pre-write snapshot can never collide with post-bump - * reader keys) and the round-trip bound (one token fetch per operation - * instead of one per row). - */ + /** @inheritDoc */ public function snapshotGeneration(): ?string { return $this->useGenerations ? $this->currentGeneration() : null; @@ -305,9 +338,7 @@ public function bumpGeneration(): ?string return $token; } - /** - * Whether contexts built by this service carry a generation token. - */ + /** @inheritDoc */ public function usesGenerations(): bool { return $this->useGenerations; @@ -322,6 +353,12 @@ public function usesGenerations(): bool * this context would re-mint a token on every read, silently defeating * generation stability, and each re-mint would broadcast CacheMissed * noise. The token must live outside policy discretion. + * + * The get-then-set is last-write-wins rather than add-if-absent, and + * that is safe: every minter SETs its token before performing its DB + * read, and tokens are random — concurrent re-mints can only orphan + * each other's fresh entries (a cold-start hit-rate cost), never revive + * a stale one. */ protected function currentGeneration(): string { diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 85b480e..4f8988b 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -19,6 +19,7 @@ use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Utils\Helpers\Arr; use PHPNomad\Utils\Helpers\Obj; +use Throwable; trait WithDatastoreHandlerMethods { @@ -161,16 +162,24 @@ public function create(array $attributes): DataModel $row = Arr::merge($attributes, $ids); $result = $this->modelAdapter->toModel($row); - // Bump BEFORE pre-warming so the row entry lands under the new - // generation and stays readable; set-level caches (estimatedCount) - // keyed under the old generation become unreachable. - $generation = $this->rowCache()->bumpGeneration(); - $this->invalidateSetCachesWithoutGenerations(); - - // Pre-warm the cache so subsequent reads of this record don't have to - // round-trip the DB at all. Same canonical key every read path uses, - // so existing read paths transparently pick it up. - $this->rowCache()->storeRow($row, $result, $generation); + // The insert is committed: cache maintenance below is best-effort + // and must not fail the create or suppress RecordCreated. Bump + // BEFORE pre-warming so the row entry lands under the new + // generation and stays readable; set-level caches keyed under the + // old generation become unreachable. + try { + $generation = $this->rowCache()->invalidateAfterWrite(); + + // Pre-warm the cache so subsequent reads of this record don't + // have to round-trip the DB at all. Same canonical key every + // read path uses, so existing read paths transparently pick it up. + $this->rowCache()->storeRow($row, $result, $generation); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Post-create cache maintenance failed — the row was created but not pre-warmed.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -232,10 +241,19 @@ public function deleteWhere(array $conditions): void // outside the matched set. $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); - $identity = $this->rowCache()->rowIdentity($identityRow); + try { + $identity = $this->rowCache()->rowIdentity($identityRow); - if ($identity !== null) { - $this->rowCache()->deleteRow($identity, $generation); + if ($identity !== null) { + $this->rowCache()->deleteRow($identity, $generation); + } + } catch (Throwable $e) { + // Cache trouble must not abort the remaining SQL deletes; + // the finally-block invalidation is the safety net. + $this->serviceProvider->loggerStrategy->warning( + 'Cache invalidation failed for a deleted row.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); } // The broadcast carries the raw identity row (DB-typed values): @@ -248,21 +266,31 @@ public function deleteWhere(array $conditions): void } } finally { if ($deleted) { - $this->rowCache()->bumpGeneration(); - $this->invalidateSetCachesWithoutGenerations(); + $this->invalidateAfterWriteSafely(); } } } /** - * Set-level caches (estimatedCount) are normally orphaned by the - * generation bump. Tables opted out of generations have no bump, so - * writes delete the set-level context precisely instead. + * Post-write invalidation that never throws: a cache-layer failure after + * a committed database write must not turn the write into a + * caller-visible error, mask an in-flight exception, or suppress event + * broadcasts. Failures are logged and left to TTL recovery. + * + * @return string|null The fresh generation token, or null when + * generations are disabled or the cache failed. */ - protected function invalidateSetCachesWithoutGenerations(): void + protected function invalidateAfterWriteSafely(): ?string { - if (!$this->rowCache()->usesGenerations()) { - $this->rowCache()->deleteTableContext(); + try { + return $this->rowCache()->invalidateAfterWrite(); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + + return null; } } @@ -362,6 +390,7 @@ protected function rowCache(): RowCache return $this->rowCacheService ??= $this->serviceProvider->rowCacheFactory->make( $this->table, $this->model, + $this->modelAdapter, $this->shouldUseTableGenerations() ); } @@ -480,11 +509,7 @@ protected function findFromCompound(array $ids, ?string $generation = null) if ($this->rowCache()->isTableIdentity($ids)) { $identity = $this->rowCache()->rowIdentity($ids); - return $this->serviceProvider->cacheableService->getWithCache( - Operation::Read, - $this->rowCache()->identityContext($identity, $generation), - fn () => $this->queryRowAndModel($ids)[1] - ); + return $this->rowCache()->readRow($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); } // Business-key lookup: resolve through an alias entry so the row is @@ -501,7 +526,7 @@ protected function findFromCompound(array $ids, ?string $generation = null) // tables nothing else would ever notice — the alias would // keep serving fresh-looking rows for a key they no longer // carry. - if ($this->modelMatchesLookup($model, $ids)) { + if ($this->rowCache()->matchesLookup($model, $ids)) { return $model; } @@ -525,38 +550,6 @@ protected function findFromCompound(array $ids, ?string $generation = null) return $model; } - /** - * True when the model's serialized data still carries the caller's - * lookup values. Fields the adapter does not expose are skipped — they - * cannot be verified, and models with narrower serialization should not - * lose alias caching over it. - * - * @param DataModel $model - * @param array $ids The caller's lookup key. - */ - protected function modelMatchesLookup(DataModel $model, array $ids): bool - { - $data = $this->modelAdapter->toArray($model); - - foreach ($ids as $field => $value) { - if (!array_key_exists($field, $data)) { - continue; - } - - $actual = $data[$field]; - - if (is_scalar($actual) && is_scalar($value)) { - if ((string) $actual !== (string) $value) { - return false; - } - } elseif ($actual !== $value) { - return false; - } - } - - return true; - } - /** * Queries a single row by the given compound key and hydrates it. * @@ -585,7 +578,7 @@ protected function queryRowAndModel(array $ids): array if (!$item) { throw new RecordNotFoundException(sprintf( - 'Record not found in table "%s" using identity %s.', + 'Record not found in table "%s" using lookup key %s.', $this->table->getName(), $this->encodeExceptionContext($ids) )); @@ -601,32 +594,42 @@ public function updateCompound($ids, array $attributes): void // The pre-read warms the alias (business-key callers) or the row // entry (canonical callers) — which is what makes the canonical - // identity resolvable below without an extra query. - $record = $this->findFromCompound($ids, $generation); + // identity resolvable below without an extra query. It also throws + // RecordNotFoundException before any write when the record is gone. + $this->findFromCompound($ids, $generation); $this->maybeThrowForDuplicateUniqueFields($attributes, $ids); $identity = $this->resolveTableIdentity($ids, $generation); $this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes); - // Precise invalidation first, under the pre-write generation (the - // one readers wrote their entries with), then bump. The precise - // deletes carry tables that opt out of generations; the bump closes - // the cache-aside race for everyone else. - if ($identity !== null) { - $this->rowCache()->deleteRow($identity, $generation); - } + // The DB write is committed: everything below is best-effort cache + // maintenance, and a cache-layer failure must not turn the + // successful update into a caller-visible error or suppress the + // RecordUpdated broadcast. Precise deletes run under the pre-write + // generation (the one readers wrote their entries with), then the + // finally-block bump closes the cache-aside race — precise deletes + // carry tables that opt out of generations. + try { + if ($identity !== null) { + $this->rowCache()->deleteRow($identity, $generation); + } - if (!$this->rowCache()->isTableIdentity($ids)) { - // Drop the alias too: the update may have moved the row's - // business key or identity out from under it. - $this->rowCache()->deleteAlias($ids, $generation); + if (!$this->rowCache()->isTableIdentity($ids)) { + // Drop the alias too: the update may have moved the row's + // business key or identity out from under it. + $this->rowCache()->deleteAlias($ids, $generation); + } + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Post-update cache invalidation failed — the generation bump is the remaining safety net.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } finally { + $this->invalidateAfterWriteSafely(); } - $this->rowCache()->bumpGeneration(); - $this->invalidateSetCachesWithoutGenerations(); - - $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes)); + $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($this->model, $ids, $attributes)); } /** diff --git a/tests/Doubles/RecordingEventStrategy.php b/tests/Doubles/RecordingEventStrategy.php index a63d6ec..f7206ac 100644 --- a/tests/Doubles/RecordingEventStrategy.php +++ b/tests/Doubles/RecordingEventStrategy.php @@ -19,6 +19,17 @@ public function broadcast(Event $event): void $this->broadcasts[] = $event; } + /** + * All recorded broadcasts of one event class, in broadcast order. + * + * @param class-string $eventClass + * @return Event[] + */ + public function ofType(string $eventClass): array + { + return array_values(array_filter($this->broadcasts, fn (Event $event) => $event instanceof $eventClass)); + } + public function attach(string $event, callable $action, ?int $priority = null): void { } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 8db3bf8..5761cdd 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -4,6 +4,7 @@ use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Database\Factories\DatastoreRowCacheFactory; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; @@ -74,13 +75,16 @@ private function makeHandler( $tableSchemaService = $this->createMock(TableSchemaService::class); $tableSchemaService->method('getUniqueColumns')->willReturn([]); + $logger = $logger ?? $this->createMock(LoggerStrategy::class); + $serviceProvider = new DatabaseServiceProvider( - $logger ?? $this->createMock(LoggerStrategy::class), + $logger, $this->queryStrategy, new NoopQueryBuilder(), new NoopClauseBuilder(), $this->cacheableService, - $events ?? new NullEventStrategy() + $events ?? new NullEventStrategy(), + new DatastoreRowCacheFactory($this->cacheableService, $logger) ); return new CanonicalHandler( @@ -290,9 +294,15 @@ public function testGenerationBumpMakesLateStaleWriteUnreachable(): void $this->assertSame('revoked', $fresh->get('status'), 'Late stale write-back was served after the generation bump.'); } - public function testEstimatedCountInvalidatesAfterWrite(): void + /** + * Generations orphan the cached count via the bump; opt-out tables + * delete the set-level context precisely. Same observable behavior. + * + * @dataProvider generationModes + */ + public function testEstimatedCountInvalidatesAfterWrite(bool $useGenerations): void { - $handler = $this->makeHandler(['id'], 'test_records', true); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); $this->queryStrategy->estimatedCountValue = 5; $this->assertSame(5, $handler->getEstimatedCount()); @@ -301,27 +311,10 @@ public function testEstimatedCountInvalidatesAfterWrite(): void $this->queryStrategy->estimatedCountValue = 6; $this->assertSame(5, $handler->getEstimatedCount()); - // Any write bumps the table generation, orphaning the cached count. - $handler->create(['name' => 'new row']); - - $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write — generation did not invalidate it.'); - } - - public function testEstimatedCountInvalidatesAfterWriteWithoutGenerations(): void - { - // Opt-out tables have no generation bump; writes must delete the - // set-level context precisely instead. - $handler = $this->makeHandler(['id'], 'test_records', false); - - $this->queryStrategy->estimatedCountValue = 5; - $this->assertSame(5, $handler->getEstimatedCount()); - - $this->queryStrategy->estimatedCountValue = 6; - $this->assertSame(5, $handler->getEstimatedCount()); - + // Any write invalidates the cached count. $handler->create(['name' => 'new row']); - $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write on a generation-disabled table.'); + $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write.'); } public function testCreatePreWarmsRowReadableWithoutQuery(): void @@ -406,11 +399,10 @@ public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGen // The deletion is broadcast with the raw identity row — DB-typed // values, no cache normalization in the event contract. - $deletions = array_filter($events->broadcasts, fn($event) => $event instanceof RecordDeleted); + $deletions = $events->ofType(RecordDeleted::class); $this->assertCount(1, $deletions); - $deletion = array_values($deletions)[0]; - $this->assertSame(AttrModel::class, $deletion->getType()); - $this->assertSame(['orgId' => '1', 'id' => '42'], $deletion->getIdentity()); + $this->assertSame(AttrModel::class, $deletions[0]->getType()); + $this->assertSame(['orgId' => '1', 'id' => '42'], $deletions[0]->getIdentity()); } public function testDeleteWhereBroadcastsWhenIdentityCannotBeDerived(): void @@ -428,9 +420,9 @@ public function testDeleteWhereBroadcastsWhenIdentityCannotBeDerived(): void $this->assertSame([['id' => '42']], $this->queryStrategy->deletes); - $deletions = array_filter($events->broadcasts, fn($event) => $event instanceof RecordDeleted); + $deletions = $events->ofType(RecordDeleted::class); $this->assertCount(1, $deletions); - $this->assertSame(['id' => '42'], array_values($deletions)[0]->getIdentity()); + $this->assertSame(['id' => '42'], $deletions[0]->getIdentity()); } public function testDeleteWhereWithNoMatchesLeavesCacheUntouched(): void diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 0867101..fdcf54f 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -6,6 +6,7 @@ use PHPNomad\Database\Factories\Column; use PHPNomad\Database\Interfaces\QueryStrategy; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Database\Factories\DatastoreRowCacheFactory; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; @@ -67,7 +68,8 @@ public function testCreateHydratesFromAttributesWithoutPostInsertRead(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( @@ -130,7 +132,8 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( @@ -141,7 +144,7 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void $modelAdapter ); - $handler->create(['name' => 'Example']); + $this->assertSame($createdModel, $handler->create(['name' => 'Example'])); } public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void @@ -168,8 +171,9 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void $tableSchemaService = $this->createMock(TableSchemaService::class); $tableSchemaService->method('getUniqueColumns')->willReturn([]); + $createdModel = new TestModel(7); $modelAdapter = $this->createMock(ModelAdapter::class); - $modelAdapter->method('toModel')->willReturn(new TestModel(7)); + $modelAdapter->method('toModel')->willReturn($createdModel); $serviceProvider = new DatabaseServiceProvider( $loggerStrategy, @@ -177,7 +181,8 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( @@ -188,7 +193,7 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void $modelAdapter ); - $handler->create(['createdAt' => 'caller-provided']); + $this->assertSame($createdModel, $handler->create(['createdAt' => 'caller-provided'])); } public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void @@ -212,7 +217,8 @@ public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( @@ -271,7 +277,8 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( @@ -321,7 +328,8 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy + $eventStrategy, + new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) ); $handler = new DummyDatastoreHandler( From 74225f349ebf987f80d0ef912b4f4ed664c0454f Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 01:35:01 -0400 Subject: [PATCH 06/26] refactor(cache): complete the RowCache boundary; prove the failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 audit findings — five of six reviewers independently converged on the same residual seam, and the testing lens correctly flagged that round 5's failure-path guarantees shipped without proof. The boundary, finished: hasRow() and readTableValue() join the RowCache contract, getEstimatedCount() and getModels() route through them, and the trait now holds ZERO direct cache access — a swapped RowCache implementation intercepts every read and mutation. The interface is simultaneously trimmed to what consumers call: identityContext, aliasContext, tableContext, deleteTableContext, withGeneration, and bumpGeneration are implementation plumbing again (protected on DatastoreRowCache). Correctness tightening from the code-smells lens: - matchesLookup() on generation-disabled tables now treats ANY unverifiable lookup field as stale (it is exactly the field a rotation may have changed), not just all-unverifiable — and the vacuous-pass case is covered by tests with a nothing-exposing adapter. - updateCompound()'s SQL now targets the RESOLVED table identity, not the caller's business key: the contract is one record (limit(1) pre-read, one RecordUpdated), so a non-unique business key can no longer fan the write out past what invalidation saw. - Generation minting persists its token best-effort: a cache that throws on writes degrades caching to disabled (fresh token per operation, reads fall to the DB) instead of breaking datastore reads. - create() logs bump failures and pre-warm failures distinctly. Proof for the round-5 claims: a FlakyCacheStrategy double drives create/updateCompound/deleteWhere through a mid-operation cache-write outage and asserts committed writes return, events still broadcast, and failures land in the log. New DatastoreRowCacheTest unit-covers the pure helpers (identity derivation and ordering, identity-shape checks, malformed alias values, lookup verification in both generation modes). Phantom docblock reference fixed; healing-sequence test setup deduped. 56 tests, 128 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Interfaces/RowCache.php | 80 ++----- lib/Services/DatastoreRowCache.php | 100 +++++--- lib/Traits/WithDatastoreHandlerMethods.php | 40 ++-- tests/Doubles/FlakyCacheStrategy.php | 34 +++ tests/Unit/Services/DatastoreRowCacheTest.php | 225 ++++++++++++++++++ tests/Unit/Traits/CanonicalRowCacheTest.php | 155 ++++++++++-- 6 files changed, 507 insertions(+), 127 deletions(-) create mode 100644 tests/Doubles/FlakyCacheStrategy.php create mode 100644 tests/Unit/Services/DatastoreRowCacheTest.php diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index 5c48056..c365d76 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -7,9 +7,9 @@ /** * The cache-context contract for one table's datastore: canonical row * identities, alias entries, set-level contexts, and per-table generation - * tokens. Every cache key a datastore reads or invalidates is built (and - * every mutation performed) behind this contract, so writers can always name - * the keys readers used. + * tokens. Every cache read and every mutation the datastore performs + * happens behind this contract — the consuming trait holds no cache access + * of its own — so writers can always name the keys readers used. * * @see \PHPNomad\Database\Services\DatastoreRowCache the default implementation */ @@ -40,31 +40,6 @@ public function rowIdentity(array $row): ?array; */ public function rowContext(array $row, ?string $generation = null): ?array; - /** - * Wraps an already-canonical identity in the row cache context. - * - * @param array $identity - * @param string|null $generation Generation snapshot; taken fresh when omitted. - */ - public function identityContext(array $identity, ?string $generation = null): array; - - /** - * Builds the cache context for an alias entry (business-key lookup → - * canonical identity pointer). - * - * @param array $ids - * @param string|null $generation Generation snapshot; taken fresh when omitted. - */ - public function aliasContext(array $ids, ?string $generation = null): array; - - /** - * The set-level context for whole-table values (estimatedCount and any - * future query caches). - * - * @param string|null $generation Generation snapshot; taken fresh when omitted. - */ - public function tableContext(?string $generation = null): array; - /** * Reads the identity an alias entry points at, validated against the * table's identity shape. Null on miss or malformed value. @@ -87,12 +62,33 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): */ public function readRow(array $identity, ?string $generation, callable $fallback); + /** + * Whether a row entry exists for the given identity row. False when the + * row cannot produce a full identity (it can never have been cached). + * + * @param array $identityRow + * @param string|null $generation Pre-query generation snapshot. + */ + public function hasRow(array $identityRow, ?string $generation = null): bool; + + /** + * Read-through for the table's set-level value (estimatedCount and any + * future whole-table caches): serves the cached value or runs the + * fallback and caches its result. + * + * @param callable $fallback Computes the value on miss; its result is cached. + * @return mixed + */ + public function readTableValue(callable $fallback); + /** * True when the model still carries the caller's lookup values — * the guard against an alias whose business key was rotated out from * under it by an identity-keyed update. For generation-disabled tables - * an unverifiable lookup (no field exposed by the adapter) is treated - * as stale, because this check is their ONLY rotation defense. + * ANY lookup field the adapter cannot expose makes the lookup + * unverifiable and the alias is treated as stale, because this check is + * their ONLY rotation defense; generation-enabled tables skip + * unverifiable fields (the bump covers rotation). * * @param DataModel $model * @param array $ids The caller's lookup key. @@ -150,23 +146,6 @@ public function deleteRow(array $identity, ?string $generation = null): void; */ public function deleteAlias(array $ids, ?string $generation = null): void; - /** - * Deletes the set-level context. Used by writes on generation-disabled - * tables, where no bump exists to orphan it. - */ - public function deleteTableContext(): void; - - /** - * Folds the current table generation into a cache context. The token is - * replaced on every write, which orphans all previously written contexts - * for the table at once — the transaction-free invalidation primitive - * the whole design leans on. - * - * @param array $context The context to fold the token into. - * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. - */ - public function withGeneration(array $context, ?string $generation = null): array; - /** * Takes the generation snapshot an operation should key its contexts * under — once, before any database query. Null when generations are @@ -174,13 +153,6 @@ public function withGeneration(array $context, ?string $generation = null): arra */ public function snapshotGeneration(): ?string; - /** - * Replaces the table's generation token after a successful write. - * - * @return string|null The fresh token, or null when generations are disabled. - */ - public function bumpGeneration(): ?string; - /** * Whether contexts built by this service carry a generation token. */ diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 505351f..64f30cf 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -126,7 +126,7 @@ public function rowContext(array $row, ?string $generation = null): ?array * @param array $identity * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. */ - public function identityContext(array $identity, ?string $generation = null): array + protected function identityContext(array $identity, ?string $generation = null): array { return $this->withGeneration(['type' => $this->model, 'identities' => $identity], $generation); } @@ -141,7 +141,7 @@ public function identityContext(array $identity, ?string $generation = null): ar * @param array $ids The caller's lookup key. * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. */ - public function aliasContext(array $ids, ?string $generation = null): array + protected function aliasContext(array $ids, ?string $generation = null): array { $normalized = $this->stringifyScalars($ids); @@ -150,16 +150,20 @@ public function aliasContext(array $ids, ?string $generation = null): array return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); } - /** @inheritDoc */ - public function tableContext(?string $generation = null): array + /** + * The set-level context for whole-table values. + * + * @param string|null $generation Generation snapshot; taken fresh when omitted. + */ + protected function tableContext(?string $generation = null): array { return $this->withGeneration(['type' => $this->model], $generation); } /** - * Stores a row's model under its canonical row context. Skips silently - * (already logged by rowIdentity()) when the row cannot produce a full - * identity. + * Stores a row's model under its canonical row context. Skips without + * throwing (rowIdentity() logs the reason) when the row cannot produce a + * full identity. * * Read paths MUST pass the generation snapshot they took before querying * the database: taking a fresh token here would let a stale row land @@ -197,8 +201,11 @@ public function deleteAlias(array $ids, ?string $generation = null): void $this->cacheableService->delete($this->aliasContext($ids, $generation)); } - /** @inheritDoc */ - public function deleteTableContext(): void + /** + * Deletes the set-level context. invalidateAfterWrite() calls this for + * generation-disabled tables, where no bump exists to orphan it. + */ + protected function deleteTableContext(): void { $this->cacheableService->delete($this->tableContext()); } @@ -213,27 +220,49 @@ public function readRow(array $identity, ?string $generation, callable $fallback ); } + /** @inheritDoc */ + public function hasRow(array $identityRow, ?string $generation = null): bool + { + $context = $this->rowContext($identityRow, $generation); + + return $context !== null && $this->cacheableService->exists($context); + } + + /** @inheritDoc */ + public function readTableValue(callable $fallback) + { + return $this->cacheableService->getWithCache(Operation::Read, $this->tableContext(), $fallback); + } + /** * @inheritDoc * - * Fields the adapter does not expose are skipped — they cannot be - * verified, and models with narrower serialization should not lose - * alias caching over it. But when generations are disabled and NO field - * was verifiable, the alias is treated as stale (with a warning): - * read-time verification is the only rotation defense those tables have, - * and a vacuous pass would silently remove it. + * On generation-enabled tables, fields the adapter does not expose are + * skipped — the bump already covers rotation, and models with narrower + * serialization should not lose alias caching over it. On + * generation-disabled tables ANY unverifiable field fails verification + * (with a warning): this check is the only rotation defense those tables + * have, and a field that cannot be checked is exactly the field a + * rotation may have changed. */ public function matchesLookup(DataModel $model, array $ids): bool { $data = $this->modelAdapter->toArray($model); - $verified = 0; foreach ($ids as $field => $value) { if (!array_key_exists($field, $data)) { - continue; + if ($this->useGenerations) { + continue; + } + + $this->logger->warning( + 'Alias lookup field could not be verified — the model adapter does not expose it; treating the alias as stale.', + ['table' => $this->table->getName(), 'field' => $field] + ); + + return false; } - $verified++; $actual = $data[$field]; if (is_scalar($actual) && is_scalar($value)) { @@ -245,15 +274,6 @@ public function matchesLookup(DataModel $model, array $ids): bool } } - if ($verified === 0 && !$this->useGenerations) { - $this->logger->warning( - 'Alias lookup could not be verified — the model adapter exposes none of the lookup fields; treating the alias as stale.', - ['table' => $this->table->getName(), 'lookupFields' => array_keys($ids)] - ); - - return false; - } - return true; } @@ -269,14 +289,7 @@ public function invalidateAfterWrite(): ?string return $this->bumpGeneration(); } - /** - * Reads the identity an alias entry points at, validated against the - * table's identity shape. Null on miss, cache failure, or malformed value. - * - * @param array $ids The caller's lookup key. - * @param string|null $generation Generation snapshot for the alias context. - * @return array|null - */ + /** @inheritDoc */ public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array { try { @@ -300,7 +313,7 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): * * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin */ - public function withGeneration(array $context, ?string $generation = null): array + protected function withGeneration(array $context, ?string $generation = null): array { if (!$this->useGenerations) { return $context; @@ -325,7 +338,7 @@ public function snapshotGeneration(): ?string * writes (create()'s pre-warm) can key under it; null * when generations are disabled. */ - public function bumpGeneration(): ?string + protected function bumpGeneration(): ?string { if (!$this->useGenerations) { return null; @@ -370,7 +383,18 @@ protected function currentGeneration(): string if (!is_string($token) || $token === '') { $token = $this->mintGeneration(); - $this->cacheableService->set($this->generationContext(), $token); + + try { + $this->cacheableService->set($this->generationContext(), $token); + } catch (\Throwable $e) { + // Cache down: every operation mints its own token, so keys + // never match and reads fall through to the database — + // caching degrades to disabled instead of breaking reads. + $this->logger->warning( + 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } } return $token; diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 4f8988b..eba63eb 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -2,7 +2,6 @@ namespace PHPNomad\Database\Traits; -use PHPNomad\Cache\Enums\Operation; use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Datastore\Events\RecordUpdated; @@ -39,10 +38,9 @@ trait WithDatastoreHandlerMethods */ public function getEstimatedCount(): int { - return $this->serviceProvider->cacheableService - ->getWithCache(Operation::Read, $this->rowCache()->tableContext(), function () { - return $this->serviceProvider->queryStrategy->estimatedCount($this->table); - }); + return $this->rowCache()->readTableValue(function () { + return $this->serviceProvider->queryStrategy->estimatedCount($this->table); + }); } /** @inheritDoc */ @@ -166,17 +164,19 @@ public function create(array $attributes): DataModel // and must not fail the create or suppress RecordCreated. Bump // BEFORE pre-warming so the row entry lands under the new // generation and stays readable; set-level caches keyed under the - // old generation become unreachable. - try { - $generation = $this->rowCache()->invalidateAfterWrite(); + // old generation become unreachable. Bump and pre-warm failures are + // logged separately — the first means stale set-level caches until + // TTL, the second only a missed warm-up. + $generation = $this->invalidateAfterWriteSafely(); + try { // Pre-warm the cache so subsequent reads of this record don't // have to round-trip the DB at all. Same canonical key every // read path uses, so existing read paths transparently pick it up. $this->rowCache()->storeRow($row, $result, $generation); } catch (Throwable $e) { - $this->serviceProvider->loggerStrategy->error( - 'Post-create cache maintenance failed — the row was created but not pre-warmed.', + $this->serviceProvider->loggerStrategy->warning( + 'Post-create pre-warm failed — the row was created but the first read will hit the database.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); } @@ -403,9 +403,9 @@ protected function rowCache(): RowCache * What opting out costs: the cache-aside read-back race is only * TTL-bounded (a slow reader can write a just-invalidated row back), and * set-level caches plus rotated aliases fall to precise handling - * (invalidateSetCachesWithoutGenerations() and the read-time lookup - * verification in findFromCompound()) instead of being orphaned - * wholesale by the bump. + * (RowCache::invalidateAfterWrite()'s set-level delete and the read-time + * RowCache::matchesLookup() verification in findFromCompound()) instead + * of being orphaned wholesale by the bump. */ protected function shouldUseTableGenerations(): bool { @@ -456,11 +456,7 @@ protected function getModels(array $ids): array // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, - function (array $identityRow) use ($generation) { - $context = $this->rowCache()->rowContext($identityRow, $generation); - - return $context === null || !$this->serviceProvider->cacheableService->exists($context); - } + fn (array $identityRow) => !$this->rowCache()->hasRow($identityRow, $generation) ); if (!empty($idsToQuery)) { @@ -601,7 +597,13 @@ public function updateCompound($ids, array $attributes): void $identity = $this->resolveTableIdentity($ids, $generation); - $this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes); + // The SQL update targets the RESOLVED table identity when the + // pre-read could produce one: updateCompound's contract is "update + // THE record this key identifies" (the pre-read is limit(1) and one + // RecordUpdated fires), so a non-unique business key must not fan + // the write out to rows the invalidation below never saw. The + // caller's key is the fallback only when resolution failed. + $this->serviceProvider->queryStrategy->update($this->table, $identity ?? $ids, $attributes); // The DB write is committed: everything below is best-effort cache // maintenance, and a cache-layer failure must not turn the diff --git a/tests/Doubles/FlakyCacheStrategy.php b/tests/Doubles/FlakyCacheStrategy.php new file mode 100644 index 0000000..8c65982 --- /dev/null +++ b/tests/Doubles/FlakyCacheStrategy.php @@ -0,0 +1,34 @@ +failWrites) { + throw new RuntimeException('Simulated cache write failure.'); + } + + parent::set($key, $value, $ttl); + } + + public function delete(string $key): void + { + if ($this->failWrites) { + throw new RuntimeException('Simulated cache write failure.'); + } + + parent::delete($key); + } +} diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php new file mode 100644 index 0000000..3d15fdf --- /dev/null +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -0,0 +1,225 @@ +cacheStrategy = new ArrayCacheStrategy(); + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $this->cacheStrategy, + new SerializingCachePolicy() + ); + } + + private function makeRowCache( + array $identityFields, + bool $useGenerations = false, + ?LoggerStrategy $logger = null, + ?ModelAdapter $adapter = null + ): ExposedRowCache { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn($identityFields); + + return new ExposedRowCache( + $this->cacheableService, + $logger ?? $this->createMock(LoggerStrategy::class), + $table, + RowModel::class, + $adapter ?? new RowModelAdapter(), + $useGenerations + ); + } + + public function testRowIdentityReordersToTableOrderAndStringifies(): void + { + $rowCache = $this->makeRowCache(['orgId', 'id']); + + $this->assertSame( + ['orgId' => '1', 'id' => '42'], + $rowCache->rowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) + ); + } + + public function testRowIdentityMissingFieldReturnsNullAndWarns(): void + { + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('missingField')); + + $rowCache = $this->makeRowCache(['orgId', 'id'], false, $logger); + + $this->assertNull($rowCache->rowIdentity(['id' => 42])); + } + + public function testRowIdentityWithNoIdentityFieldsReturnsNull(): void + { + $rowCache = $this->makeRowCache([]); + + $this->assertNull($rowCache->rowIdentity(['id' => 42])); + } + + /** + * @return array + */ + public function identityShapes(): array + { + return [ + 'exact identity' => [['orgId' => '1', 'id' => '42'], true], + 'reordered identity' => [['id' => '42', 'orgId' => '1'], true], + 'subset' => [['id' => '42'], false], + 'superset' => [['orgId' => '1', 'id' => '42', 'name' => 'x'], false], + 'different fields' => [['keyHash' => 'abc', 'status' => 'active'], false], + 'empty' => [[], false], + ]; + } + + /** + * @dataProvider identityShapes + */ + public function testIsTableIdentityMatchesFieldSetsNotOrder(array $ids, bool $expected): void + { + $rowCache = $this->makeRowCache(['orgId', 'id']); + + $this->assertSame($expected, $rowCache->isTableIdentity($ids)); + } + + public function testResolveAliasedIdentityRejectsMalformedValues(): void + { + $rowCache = $this->makeRowCache(['id']); + + // Something that is not a valid table identity sits under the alias + // key (a bug, a poisoned cache, an old format) — it must never be + // dereferenced as an identity. + $this->cacheableService->set($rowCache->exposeAliasContext(['keyHash' => 'abc']), 'not-an-identity'); + + $this->assertNull($rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); + } + + public function testResolveAliasedIdentityReturnsStoredIdentity(): void + { + $rowCache = $this->makeRowCache(['id']); + + $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7']); + + $this->assertSame(['id' => '7'], $rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); + } + + public function testMatchesLookupComparesTypeInsensitively(): void + { + $rowCache = $this->makeRowCache(['id']); + + $model = new RowModel(['id' => 7, 'keyHash' => 'abc']); + + $this->assertTrue($rowCache->matchesLookup($model, ['keyHash' => 'abc'])); + $this->assertFalse($rowCache->matchesLookup($model, ['keyHash' => 'xyz'])); + } + + public function testMatchesLookupTreatsUnverifiableFieldAsStaleWithoutGenerations(): void + { + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('could not be verified'), $this->arrayHasKey('field')); + + $rowCache = $this->makeRowCache(['id'], false, $logger, new HidingModelAdapter()); + + $this->assertFalse( + $rowCache->matchesLookup(new RowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']), + 'An unverifiable lookup passed on a generation-disabled table — read-time verification is its only rotation defense.' + ); + } + + public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void + { + $rowCache = $this->makeRowCache(['id'], true, null, new HidingModelAdapter()); + + $this->assertTrue( + $rowCache->matchesLookup(new RowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']) + ); + } +} + +/** + * Exposes the protected alias context builder so tests can poison the exact + * cache slot an alias would occupy. + */ +class ExposedRowCache extends DatastoreRowCache +{ + public function exposeAliasContext(array $ids): array + { + return $this->aliasContext($ids); + } +} + +class RowModel implements DataModel +{ + public function __construct(private array $row = []) + { + } + + public function toRow(): array + { + return $this->row; + } + + public function getIdentity(): array + { + return ['id' => $this->row['id'] ?? null]; + } +} + +class RowModelAdapter implements ModelAdapter +{ + public function toModel(array $array): DataModel + { + return new RowModel($array); + } + + public function toArray(DataModel $model): array + { + return $model instanceof RowModel ? $model->toRow() : []; + } +} + +/** + * Adapter that exposes nothing — the narrow-serialization case lookup + * verification has to survive. + */ +class HidingModelAdapter implements ModelAdapter +{ + public function toModel(array $array): DataModel + { + return new RowModel($array); + } + + public function toArray(DataModel $model): array + { + return []; + } +} diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 5761cdd..07b868a 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -8,6 +8,7 @@ use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; +use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; use PHPNomad\Database\Tests\Doubles\NoopQueryBuilder; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; @@ -16,7 +17,9 @@ use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; use PHPNomad\Database\Tests\TestCase; use PHPNomad\Database\Traits\WithDatastoreHandlerMethods; +use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; +use PHPNomad\Datastore\Events\RecordUpdated; use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; @@ -65,7 +68,8 @@ private function makeHandler( string $tableName = 'test_records', bool $useGenerations = false, ?LoggerStrategy $logger = null, - ?EventStrategy $events = null + ?EventStrategy $events = null, + ?ModelAdapter $adapter = null ): CanonicalHandler { $table = $this->createMock(Table::class); $table->method('getName')->willReturn($tableName); @@ -92,7 +96,7 @@ private function makeHandler( $table, $tableSchemaService, AttrModel::class, - new ArrayModelAdapter(), + $adapter ?? new ArrayModelAdapter(), $useGenerations ); } @@ -164,7 +168,11 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $use $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); - $this->assertSame([['keyHash' => 'abc'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); + // The SQL update targets the RESOLVED identity, not the business + // key - updateCompound's contract is a single record, and a + // non-unique business key must not fan the write out past what the + // invalidation saw. + $this->assertSame([['id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); $this->assertFalse( $this->cacheableService->exists($handler->exposeRowContext(['id' => '7'])), 'Business-key update left the canonical row entry to serve stale reads.' @@ -196,12 +204,11 @@ public function testBusinessKeyLookupIsServedByAliasWithoutRequery(bool $useGene } /** - * @dataProvider generationModes + * Seeds keyHash abc → id 7, kills row 7 out from under the alias, and + * scripts the re-resolution to id 9 — the shared healing sequence. */ - public function testStaleAliasSelfHeals(bool $useGenerations): void + private function healRotatedAlias(CanonicalHandler $handler): DataModel { - $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); - // Seed: keyHash abc → id 7. $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->findByCompound(['keyHash' => 'abc']); @@ -215,7 +222,17 @@ public function testStaleAliasSelfHeals(bool $useGenerations): void // …so the alias is dropped and the business key re-resolves to id 9. $this->queryStrategy->queueQueryResult([['id' => '9', 'keyHash' => 'abc', 'status' => 'active']]); - $model = $handler->findByCompound(['keyHash' => 'abc']); + return $handler->findByCompound(['keyHash' => 'abc']); + } + + /** + * @dataProvider generationModes + */ + public function testStaleAliasSelfHeals(bool $useGenerations): void + { + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + + $model = $this->healRotatedAlias($handler); $this->assertSame('9', $model->get('id'), 'Stale alias did not self-heal.'); } @@ -227,15 +244,9 @@ public function testHealedAliasServesNextLookupWithoutQuery(bool $useGenerations { $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); - // Same healing sequence as testStaleAliasSelfHeals… - $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); - $handler->findByCompound(['keyHash' => 'abc']); - $this->cacheableService->delete($handler->exposeRowContext(['id' => '7'])); - $this->queryStrategy->queueQueryResult([]); - $this->queryStrategy->queueQueryResult([['id' => '9', 'keyHash' => 'abc', 'status' => 'active']]); - $handler->findByCompound(['keyHash' => 'abc']); + $this->healRotatedAlias($handler); - // …then the healed alias must serve the follow-up lookup query-free. + // The healed alias must serve the follow-up lookup query-free. $before = $this->queryStrategy->queryCount; $handler->findByCompound(['keyHash' => 'abc']); @@ -441,6 +452,101 @@ public function testDeleteWhereWithNoMatchesLeavesCacheUntouched(): void $this->assertSame($storeBefore, $this->cacheStrategy->store); $this->assertSame([], $this->queryStrategy->deletes); } + private function useFlakyCache(): FlakyCacheStrategy + { + $flaky = new FlakyCacheStrategy(); + $this->cacheStrategy = $flaky; + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $flaky, + new SerializingCachePolicy() + ); + + return $flaky; + } + + public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('warning'); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + + $flaky->failWrites = true; + + $created = $handler->create(['name' => 'survivor']); + + $this->assertSame('survivor', $created->get('name')); + $this->assertCount(1, $events->ofType(RecordCreated::class), 'A cache outage suppressed RecordCreated for a committed insert.'); + } + + public function testUpdateCompoundSurvivesCacheWriteFailureAndStillBroadcasts(): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + + // Prime the row while the cache is healthy so the pre-read hits. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + + $flaky->failWrites = true; + + $handler->updateCompound(['id' => '7'], ['status' => 'revoked']); + + $this->assertCount(1, $this->queryStrategy->updates, 'The committed update was rolled back by a cache failure.'); + $this->assertCount(1, $events->ofType(RecordUpdated::class), 'A cache outage suppressed RecordUpdated for a committed update.'); + } + + public function testDeleteWhereSurvivesCacheWriteFailureAndStillBroadcasts(): void + { + $flaky = $this->useFlakyCache(); + $events = new RecordingEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('warning'); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + + // Prime a generation token while the cache is healthy. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'doomed']]); + $handler->findByCompound(['id' => '7']); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertSame([['id' => '7']], $this->queryStrategy->deletes, 'A cache failure aborted the SQL delete.'); + $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A cache outage suppressed RecordDeleted for a committed delete.'); + } + + public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerations(): void + { + // The adapter exposes none of the lookup fields, so alias-resolved + // rows can't be verified. On a generation-disabled table the alias + // must not be trusted: every lookup re-resolves from the database. + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('warning'); + + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new HidingAdapter()); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + // Second lookup: alias hit, row hit, unverifiable, treated as + // stale, re-resolved from the database. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'An unverifiable alias was trusted on a generation-disabled table.'); + } + } class CanonicalHandler @@ -520,3 +626,20 @@ public function toArray(DataModel $model): array return $model instanceof AttrModel ? $model->toRow() : []; } } + +/** + * Adapter that exposes nothing — the narrow-serialization case alias + * verification must treat as unverifiable. + */ +class HidingAdapter implements ModelAdapter +{ + public function toModel(array $array): DataModel + { + return new AttrModel($array); + } + + public function toArray(DataModel $model): array + { + return []; + } +} From 23f4750623bad7552f5fdfdfc884b5dfd8cbd833 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 02:10:09 -0400 Subject: [PATCH 07/26] fix(cache): close the identity-resolution fallback fan-out; survive read-side outages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 audit findings (five passes, one concerns). The major: resolveTableIdentity() gave up on generation-enabled tables when the alias was missing, so updateCompound()'s SQL fell back to the caller's business key — reopening the multi-row fan-out for non-unique keys on any alias eviction. Resolution now always falls through to the database, and the trait's last usesGenerations() branch disappears with it (the method left the RowCache contract entirely). Read-side resilience, matching what the PR already claimed: alias reads, row probes, and generation-token reads treat ANY cache-layer failure as a miss (DB fallback / fresh mint) instead of breaking the operation. deleteWhere buffers RecordDeleted broadcasts and emits them after the SQL work with per-event isolation — a throwing listener can no longer abort a bulk delete mid-set, and rows deleted before a mid-loop SQL failure still announce themselves. findIds() now resets the shared query builder — stale builder state feeding a DELETE's row selection was a pre-existing smell this PR made load-bearing. Contract hygiene: rowContext() demoted to protected (tests probe context slots via a shared ExposedRowCache double instead of widening the production surface); interface docblocks state the pre-query snapshot requirement on all alias methods, rowIdentity's unlogged null case, and the single-slot nature of the table context; the RecordUpdated payload choice (caller's key, not resolved identity) is now documented at the call site; matchesLookup's type-insensitivity is pinned by cross-type data-provider cases it previously never exercised. The attribute-typed pre-warm follow-up is filed as a repo issue so it can't get lost. 60 tests, 131 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Interfaces/RowCache.php | 30 ++++------- lib/Services/DatastoreRowCache.php | 44 +++++++++++----- lib/Traits/WithDatastoreHandlerMethods.php | 51 +++++++++++++------ tests/Doubles/ExposedRowCache.php | 24 +++++++++ tests/Unit/Services/DatastoreRowCacheTest.php | 38 +++++++------- tests/Unit/Traits/CanonicalRowCacheTest.php | 36 +++++++++---- .../WithDatastoreHandlerMethodsTest.php | 15 +++--- 7 files changed, 153 insertions(+), 85 deletions(-) create mode 100644 tests/Doubles/ExposedRowCache.php diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index c365d76..2ed6f2a 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -9,7 +9,10 @@ * identities, alias entries, set-level contexts, and per-table generation * tokens. Every cache read and every mutation the datastore performs * happens behind this contract — the consuming trait holds no cache access - * of its own — so writers can always name the keys readers used. + * of its own — so writers can always name the keys readers used. The + * WHEN of alias healing (verify on hit, drop on mismatch or dead target) + * is datastore orchestration and lives with the consuming trait; this + * contract supplies the operations it composes. * * @see \PHPNomad\Database\Services\DatastoreRowCache the default implementation */ @@ -24,28 +27,21 @@ interface RowCache public function isTableIdentity(array $ids): bool; /** - * Extracts the canonical identity from row data, or null (logged) when - * the row is missing an identity field. + * Extracts the canonical identity from row data. Null (logged) when the + * row is missing an identity field, or (not logged) when the table + * declares no identity fields at all. * * @param array $row * @return array|null Scalars stringified, table order. */ public function rowIdentity(array $row): ?array; - /** - * Builds the ONE cache context a row is stored under. - * - * @param array $row - * @param string|null $generation Generation snapshot; taken fresh when omitted. - */ - public function rowContext(array $row, ?string $generation = null): ?array; - /** * Reads the identity an alias entry points at, validated against the * table's identity shape. Null on miss or malformed value. * * @param array $ids - * @param string|null $generation + * @param string|null $generation Pre-query generation snapshot. * @return array|null */ public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array; @@ -126,7 +122,7 @@ public function storeRow(array $row, $model, ?string $generation = null): void; * * @param array $ids * @param array $identity - * @param string|null $generation + * @param string|null $generation Pre-query generation snapshot. */ public function storeAlias(array $ids, array $identity, ?string $generation = null): void; @@ -142,7 +138,7 @@ public function deleteRow(array $identity, ?string $generation = null): void; * Deletes an alias entry. * * @param array $ids - * @param string|null $generation + * @param string|null $generation Pre-query generation snapshot. */ public function deleteAlias(array $ids, ?string $generation = null): void; @@ -153,8 +149,4 @@ public function deleteAlias(array $ids, ?string $generation = null): void; */ public function snapshotGeneration(): ?string; - /** - * Whether contexts built by this service carry a generation token. - */ - public function usesGenerations(): bool; -} + } diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 64f30cf..8cc5ff6 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -3,13 +3,13 @@ namespace PHPNomad\Database\Services; use PHPNomad\Cache\Enums\Operation; -use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\RowCache; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Logger\Interfaces\LoggerStrategy; +use Throwable; /** * Owns the cache-context vocabulary for one table's datastore: canonical row @@ -112,7 +112,7 @@ public function rowIdentity(array $row): ?array * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. * @return array|null Null when the row cannot produce a full identity. */ - public function rowContext(array $row, ?string $generation = null): ?array + protected function rowContext(array $row, ?string $generation = null): ?array { $identity = $this->rowIdentity($row); @@ -151,7 +151,7 @@ protected function aliasContext(array $ids, ?string $generation = null): array } /** - * The set-level context for whole-table values. + * The set-level context — one undiscriminated slot per table. * * @param string|null $generation Generation snapshot; taken fresh when omitted. */ @@ -225,10 +225,26 @@ public function hasRow(array $identityRow, ?string $generation = null): bool { $context = $this->rowContext($identityRow, $generation); - return $context !== null && $this->cacheableService->exists($context); + if ($context === null) { + return false; + } + + try { + return $this->cacheableService->exists($context); + } catch (Throwable $e) { + // A probe failure reads as uncached — the caller falls through + // to the database. + return false; + } } - /** @inheritDoc */ + /** + * @inheritDoc + * + * The table context is a single undiscriminated slot: it holds exactly + * one set-level value per table (estimatedCount today). A second value + * would need a discriminator added to the context. + */ public function readTableValue(callable $fallback) { return $this->cacheableService->getWithCache(Operation::Read, $this->tableContext(), $fallback); @@ -294,7 +310,10 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): { try { $aliased = $this->cacheableService->get($this->aliasContext($ids, $generation)); - } catch (CachedItemNotFoundException $e) { + } catch (Throwable $e) { + // Any cache-layer failure reads as a miss: every alias caller + // has a database fallback, so a throwing backend degrades to + // uncached instead of breaking the lookup. return null; } @@ -351,12 +370,6 @@ protected function bumpGeneration(): ?string return $token; } - /** @inheritDoc */ - public function usesGenerations(): bool - { - return $this->useGenerations; - } - /** * Reads the current generation token for this table, minting one when * absent (first read, or after eviction — both simply start a new @@ -377,7 +390,10 @@ protected function currentGeneration(): string { try { $token = $this->cacheableService->get($this->generationContext()); - } catch (CachedItemNotFoundException $e) { + } catch (Throwable $e) { + // Read failure is treated exactly like a missing token: mint a + // fresh one so the operation proceeds with caching effectively + // disabled rather than breaking on a dead cache. $token = null; } @@ -386,7 +402,7 @@ protected function currentGeneration(): string try { $this->cacheableService->set($this->generationContext(), $token); - } catch (\Throwable $e) { + } catch (Throwable $e) { // Cache down: every operation mints its own token, so keys // never match and reads fall through to the database — // caching degrades to disabled instead of breaking reads. diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index eba63eb..0d510bf 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -211,7 +211,9 @@ protected function applyPhpDefaults(array $attributes): array } /** - * Delete all items that fit the specified condition. + * Deletes every row matching the conditions, by full table identity. + * Each deleted row broadcasts a RecordDeleted carrying its raw identity + * row; no matching rows is a silent no-op. * * @param array $conditions * @return void @@ -227,6 +229,7 @@ public function deleteWhere(array $conditions): void $generation = $this->rowCache()->snapshotGeneration(); $deleted = false; + $broadcastQueue = []; // Alias entries pointing at deleted rows are left to self-heal: the // row read they resolve to misses and falls through to the database. @@ -256,18 +259,32 @@ public function deleteWhere(array $conditions): void ); } - // The broadcast carries the raw identity row (DB-typed values): - // the deletion HAPPENED, so listeners must hear about it even - // when a canonical cache identity could not be derived, and cache - // key normalization must not leak into the event contract. - $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $identityRow)); - + $broadcastQueue[] = $identityRow; $deleted = true; } } finally { if ($deleted) { $this->invalidateAfterWriteSafely(); } + + // Broadcasts are buffered and emitted after the SQL work so a + // throwing listener cannot abort a bulk delete mid-set, and + // emitted per-row inside the finally so rows deleted before a + // mid-loop SQL failure still announce themselves. Each carries + // the raw identity row (DB-typed values): the deletion HAPPENED, + // listeners must hear about it even when no cache identity could + // be derived, and cache-key normalization must not leak into the + // event contract. + foreach ($broadcastQueue as $deletedIdentityRow) { + try { + $this->serviceProvider->eventStrategy->broadcast(new RecordDeleted($this->model, $deletedIdentityRow)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'A RecordDeleted listener failed; remaining deletion events still fire.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } + } } } @@ -423,6 +440,7 @@ protected function shouldUseTableGenerations(): bool public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array { $this->serviceProvider->queryBuilder + ->reset() ->from($this->table) ->select(...$this->table->getFieldsForIdentity()); @@ -631,6 +649,9 @@ public function updateCompound($ids, array $attributes): void $this->invalidateAfterWriteSafely(); } + // The event intentionally carries the caller's key — the lookup + // contract they wrote against — not the cache-normalized identity + // the SQL targeted. $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($this->model, $ids, $attributes)); } @@ -639,10 +660,9 @@ public function updateCompound($ids, array $attributes): void * identity: directly when the key IS the table identity, via the alias * entry (warmed by the pre-read) otherwise. * - * For generation-disabled tables the alias is the ONLY invalidation - * route — there is no bump to fall back on — so an alias miss (evicted - * between the pre-read and here) resolves from the database instead of - * silently skipping precise invalidation. + * An alias miss (evicted between the pre-read and here, or a cache + * outage) resolves from the database: the caller targets its SQL write + * at this identity, so resolution must not silently degrade. * * @param array $ids * @param string|null $generation Generation snapshot for the alias lookup. @@ -660,10 +680,11 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): return $aliased; } - if ($this->rowCache()->usesGenerations()) { - return null; - } - + // The alias should have been warmed by the pre-read; reaching here + // means it was evicted or the cache is down. Resolve from the + // database regardless of generation mode — the SQL update targets + // this identity, so skipping resolution would reopen the + // multi-row fan-out for non-unique business keys. try { [$row] = $this->queryRowAndModel($ids); diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php new file mode 100644 index 0000000..ff718d7 --- /dev/null +++ b/tests/Doubles/ExposedRowCache.php @@ -0,0 +1,24 @@ +rowContext($row); + } + + public function exposeAliasContext(array $ids): array + { + return $this->aliasContext($ids); + } +} diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php index 3d15fdf..48b46b5 100644 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -4,8 +4,8 @@ use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\Table; -use PHPNomad\Database\Services\DatastoreRowCache; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; +use PHPNomad\Database\Tests\Doubles\ExposedRowCache; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; use PHPNomad\Database\Tests\TestCase; @@ -130,14 +130,28 @@ public function testResolveAliasedIdentityReturnsStoredIdentity(): void $this->assertSame(['id' => '7'], $rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); } - public function testMatchesLookupComparesTypeInsensitively(): void + /** + * @return array + */ + public function lookupComparisons(): array { - $rowCache = $this->makeRowCache(['id']); + return [ + 'same-type match' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'abc'], true], + 'same-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'xyz'], false], + 'int model vs string lookup' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '7'], true], + 'string model vs int lookup' => [['id' => '7', 'keyHash' => 'abc'], ['id' => 7], true], + 'cross-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '8'], false], + ]; + } - $model = new RowModel(['id' => 7, 'keyHash' => 'abc']); + /** + * @dataProvider lookupComparisons + */ + public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRow, array $lookup, bool $expected): void + { + $rowCache = $this->makeRowCache(['id']); - $this->assertTrue($rowCache->matchesLookup($model, ['keyHash' => 'abc'])); - $this->assertFalse($rowCache->matchesLookup($model, ['keyHash' => 'xyz'])); + $this->assertSame($expected, $rowCache->matchesLookup(new RowModel($modelRow), $lookup)); } public function testMatchesLookupTreatsUnverifiableFieldAsStaleWithoutGenerations(): void @@ -165,18 +179,6 @@ public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void } } -/** - * Exposes the protected alias context builder so tests can poison the exact - * cache slot an alias would occupy. - */ -class ExposedRowCache extends DatastoreRowCache -{ - public function exposeAliasContext(array $ids): array - { - return $this->aliasContext($ids); - } -} - class RowModel implements DataModel { public function __construct(private array $row = []) diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 07b868a..fffae9e 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -8,6 +8,7 @@ use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; +use PHPNomad\Database\Tests\Doubles\ExposedRowCache; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; use PHPNomad\Database\Tests\Doubles\NoopQueryBuilder; @@ -50,6 +51,13 @@ class CanonicalRowCacheTest extends TestCase private CacheableService $cacheableService; private ScriptedQueryStrategy $queryStrategy; + /** + * Context prober mirroring the last-built handler's row-cache config; + * computes the same cache slots (shared cache = shared generation token) + * without widening the production RowCache contract. + */ + private ExposedRowCache $prober; + protected function setUp(): void { parent::setUp(); @@ -91,12 +99,23 @@ private function makeHandler( new DatastoreRowCacheFactory($this->cacheableService, $logger) ); + $adapter = $adapter ?? new ArrayModelAdapter(); + + $this->prober = new ExposedRowCache( + $this->cacheableService, + $logger, + $table, + AttrModel::class, + $adapter, + $useGenerations + ); + return new CanonicalHandler( $serviceProvider, $table, $tableSchemaService, AttrModel::class, - $adapter ?? new ArrayModelAdapter(), + $adapter, $useGenerations ); } @@ -133,7 +152,7 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG // Cached under the TABLE identity (orgId + id, stringified, table order)… $this->assertTrue( - $this->cacheableService->exists($handler->exposeRowContext(['orgId' => 1, 'id' => 42])), + $this->cacheableService->exists($this->prober->exposeRowContext(['orgId' => 1, 'id' => 42])), 'Row is not cached under its canonical table-identity context.' ); // …and NOT under the model's own (subset) identity. @@ -174,7 +193,7 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $use // invalidation saw. $this->assertSame([['id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); $this->assertFalse( - $this->cacheableService->exists($handler->exposeRowContext(['id' => '7'])), + $this->cacheableService->exists($this->prober->exposeRowContext(['id' => '7'])), 'Business-key update left the canonical row entry to serve stale reads.' ); @@ -215,7 +234,7 @@ private function healRotatedAlias(CanonicalHandler $handler): DataModel // The row dies out from under the alias (deleted / re-keyed outside // this process). Drop the row entry to simulate; the alias remains. - $this->cacheableService->delete($handler->exposeRowContext(['id' => '7'])); + $this->cacheableService->delete($this->prober->exposeRowContext(['id' => '7'])); // Alias → id 7 → miss → DB says id 7 is gone… $this->queryStrategy->queueQueryResult([]); @@ -288,7 +307,7 @@ public function testGenerationBumpMakesLateStaleWriteUnreachable(): void $stale = $handler->findByCompound(['id' => '7']); // A slow reader computed its context BEFORE the write… - $staleContext = $handler->exposeRowContext(['id' => '7']); + $staleContext = $this->prober->exposeRowContext(['id' => '7']); // …the writer updates and bumps the generation (its pre-read is // served from the cache — no query)… @@ -404,7 +423,7 @@ public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGen $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); $this->assertFalse( - $this->cacheableService->exists($handler->exposeRowContext(['orgId' => '1', 'id' => '42'])), + $this->cacheableService->exists($this->prober->exposeRowContext(['orgId' => '1', 'id' => '42'])), 'deleteWhere left the canonical row entry behind.' ); @@ -580,11 +599,6 @@ public function findByCompound(array $ids) { return $this->findFromCompound($ids); } - - public function exposeRowContext(array $row): ?array - { - return $this->rowCache()->rowContext($row); - } } /** diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index fdcf54f..912057a 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -7,6 +7,7 @@ use PHPNomad\Database\Interfaces\QueryStrategy; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Factories\DatastoreRowCacheFactory; +use PHPNomad\Database\Tests\Doubles\ExposedRowCache; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; @@ -229,8 +230,10 @@ public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void $modelAdapter ); - $intContext = $handler->exposeRowContext(['id' => 123]); - $stringContext = $handler->exposeRowContext(['id' => '123']); + $prober = new ExposedRowCache($cacheableService, $loggerStrategy, $table, TestModel::class, $modelAdapter, false); + + $intContext = $prober->exposeRowContext(['id' => 123]); + $stringContext = $prober->exposeRowContext(['id' => '123']); $this->assertNotNull($intContext); $this->assertSame($intContext, $stringContext); @@ -294,7 +297,8 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // The deleted cache context must match what the read path wrote, // which used the int identity from the hydrated row. - $expected = $handler->exposeRowContext(['id' => 42]); + $prober = new ExposedRowCache($cacheableService, $loggerStrategy, $table, TestModel::class, $modelAdapter, false); + $expected = $prober->exposeRowContext(['id' => 42]); $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); $this->assertSame(['type' => TestModel::class], $deletedKeys[1]); @@ -371,11 +375,6 @@ public function findByIdentity(array $ids) return $this->findFromCompound($ids); } - public function exposeRowContext(array $row): ?array - { - return $this->rowCache()->rowContext($row); - } - /** * Legacy tests assert precise per-key cache interactions against a mocked * CacheableService; generations would add token get/set chatter that From 725819dbafc79cb09c2000034822120b105e3755 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 02:49:03 -0400 Subject: [PATCH 08/26] fix(cache): make reads outage-proof end to end; drop create's pre-warm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-10 audit findings (five passes; code-smells reproduced one major). The major: read paths performed unguarded cache MUTATIONS — the miss-path storeAlias/storeRow and the self-heal deleteAlias — so a write-failing cache backend broke reads (a rotated-alias lookup surfaced RuntimeException instead of RecordNotFoundException). Every RowCache mutation now swallows-and-logs cache failures, and the readRow/readTableValue read-throughs use a capture-guard that distinguishes three outcomes: fallback loaded then the store threw → serve the loaded value; the fallback itself threw → propagate the domain exception untouched; the cache probe threw before the fallback ran → load directly. The no-throw obligation is now stated on the RowCache contract itself, since the consuming trait calls everything unguarded — and the trait's now-dead defensive try/catch blocks are gone. create()'s cache pre-warm is REMOVED, not repaired. Two audit rounds found real hazards in it: request-typed scalars diverging from column types (#29), and this round's race — a pre-warm keyed under create's own post-insert bump can seed the newest generation with a row another writer already overwrote. The first read after create costs one DB round-trip and is always correct; correctness beats a micro-warm-up. updateCompound now THROWS RecordNotFoundException when the record cannot be re-resolved to a table identity mid-operation, instead of silently degrading the SQL target to the caller's raw business key — the failure-path fan-out the resolved-identity targeting exists to prevent. Also: booleans normalize to '0'/'1' in cache keys ((string) false is ''), the provider's CacheableService is documented as a deliberate downstream extension surface (the package's own flows no longer touch it), the interface's stray indentation passes php-cs-fixer, and ExposedRowCache documents why it deliberately extends a lib/ concrete. New coverage: cold business-key read under cache-write failure, rotated-alias lookup surfacing not-found (not cache errors) under failure, unresolvable update refusing to fan out, and first-read-after- create being DB-true. 63 tests, 133 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Interfaces/RowCache.php | 17 ++- lib/Providers/DatabaseServiceProvider.php | 7 + lib/Services/DatastoreRowCache.php | 124 ++++++++++++++---- lib/Traits/WithDatastoreHandlerMethods.php | 109 +++++++-------- tests/Doubles/ExposedRowCache.php | 4 + tests/Unit/Traits/CanonicalRowCacheTest.php | 90 +++++++++++-- .../WithDatastoreHandlerMethodsTest.php | 10 +- 7 files changed, 255 insertions(+), 106 deletions(-) diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index 2ed6f2a..f6b8664 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -14,6 +14,13 @@ * is datastore orchestration and lives with the consuming trait; this * contract supplies the operations it composes. * + * No-throw obligation: consumers call these methods UNGUARDED on read and + * write paths alike. Implementations must treat cache-layer failures as + * misses on reads (hasRow, resolveAliasedIdentity, snapshotGeneration) and + * swallow-and-log them on mutations (storeRow, storeAlias, deleteRow, + * deleteAlias, invalidateAfterWrite) — a throwing implementation breaks + * datastore reads and deletes during a cache outage. + * * @see \PHPNomad\Database\Services\DatastoreRowCache the default implementation */ interface RowCache @@ -68,9 +75,10 @@ public function readRow(array $identity, ?string $generation, callable $fallback public function hasRow(array $identityRow, ?string $generation = null): bool; /** - * Read-through for the table's set-level value (estimatedCount and any - * future whole-table caches): serves the cached value or runs the - * fallback and caches its result. + * Read-through for the table's single set-level value (estimatedCount): + * serves the cached value or runs the fallback and caches its result. + * One undiscriminated slot per table — a second whole-table value would + * need a discriminator added to the context. * * @param callable $fallback Computes the value on miss; its result is cached. * @return mixed @@ -148,5 +156,4 @@ public function deleteAlias(array $ids, ?string $generation = null): void; * disabled. */ public function snapshotGeneration(): ?string; - - } +} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index 4a3ab47..2863409 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -14,6 +14,13 @@ class DatabaseServiceProvider { public LoggerStrategy $loggerStrategy; public QueryStrategy $queryStrategy; + + /** + * Kept on the bundle as an extension surface for downstream handlers + * with caching needs beyond row caching — the package's own datastore + * flows no longer touch it directly (everything routes through the + * RowCache built by $rowCacheFactory). + */ public CacheableService $cacheableService; public QueryBuilder $queryBuilder; diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 8cc5ff6..a369f12 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -160,45 +160,62 @@ protected function tableContext(?string $generation = null): array return $this->withGeneration(['type' => $this->model], $generation); } - /** - * Stores a row's model under its canonical row context. Skips without - * throwing (rowIdentity() logs the reason) when the row cannot produce a - * full identity. - * - * Read paths MUST pass the generation snapshot they took before querying - * the database: taking a fresh token here would let a stale row land - * under a generation minted AFTER a concurrent write — reopening the - * exact race generations exist to close. - * - * @param array $row - * @param mixed $model - * @param string|null $generation Pre-query generation snapshot. - */ + /** @inheritDoc */ public function storeRow(array $row, $model, ?string $generation = null): void { $context = $this->rowContext($row, $generation); - if ($context !== null) { + if ($context === null) { + return; + } + + try { $this->cacheableService->set($context, $model); + } catch (Throwable $e) { + $this->logger->warning( + 'Could not cache a row — the next read will hit the database.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); } } /** @inheritDoc */ public function storeAlias(array $ids, array $identity, ?string $generation = null): void { - $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); + try { + $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); + } catch (Throwable $e) { + $this->logger->warning( + 'Could not cache an alias — the next lookup will re-resolve from the database.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } } /** @inheritDoc */ public function deleteRow(array $identity, ?string $generation = null): void { - $this->cacheableService->delete($this->identityContext($identity, $generation)); + try { + $this->cacheableService->delete($this->identityContext($identity, $generation)); + } catch (Throwable $e) { + $this->logger->error( + 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } } /** @inheritDoc */ public function deleteAlias(array $ids, ?string $generation = null): void { - $this->cacheableService->delete($this->aliasContext($ids, $generation)); + try { + $this->cacheableService->delete($this->aliasContext($ids, $generation)); + } catch (Throwable $e) { + $this->logger->error( + 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + } } /** @@ -213,11 +230,62 @@ protected function deleteTableContext(): void /** @inheritDoc */ public function readRow(array $identity, ?string $generation, callable $fallback) { - return $this->cacheableService->getWithCache( - Operation::Read, - $this->identityContext($identity, $generation), - $fallback - ); + return $this->guardedReadThrough($this->identityContext($identity, $generation), $fallback); + } + + /** + * Read-through that survives a failing cache backend without masking + * domain exceptions. Three outcomes are distinguished by where the + * failure happened relative to the fallback: + * + * - fallback loaded, then the cache store threw → return the loaded + * value (the cache write was best-effort); + * - the fallback itself threw → propagate untouched (that is a domain + * error like RecordNotFoundException, not a cache problem); + * - the cache probe threw before the fallback ran → load directly from + * the fallback. + * + * @param array $context + * @param callable $fallback + * @return mixed + */ + protected function guardedReadThrough(array $context, callable $fallback) + { + $started = false; + $resolved = false; + $value = null; + + $capturing = function () use ($fallback, &$started, &$resolved, &$value) { + $started = true; + $value = $fallback(); + $resolved = true; + + return $value; + }; + + try { + return $this->cacheableService->getWithCache(Operation::Read, $context, $capturing); + } catch (Throwable $e) { + if ($resolved) { + $this->logger->warning( + 'Cache store failed after a successful load — serving the loaded value uncached.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + + return $value; + } + + if ($started) { + throw $e; + } + + $this->logger->warning( + 'Cache read failed — loading directly from the fallback.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + + return $fallback(); + } } /** @inheritDoc */ @@ -247,7 +315,7 @@ public function hasRow(array $identityRow, ?string $generation = null): bool */ public function readTableValue(callable $fallback) { - return $this->cacheableService->getWithCache(Operation::Read, $this->tableContext(), $fallback); + return $this->guardedReadThrough($this->tableContext(), $fallback); } /** @@ -444,7 +512,13 @@ protected function mintGeneration(): string protected function stringifyScalars(array $values): array { foreach ($values as $key => $value) { - $values[$key] = is_scalar($value) ? (string) $value : $value; + if (is_bool($value)) { + // (string) false is '' — explicit '0' keeps booleans from + // colliding with empty strings in cache keys. + $values[$key] = $value ? '1' : '0'; + } elseif (is_scalar($value)) { + $values[$key] = (string) $value; + } } return $values; diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 0d510bf..ed29454 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -160,26 +160,15 @@ public function create(array $attributes): DataModel $row = Arr::merge($attributes, $ids); $result = $this->modelAdapter->toModel($row); - // The insert is committed: cache maintenance below is best-effort - // and must not fail the create or suppress RecordCreated. Bump - // BEFORE pre-warming so the row entry lands under the new - // generation and stays readable; set-level caches keyed under the - // old generation become unreachable. Bump and pre-warm failures are - // logged separately — the first means stale set-level caches until - // TTL, the second only a missed warm-up. - $generation = $this->invalidateAfterWriteSafely(); - - try { - // Pre-warm the cache so subsequent reads of this record don't - // have to round-trip the DB at all. Same canonical key every - // read path uses, so existing read paths transparently pick it up. - $this->rowCache()->storeRow($row, $result, $generation); - } catch (Throwable $e) { - $this->serviceProvider->loggerStrategy->warning( - 'Post-create pre-warm failed — the row was created but the first read will hit the database.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] - ); - } + // The insert is committed: the bump below is best-effort and must + // not fail the create or suppress RecordCreated. There is + // deliberately NO cache pre-warm: a model hydrated from write + // attributes carries request-typed scalars instead of column types + // (#29), and a pre-warm keyed under create's own post-insert bump + // can seed the newest generation with a row another writer already + // overwrote. The first read after create costs one DB round-trip + // and is always correct. + $this->invalidateAfterWriteSafely(); $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -244,19 +233,12 @@ public function deleteWhere(array $conditions): void // outside the matched set. $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); - try { - $identity = $this->rowCache()->rowIdentity($identityRow); + $identity = $this->rowCache()->rowIdentity($identityRow); - if ($identity !== null) { - $this->rowCache()->deleteRow($identity, $generation); - } - } catch (Throwable $e) { - // Cache trouble must not abort the remaining SQL deletes; - // the finally-block invalidation is the safety net. - $this->serviceProvider->loggerStrategy->warning( - 'Cache invalidation failed for a deleted row.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] - ); + if ($identity !== null) { + // Swallow-and-log inside RowCache: cache trouble cannot + // abort the remaining SQL deletes. + $this->rowCache()->deleteRow($identity, $generation); } $broadcastQueue[] = $identityRow; @@ -615,40 +597,41 @@ public function updateCompound($ids, array $attributes): void $identity = $this->resolveTableIdentity($ids, $generation); - // The SQL update targets the RESOLVED table identity when the - // pre-read could produce one: updateCompound's contract is "update - // THE record this key identifies" (the pre-read is limit(1) and one - // RecordUpdated fires), so a non-unique business key must not fan - // the write out to rows the invalidation below never saw. The - // caller's key is the fallback only when resolution failed. - $this->serviceProvider->queryStrategy->update($this->table, $identity ?? $ids, $attributes); - - // The DB write is committed: everything below is best-effort cache - // maintenance, and a cache-layer failure must not turn the - // successful update into a caller-visible error or suppress the - // RecordUpdated broadcast. Precise deletes run under the pre-write - // generation (the one readers wrote their entries with), then the - // finally-block bump closes the cache-aside race — precise deletes - // carry tables that opt out of generations. - try { - if ($identity !== null) { - $this->rowCache()->deleteRow($identity, $generation); - } + if ($identity === null) { + // The pre-read saw the record, but it cannot be re-resolved to a + // table identity now — it vanished mid-operation. Falling back + // to the caller's raw key would fan a non-unique business key + // out to rows the invalidation below never saw, so this fails + // the same way the pre-read would have. + throw new RecordNotFoundException(sprintf( + 'Record could not be re-resolved for update in table "%s" using lookup key %s.', + $this->table->getName(), + $this->encodeExceptionContext($ids) + )); + } - if (!$this->rowCache()->isTableIdentity($ids)) { - // Drop the alias too: the update may have moved the row's - // business key or identity out from under it. - $this->rowCache()->deleteAlias($ids, $generation); - } - } catch (Throwable $e) { - $this->serviceProvider->loggerStrategy->error( - 'Post-update cache invalidation failed — the generation bump is the remaining safety net.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] - ); - } finally { - $this->invalidateAfterWriteSafely(); + // The SQL update targets the RESOLVED table identity: updateCompound's + // contract is "update THE record this key identifies" (the pre-read + // is limit(1) and one RecordUpdated fires), so a non-unique business + // key must never fan the write out to rows the invalidation below + // never saw. + $this->serviceProvider->queryStrategy->update($this->table, $identity, $attributes); + + // The DB write is committed: the invalidation below is best-effort + // (RowCache mutations swallow-and-log cache failures) and runs under + // the pre-write generation — the one readers wrote their entries + // with. The bump closes the cache-aside race; precise deletes carry + // tables that opt out of generations. + $this->rowCache()->deleteRow($identity, $generation); + + if (!$this->rowCache()->isTableIdentity($ids)) { + // Drop the alias too: the update may have moved the row's + // business key or identity out from under it. + $this->rowCache()->deleteAlias($ids, $generation); } + $this->invalidateAfterWriteSafely(); + // The event intentionally carries the caller's key — the lookup // contract they wrote against — not the cache-normalized identity // the SQL targeted. diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php index ff718d7..09e0e65 100644 --- a/tests/Doubles/ExposedRowCache.php +++ b/tests/Doubles/ExposedRowCache.php @@ -9,6 +9,10 @@ * compute the exact cache slots the datastore uses (for exists() probes, * poisoning, and manual invalidation) without widening the production * contract. + * + * Deliberate test-only exception to the "don't extend lib/ concretes" rule: + * the alternative is putting key plumbing back on the RowCache interface, + * which the production contract intentionally narrowed away. */ class ExposedRowCache extends DatastoreRowCache { diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index fffae9e..6ba1317 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -347,20 +347,21 @@ public function testEstimatedCountInvalidatesAfterWrite(bool $useGenerations): v $this->assertSame(6, $handler->getEstimatedCount(), 'estimatedCount survived a write.'); } - public function testCreatePreWarmsRowReadableWithoutQuery(): void + public function testFirstReadAfterCreateHitsTheDatabase(): void { - // Bump-then-pre-warm ordering: the created row's entry must land - // under the NEW generation, or every post-create read would miss. + // create() deliberately does NOT pre-warm: a model hydrated from + // write attributes carries request-typed scalars, and a pre-warm can + // seed the newest generation with a row another writer already + // overwrote. The first read costs one round-trip and is DB-true. $handler = $this->makeHandler(['id'], 'test_records', true); - $created = $handler->create(['name' => 'warm']); + $handler->create(['name' => 'as-written']); - // No query result is queued: a DB round-trip here would throw. + $this->queryStrategy->queueQueryResult([['id' => '1', 'name' => 'db-truth']]); $read = $handler->findByCompound(['id' => 1]); - $this->assertSame('warm', $read->get('name')); - $this->assertSame(0, $this->queryStrategy->queryCount); - $this->assertSame($created, $read); + $this->assertSame('db-truth', $read->get('name')); + $this->assertSame(1, $this->queryStrategy->queryCount); } /** @@ -489,7 +490,7 @@ public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(): void $flaky = $this->useFlakyCache(); $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('warning'); + $logger->expects($this->atLeastOnce())->method('error'); $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); @@ -527,7 +528,7 @@ public function testDeleteWhereSurvivesCacheWriteFailureAndStillBroadcasts(): vo $flaky = $this->useFlakyCache(); $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('warning'); + $logger->expects($this->atLeastOnce())->method('error'); $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); @@ -566,6 +567,75 @@ public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerati $this->assertSame(2, $this->queryStrategy->queryCount, 'An unverifiable alias was trusted on a generation-disabled table.'); } + public function testColdBusinessKeyReadSurvivesCacheWriteFailure(): void + { + // The DB answered; a cache that cannot store the result must not + // turn a successful read into an error. + $flaky = $this->useFlakyCache(); + $logger = $this->createMock(LoggerStrategy::class); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + } + + public function testRotatedAliasLookupSurfacesNotFoundNotCacheErrorsUnderWriteFailure(): void + { + // The self-heal path performs cache mutations (row store, alias + // delete); with a write-failing cache the lookup must still resolve + // to its true outcome — RecordNotFoundException — not a cache error. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_api_keys', false); + + // Prime alias keyHash abc → id 7 while healthy, then drop the row + // entry so the next lookup takes the re-read path. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + $this->cacheableService->delete($this->prober->exposeRowContext(['id' => '7'])); + + $flaky->failWrites = true; + + // Alias → id 7 → row re-read shows the key rotated away → alias + // dropped (swallowed failure) → re-resolve by key finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'xyz', 'status' => 'active']]); + $this->queryStrategy->queueQueryResult([]); + + $this->expectException(RecordNotFoundException::class); + + $handler->findByCompound(['keyHash' => 'abc']); + } + + public function testUpdateCompoundThrowsWhenTheRecordCannotBeReResolved(): void + { + // With a write-dead cache the alias never persists, and the DB + // fallback resolution comes back empty (the row vanished + // mid-operation). Falling back to the caller's business key could + // fan the write out — the update must fail instead. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_api_keys', true); + + $flaky->failWrites = true; + + // Pre-read resolves via the DB (alias store fails silently)… + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + // …then identity resolution's DB fallback finds the row gone. + $this->queryStrategy->queueQueryResult([]); + + try { + $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); + $this->fail('Expected RecordNotFoundException when the record cannot be re-resolved.'); + } catch (RecordNotFoundException $e) { + $this->assertSame([], $this->queryStrategy->updates, 'An unresolvable record was still updated by raw business key.'); + } + } + } class CanonicalHandler diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 912057a..9b6be0b 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -46,9 +46,13 @@ public function testCreateHydratesFromAttributesWithoutPostInsertRead(): void $cacheableService = $this->createMock(CacheableService::class); $cacheableService->expects($this->never())->method('exists'); + // No pre-warm: create() never caches the attribute-hydrated model. + // Its only cache write path here (generations disabled) is the + // set-level context delete. + $cacheableService->expects($this->never())->method('set'); $cacheableService->expects($this->once()) - ->method('set') - ->with(['identities' => ['id' => '123'], 'type' => TestModel::class], $createdModel); + ->method('delete') + ->with(['type' => TestModel::class]); $table = $this->createMock(Table::class); $table->method('getFieldsForIdentity')->willReturn(['id']); @@ -104,7 +108,7 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void $createdModel = new TestModel(123); $cacheableService = $this->createMock(CacheableService::class); - $cacheableService->expects($this->once())->method('set'); + $cacheableService->expects($this->never())->method('set'); $nameColumn = new Column('name', 'VARCHAR', [255]); $createdAtColumn = (new Column('createdAt', 'TIMESTAMP')) From 79cc288cd1f450e58542c9d81215b9b664e1b92d Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 03:24:38 -0400 Subject: [PATCH 09/26] fix(cache): honor the no-throw contract in invalidateAfterWrite; fix duplicate self-match on business-key updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-12 audit findings — four lenses converged on the same root: the RowCache contract lists invalidateAfterWrite among the swallow-and-log mutations, but the default implementation propagated (reproduced under a failing cache in both generation modes) and only survived because the trait double-guarded it. The guard now lives in the implementation where the contract says it is, the trait's redundant wrapper is gone, and a direct service-level throwing-cache test pins the obligation. The genuinely new findings: - updateCompound's duplicate scan filtered self-matches by comparing each existing model's identity to the CALLER'S key — a business-key caller re-sending the record's own unique values could never match itself and tripped a spurious DuplicateEntryException. Self-matches now compare against the pre-read model's identity, which is model-to-model and shape-correct by construction. - getModels() held its freshly queried batch only in the cache, so a write-dead cache silently degraded one list read into 1+N per-row queries. Hydrated models are now also held locally; the cache only serves ids that were skipped as already cached. - A generation-token read BLIP (failure, not miss) now mints an ephemeral token without persisting it — readers must not clobber a healthy token and wholesale-invalidate the table cache during a read-side outage. - matchesLookup routes both comparison sides through stringifyScalars, so lookup verification can never drift from key-normalization rules (the inline version missed the bool rule: false compared as '' against a stored '0'). Also: read-path degradation now logs uniformly (hasRow, alias reads); the read-through failure semantics live on the interface where swapped implementations will read them; the stale pre-warm rationale is gone from the bump docs; the factory contract states the generations trade-off directly; the type-stability regression test moved beside the other pure-helper tests (its trait-level scaffolding was dead weight). TableSchemaService's pre-existing unguarded getWithCache is filed as a follow-up issue. 67 tests, 142 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Interfaces/RowCache.php | 14 ++-- lib/Interfaces/RowCacheFactory.php | 12 +++- lib/Services/DatastoreRowCache.php | 69 ++++++++++++++----- lib/Traits/WithDatastoreHandlerMethods.php | 69 ++++++++++--------- tests/Unit/Services/DatastoreRowCacheTest.php | 53 ++++++++++++++ tests/Unit/Traits/CanonicalRowCacheTest.php | 49 ++++++++++++- .../WithDatastoreHandlerMethodsTest.php | 42 ----------- 7 files changed, 206 insertions(+), 102 deletions(-) diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index f6b8664..b142be4 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -58,6 +58,12 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): * fallback and caches its result — every row cache read AND the miss-path * write happen behind this contract. * + * Failure semantics: cache-layer failures must never surface — serve the + * fallback's value when only the post-load store failed, and load + * directly when the probe itself broke. Exceptions thrown BY the + * fallback are domain errors (RecordNotFoundException) and must + * propagate untouched. + * * @param array $identity Canonical identity (from rowIdentity()). * @param string|null $generation Pre-query generation snapshot. * @param callable $fallback Loads the model on miss; its result is cached. @@ -75,10 +81,10 @@ public function readRow(array $identity, ?string $generation, callable $fallback public function hasRow(array $identityRow, ?string $generation = null): bool; /** - * Read-through for the table's single set-level value (estimatedCount): - * serves the cached value or runs the fallback and caches its result. - * One undiscriminated slot per table — a second whole-table value would - * need a discriminator added to the context. + * Read-through for the table's single set-level value: serves the cached + * value or runs the fallback and caches its result. One undiscriminated + * slot per table — a second whole-table value would need a discriminator + * added to the context. Same failure semantics as readRow(). * * @param callable $fallback Computes the value on miss; its result is cached. * @return mixed diff --git a/lib/Interfaces/RowCacheFactory.php b/lib/Interfaces/RowCacheFactory.php index 32db61f..a1fb674 100644 --- a/lib/Interfaces/RowCacheFactory.php +++ b/lib/Interfaces/RowCacheFactory.php @@ -18,9 +18,15 @@ interface RowCacheFactory * @param Table $table The table whose contexts the RowCache will build. * @param class-string $model The model class cache contexts are typed under. * @param ModelAdapter $adapter Used to verify alias-resolved rows against lookup values. - * @param bool $useGenerations Whether contexts carry a per-table generation token - * — see WithDatastoreHandlerMethods::shouldUseTableGenerations() - * for what opting out costs. + * @param bool $useGenerations Whether contexts carry a per-table generation + * token. On: writes orphan all prior contexts at + * once, closing the cache-aside write-back race. + * Off: higher hit rate on write-hot tables, with + * the race only TTL-bounded and invalidation + * relying on precise deletes plus read-time + * lookup verification. + * + * @see \PHPNomad\Database\Traits\WithDatastoreHandlerMethods::shouldUseTableGenerations() the per-handler override point */ public function make(Table $table, string $model, ModelAdapter $adapter, bool $useGenerations = true): RowCache; } diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index a369f12..7dabb3a 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -8,6 +8,7 @@ use PHPNomad\Database\Interfaces\Table; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; +use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; use PHPNomad\Logger\Interfaces\LoggerStrategy; use Throwable; @@ -302,6 +303,11 @@ public function hasRow(array $identityRow, ?string $generation = null): bool } catch (Throwable $e) { // A probe failure reads as uncached — the caller falls through // to the database. + $this->logger->warning( + 'Row cache probe failed — treating the row as uncached.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + return false; } } @@ -347,13 +353,14 @@ public function matchesLookup(DataModel $model, array $ids): bool return false; } - $actual = $data[$field]; + // Normalize both sides through the same rule cache keys use, so + // this comparison can never drift from key equality. + [$actual, $expected] = array_values($this->stringifyScalars([ + 'actual' => $data[$field], + 'expected' => $value, + ])); - if (is_scalar($actual) && is_scalar($value)) { - if ((string) $actual !== (string) $value) { - return false; - } - } elseif ($actual !== $value) { + if ($actual !== $expected) { return false; } } @@ -364,13 +371,22 @@ public function matchesLookup(DataModel $model, array $ids): bool /** @inheritDoc */ public function invalidateAfterWrite(): ?string { - if (!$this->useGenerations) { - $this->deleteTableContext(); + try { + if (!$this->useGenerations) { + $this->deleteTableContext(); + + return null; + } + + return $this->bumpGeneration(); + } catch (Throwable $e) { + $this->logger->error( + 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); return null; } - - return $this->bumpGeneration(); } /** @inheritDoc */ @@ -378,10 +394,17 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): { try { $aliased = $this->cacheableService->get($this->aliasContext($ids, $generation)); + } catch (CachedItemNotFoundException $e) { + return null; } catch (Throwable $e) { - // Any cache-layer failure reads as a miss: every alias caller - // has a database fallback, so a throwing backend degrades to + // A cache-layer failure reads as a miss: every alias caller has + // a database fallback, so a throwing backend degrades to // uncached instead of breaking the lookup. + $this->logger->warning( + 'Alias cache read failed — treating the alias as missing.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + return null; } @@ -421,9 +444,9 @@ public function snapshotGeneration(): ?string * Replaces the table's generation token. Called after every successful * write. A no-op when generations are disabled for this table. * - * @return string|null The freshly minted token, so post-write cache - * writes (create()'s pre-warm) can key under it; null - * when generations are disabled. + * @return string|null The freshly minted token (currently unconsumed — + * available to implementations that add post-write + * cache writes); null when generations are disabled. */ protected function bumpGeneration(): ?string { @@ -458,11 +481,19 @@ protected function currentGeneration(): string { try { $token = $this->cacheableService->get($this->generationContext()); - } catch (Throwable $e) { - // Read failure is treated exactly like a missing token: mint a - // fresh one so the operation proceeds with caching effectively - // disabled rather than breaking on a dead cache. + } catch (CachedItemNotFoundException $e) { $token = null; + } catch (Throwable $e) { + // A read FAILURE (not a miss) mints an ephemeral token for this + // operation only, WITHOUT persisting it: if reads blip while + // writes still work, persisting would let every reader clobber + // a healthy token and wholesale-invalidate the table cache. + $this->logger->warning( + 'Generation token read failed — using an ephemeral token for this operation.', + ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ); + + return $this->mintGeneration(); } if (!is_string($token) || $token === '') { diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index ed29454..19bf4bc 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -168,7 +168,7 @@ public function create(array $attributes): DataModel // can seed the newest generation with a row another writer already // overwrote. The first read after create costs one DB round-trip // and is always correct. - $this->invalidateAfterWriteSafely(); + $this->rowCache()->invalidateAfterWrite(); $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); @@ -246,7 +246,7 @@ public function deleteWhere(array $conditions): void } } finally { if ($deleted) { - $this->invalidateAfterWriteSafely(); + $this->rowCache()->invalidateAfterWrite(); } // Broadcasts are buffered and emitted after the SQL work so a @@ -270,29 +270,6 @@ public function deleteWhere(array $conditions): void } } - /** - * Post-write invalidation that never throws: a cache-layer failure after - * a committed database write must not turn the write into a - * caller-visible error, mask an in-flight exception, or suppress event - * broadcasts. Failures are logged and left to TTL recovery. - * - * @return string|null The fresh generation token, or null when - * generations are disabled or the cache failed. - */ - protected function invalidateAfterWriteSafely(): ?string - { - try { - return $this->rowCache()->invalidateAfterWrite(); - } catch (Throwable $e) { - $this->serviceProvider->loggerStrategy->error( - 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] - ); - - return null; - } - } - /** * @return $this */ @@ -459,6 +436,8 @@ protected function getModels(array $ids): array fn (array $identityRow) => !$this->rowCache()->hasRow($identityRow, $generation) ); + $hydrated = []; + if (!empty($idsToQuery)) { $clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table); // Get the things that aren't in the cache. @@ -471,14 +450,35 @@ protected function getModels(array $ids): array // Cache those items under their canonical row contexts, keyed // from the ROW's identity values — never the model's getIdentity(), - // which is not necessarily the table's identity. + // which is not necessarily the table's identity. The hydrated + // models are ALSO held locally: the batch result must not depend + // on the cache write landing, or a cache outage silently turns + // one list read into 1+N database queries. foreach ($data as $row) { - $this->rowCache()->storeRow($row, $this->modelAdapter->toModel($row), $generation); + $model = $this->modelAdapter->toModel($row); + $identity = $this->rowCache()->rowIdentity($row); + + if ($identity !== null) { + $hydrated[serialize($identity)] = $model; + } + + $this->rowCache()->storeRow($row, $model, $generation); } } - // Now, use the cache to return the rows in the requested order. - return Arr::map($ids, fn(array $id) => $this->findFromCompound($id, $generation)); + // Return the rows in the requested order: just-hydrated models are + // served directly; only ids skipped as already-cached consult the + // cache (falling through to the database on a miss). + return Arr::map($ids, function (array $id) use ($hydrated, $generation) { + $identity = $this->rowCache()->rowIdentity($id); + $key = $identity === null ? null : serialize($identity); + + if ($key !== null && array_key_exists($key, $hydrated)) { + return $hydrated[$key]; + } + + return $this->findFromCompound($id, $generation); + }); } /** @@ -592,8 +592,13 @@ public function updateCompound($ids, array $attributes): void // entry (canonical callers) — which is what makes the canonical // identity resolvable below without an extra query. It also throws // RecordNotFoundException before any write when the record is gone. - $this->findFromCompound($ids, $generation); - $this->maybeThrowForDuplicateUniqueFields($attributes, $ids); + $record = $this->findFromCompound($ids, $generation); + + // Self-matches are filtered by the pre-read MODEL's identity, not + // the caller's key: a business-key caller re-sending the record's + // own unique values must not trip a spurious duplicate error just + // because their lookup key never equals a model identity. + $this->maybeThrowForDuplicateUniqueFields($attributes, $record->getIdentity()); $identity = $this->resolveTableIdentity($ids, $generation); @@ -630,7 +635,7 @@ public function updateCompound($ids, array $attributes): void $this->rowCache()->deleteAlias($ids, $generation); } - $this->invalidateAfterWriteSafely(); + $this->rowCache()->invalidateAfterWrite(); // The event intentionally carries the caller's key — the lookup // contract they wrote against — not the cache-normalized identity diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php index 48b46b5..b188b20 100644 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -6,6 +6,7 @@ use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; use PHPNomad\Database\Tests\Doubles\ExposedRowCache; +use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; use PHPNomad\Database\Tests\TestCase; @@ -55,6 +56,20 @@ private function makeRowCache( ); } + public function testRowContextIsTypeStableAcrossIntAndStringIdentities(): void + { + // Regression: MySQL returns identity columns as strings, but hydrated + // rows can hold them as ints. Without normalization the same record + // produces two distinct cache contexts and invalidation misses one. + $rowCache = $this->makeRowCache(['id']); + + $intContext = $rowCache->exposeRowContext(['id' => 123]); + $stringContext = $rowCache->exposeRowContext(['id' => '123']); + + $this->assertNotNull($intContext); + $this->assertSame($intContext, $stringContext); + } + public function testRowIdentityReordersToTableOrderAndStringifies(): void { $rowCache = $this->makeRowCache(['orgId', 'id']); @@ -177,6 +192,44 @@ public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void $rowCache->matchesLookup(new RowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']) ); } + /** + * @return array + */ + public function generationModes(): array + { + return [ + 'generations on' => [true], + 'generations off' => [false], + ]; + } + + /** + * The contract lists invalidateAfterWrite among the swallow-and-log + * mutations: consumers call it unguarded after committed writes, so the + * default implementation must never let a cache failure escape. + * + * @dataProvider generationModes + */ + public function testInvalidateAfterWriteSwallowsCacheFailures(bool $useGenerations): void + { + $flaky = new FlakyCacheStrategy(); + $this->cacheStrategy = $flaky; + $this->cacheableService = new CacheableService( + new NullEventStrategy(), + $flaky, + new SerializingCachePolicy() + ); + + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('error'); + + $rowCache = $this->makeRowCache(['id'], $useGenerations, $logger); + + $flaky->failWrites = true; + + $this->assertNull($rowCache->invalidateAfterWrite()); + } + } class RowModel implements DataModel diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 6ba1317..02f0986 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -77,7 +77,8 @@ private function makeHandler( bool $useGenerations = false, ?LoggerStrategy $logger = null, ?EventStrategy $events = null, - ?ModelAdapter $adapter = null + ?ModelAdapter $adapter = null, + array $uniqueColumns = [] ): CanonicalHandler { $table = $this->createMock(Table::class); $table->method('getName')->willReturn($tableName); @@ -85,7 +86,7 @@ private function makeHandler( $table->method('getColumns')->willReturn([]); $tableSchemaService = $this->createMock(TableSchemaService::class); - $tableSchemaService->method('getUniqueColumns')->willReturn([]); + $tableSchemaService->method('getUniqueColumns')->willReturn($uniqueColumns); $logger = $logger ?? $this->createMock(LoggerStrategy::class); @@ -636,6 +637,50 @@ public function testUpdateCompoundThrowsWhenTheRecordCannotBeReResolved(): void } } + public function testListReadStaysBatchedWhenCacheWritesFail(): void + { + // The batch result must not depend on the cache write landing: with + // a write-dead cache, one where() is still findIds + one SELECT — + // never 1+N per-row re-queries. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + $flaky->failWrites = true; + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->queueQueryResult([ + ['id' => '1', 'name' => 'first'], + ['id' => '2', 'name' => 'second'], + ]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); + + $this->assertCount(2, $models); + $this->assertSame('first', $models[0]->get('name')); + $this->assertSame('second', $models[1]->get('name')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'A cache outage degraded a batched list read into per-row queries.'); + } + + public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): void + { + // Self-matches are filtered by the pre-read model's identity: a + // business-key caller re-sending the record's own unique values must + // not trip DuplicateEntryException just because their lookup key + // never equals a model identity. + $handler = $this->makeHandler(['id'], 'test_api_keys', true, null, null, null, [['keyHash']]); + + // Pre-read resolves the record by business key… + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + // …and the duplicate scan finds the same record (identity row, then + // its hydration is served from the cache warmed by the pre-read). + $this->queryStrategy->queueQueryResult([['id' => '7']]); + + $handler->updateCompound(['keyHash' => 'abc'], ['keyHash' => 'abc', 'status' => 'revoked']); + + $this->assertSame([['id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); + } + } class CanonicalHandler diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 9b6be0b..89c6358 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -201,48 +201,6 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void $this->assertSame($createdModel, $handler->create(['createdAt' => 'caller-provided'])); } - public function testCacheContextIsTypeStableAcrossIntAndStringIdentities(): void - { - // Regression: MySQL returns identity columns as strings, but hydrated - // models hold them as ints. Without normalization, the same record - // produces two distinct cache entries and updateCompound() only - // invalidates one of them — leaving the other to serve stale reads. - $loggerStrategy = $this->createMock(LoggerStrategy::class); - $eventStrategy = $this->createMock(EventStrategy::class); - $queryStrategy = $this->createMock(QueryStrategy::class); - $cacheableService = $this->createMock(CacheableService::class); - $table = $this->createMock(Table::class); - $table->method('getFieldsForIdentity')->willReturn(['id']); - $tableSchemaService = $this->createMock(TableSchemaService::class); - $modelAdapter = $this->createMock(ModelAdapter::class); - - $serviceProvider = new DatabaseServiceProvider( - $loggerStrategy, - $queryStrategy, - new NoopQueryBuilder(), - new NoopClauseBuilder(), - $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) - ); - - $handler = new DummyDatastoreHandler( - $serviceProvider, - $table, - $tableSchemaService, - TestModel::class, - $modelAdapter - ); - - $prober = new ExposedRowCache($cacheableService, $loggerStrategy, $table, TestModel::class, $modelAdapter, false); - - $intContext = $prober->exposeRowContext(['id' => 123]); - $stringContext = $prober->exposeRowContext(['id' => '123']); - - $this->assertNotNull($intContext); - $this->assertSame($intContext, $stringContext); - } - public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): void { // Drives the actual stale-read scenario end-to-end: the read path From 5e318b45f2a0411b5e3ad2cd036540eace9950be Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 04:02:38 -0400 Subject: [PATCH 10/26] fix(cache): table-identity duplicate filtering; rotation-conditioned updates; read-outage proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-14 audit findings — zero majors for the first time; two code-smell minors, one testing minor, and the accumulated convention nits. Correctness: - The duplicate scan's self-match filter now compares TABLE identities (deriving each candidate's canonical identity through the adapter): model identities can be a strict subset of the table's, so a true duplicate on a DIFFERENT row sharing the pre-read record's model identity could hide behind a model-identity self-match. The model-identity comparison remains only as the fallback for adapters that cannot expose a table identity, with the residual shadow documented. Pinned by a test where org 2's row shares org 1's model identity and still raises DuplicateEntryException. - updateCompound's SQL WHERE now pins the resolved identity TOGETHER with the caller's lookup fields: identity targeting alone reopened a rotation TOCTOU (resolve, concurrent key rotation, update lands on a row that no longer carries the key) — merged conditions make that a no-op write instead. QueryStrategy::update() returning void means a no-op still broadcasts; documented as a known limitation. - The read-side outage claims are now proven: FlakyCacheStrategy grew a failReads switch, and tests drive canonical reads, alias lookups, estimatedCount, and the generation read-blip (ephemeral token, healthy token NOT clobbered, entries live after recovery) through a read-failing cache. Contract/consistency: identityKey() joins RowCache so identity equality is defined by its owner (getModels' dedupe map uses it, and skips the store for underivable rows instead of double-logging); the snapshot-vs-miss distinction is stated on the contract; collaborator names align with package conventions ($loggerStrategy, $modelAdapter); model/adapter fixtures consolidated into tests/Doubles; provider docblock scopes the extension surface and forbids bypassing RowCache for row/alias caching; assorted wording fixes (neutral post-write invalidation phrasing, ANTI-PATTERNS reference, lazy-init rationale). 72 tests, 153 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Factories/DatastoreRowCacheFactory.php | 10 +- lib/Interfaces/RowCache.php | 16 ++ lib/Interfaces/RowCacheFactory.php | 4 +- lib/Providers/DatabaseServiceProvider.php | 9 +- lib/Services/DatastoreRowCache.php | 40 ++-- lib/Traits/WithDatastoreHandlerMethods.php | 84 +++++--- tests/Doubles/ExposedRowCache.php | 8 +- tests/Doubles/FlakyCacheStrategy.php | 27 ++- tests/Doubles/HidingModelAdapter.php | 23 +++ tests/Doubles/IdentityRowModel.php | 32 ++++ tests/Doubles/IdentityRowModelAdapter.php | 23 +++ tests/Unit/Services/DatastoreRowCacheTest.php | 60 ++---- tests/Unit/Traits/CanonicalRowCacheTest.php | 179 +++++++++++------- 13 files changed, 334 insertions(+), 181 deletions(-) create mode 100644 tests/Doubles/HidingModelAdapter.php create mode 100644 tests/Doubles/IdentityRowModel.php create mode 100644 tests/Doubles/IdentityRowModelAdapter.php diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php index edf9e81..41a864f 100644 --- a/lib/Factories/DatastoreRowCacheFactory.php +++ b/lib/Factories/DatastoreRowCacheFactory.php @@ -19,17 +19,17 @@ class DatastoreRowCacheFactory implements RowCacheFactory { protected CacheableService $cacheableService; - protected LoggerStrategy $logger; + protected LoggerStrategy $loggerStrategy; - public function __construct(CacheableService $cacheableService, LoggerStrategy $logger) + public function __construct(CacheableService $cacheableService, LoggerStrategy $loggerStrategy) { $this->cacheableService = $cacheableService; - $this->logger = $logger; + $this->loggerStrategy = $loggerStrategy; } /** @inheritDoc */ - public function make(Table $table, string $model, ModelAdapter $adapter, bool $useGenerations = true): RowCache + public function make(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true): RowCache { - return new DatastoreRowCache($this->cacheableService, $this->logger, $table, $model, $adapter, $useGenerations); + return new DatastoreRowCache($this->cacheableService, $this->loggerStrategy, $table, $model, $modelAdapter, $useGenerations); } } diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index b142be4..f7c8ddb 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -43,6 +43,16 @@ public function isTableIdentity(array $ids): bool; */ public function rowIdentity(array $row): ?array; + /** + * Opaque equality key for a row's canonical identity — two rows are the + * same record exactly when their identity keys match. Consumers use this + * for local dedupe so identity equality stays defined in one place. + * + * @param array $row + * @return string|null Null when the row cannot produce a full identity. + */ + public function identityKey(array $row): ?string; + /** * Reads the identity an alias entry points at, validated against the * table's identity shape. Null on miss or malformed value. @@ -160,6 +170,12 @@ public function deleteAlias(array $ids, ?string $generation = null): void; * Takes the generation snapshot an operation should key its contexts * under — once, before any database query. Null when generations are * disabled. + * + * Failure is NOT a miss here: a missing token may be minted and + * persisted, but a token that could not be READ must yield an ephemeral + * token that is never persisted — persisting on a read blip would let + * every reader clobber a healthy token and wholesale-invalidate the + * table cache. */ public function snapshotGeneration(): ?string; } diff --git a/lib/Interfaces/RowCacheFactory.php b/lib/Interfaces/RowCacheFactory.php index a1fb674..597a3fa 100644 --- a/lib/Interfaces/RowCacheFactory.php +++ b/lib/Interfaces/RowCacheFactory.php @@ -17,7 +17,7 @@ interface RowCacheFactory /** * @param Table $table The table whose contexts the RowCache will build. * @param class-string $model The model class cache contexts are typed under. - * @param ModelAdapter $adapter Used to verify alias-resolved rows against lookup values. + * @param ModelAdapter $modelAdapter Used to verify alias-resolved rows against lookup values. * @param bool $useGenerations Whether contexts carry a per-table generation * token. On: writes orphan all prior contexts at * once, closing the cache-aside write-back race. @@ -28,5 +28,5 @@ interface RowCacheFactory * * @see \PHPNomad\Database\Traits\WithDatastoreHandlerMethods::shouldUseTableGenerations() the per-handler override point */ - public function make(Table $table, string $model, ModelAdapter $adapter, bool $useGenerations = true): RowCache; + public function make(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true): RowCache; } diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index 2863409..b36234b 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -17,9 +17,12 @@ class DatabaseServiceProvider /** * Kept on the bundle as an extension surface for downstream handlers - * with caching needs beyond row caching — the package's own datastore - * flows no longer touch it directly (everything routes through the - * RowCache built by $rowCacheFactory). + * with caching needs beyond row caching. All row and alias caching in + * the package's datastore flows routes through the RowCache built by + * $rowCacheFactory (schema lookups cache separately via + * TableSchemaService) — never cache rows or aliases against this + * directly; build or extend a RowCache instead, or invalidation cannot + * name your keys. */ public CacheableService $cacheableService; diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 7dabb3a..a03359f 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -28,7 +28,7 @@ class DatastoreRowCache implements RowCache { protected CacheableService $cacheableService; - protected LoggerStrategy $logger; + protected LoggerStrategy $loggerStrategy; protected Table $table; /** @@ -43,14 +43,14 @@ class DatastoreRowCache implements RowCache */ public function __construct( CacheableService $cacheableService, - LoggerStrategy $logger, + LoggerStrategy $loggerStrategy, Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true ) { $this->cacheableService = $cacheableService; - $this->logger = $logger; + $this->loggerStrategy = $loggerStrategy; $this->table = $table; $this->model = $model; $this->modelAdapter = $modelAdapter; @@ -91,7 +91,7 @@ public function rowIdentity(array $row): ?array foreach ($identityFields as $field) { if (!array_key_exists($field, $row)) { - $this->logger->warning( + $this->loggerStrategy->warning( 'Cannot derive a canonical cache identity — row is missing an identity field.', ['table' => $this->table->getName(), 'missingField' => $field, 'rowFields' => array_keys($row)] ); @@ -105,6 +105,14 @@ public function rowIdentity(array $row): ?array return $this->stringifyScalars($identity); } + /** @inheritDoc */ + public function identityKey(array $row): ?string + { + $identity = $this->rowIdentity($row); + + return $identity === null ? null : serialize($identity); + } + /** * Builds the ONE cache context a row is stored under, derived from row * data rather than from whatever shape the caller asked for. @@ -173,7 +181,7 @@ public function storeRow(array $row, $model, ?string $generation = null): void try { $this->cacheableService->set($context, $model); } catch (Throwable $e) { - $this->logger->warning( + $this->loggerStrategy->warning( 'Could not cache a row — the next read will hit the database.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -186,7 +194,7 @@ public function storeAlias(array $ids, array $identity, ?string $generation = nu try { $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); } catch (Throwable $e) { - $this->logger->warning( + $this->loggerStrategy->warning( 'Could not cache an alias — the next lookup will re-resolve from the database.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -199,7 +207,7 @@ public function deleteRow(array $identity, ?string $generation = null): void try { $this->cacheableService->delete($this->identityContext($identity, $generation)); } catch (Throwable $e) { - $this->logger->error( + $this->loggerStrategy->error( 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -212,7 +220,7 @@ public function deleteAlias(array $ids, ?string $generation = null): void try { $this->cacheableService->delete($this->aliasContext($ids, $generation)); } catch (Throwable $e) { - $this->logger->error( + $this->loggerStrategy->error( 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -268,7 +276,7 @@ protected function guardedReadThrough(array $context, callable $fallback) return $this->cacheableService->getWithCache(Operation::Read, $context, $capturing); } catch (Throwable $e) { if ($resolved) { - $this->logger->warning( + $this->loggerStrategy->warning( 'Cache store failed after a successful load — serving the loaded value uncached.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -280,7 +288,7 @@ protected function guardedReadThrough(array $context, callable $fallback) throw $e; } - $this->logger->warning( + $this->loggerStrategy->warning( 'Cache read failed — loading directly from the fallback.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -303,7 +311,7 @@ public function hasRow(array $identityRow, ?string $generation = null): bool } catch (Throwable $e) { // A probe failure reads as uncached — the caller falls through // to the database. - $this->logger->warning( + $this->loggerStrategy->warning( 'Row cache probe failed — treating the row as uncached.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -345,7 +353,7 @@ public function matchesLookup(DataModel $model, array $ids): bool continue; } - $this->logger->warning( + $this->loggerStrategy->warning( 'Alias lookup field could not be verified — the model adapter does not expose it; treating the alias as stale.', ['table' => $this->table->getName(), 'field' => $field] ); @@ -380,7 +388,7 @@ public function invalidateAfterWrite(): ?string return $this->bumpGeneration(); } catch (Throwable $e) { - $this->logger->error( + $this->loggerStrategy->error( 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -400,7 +408,7 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): // A cache-layer failure reads as a miss: every alias caller has // a database fallback, so a throwing backend degrades to // uncached instead of breaking the lookup. - $this->logger->warning( + $this->loggerStrategy->warning( 'Alias cache read failed — treating the alias as missing.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -488,7 +496,7 @@ protected function currentGeneration(): string // operation only, WITHOUT persisting it: if reads blip while // writes still work, persisting would let every reader clobber // a healthy token and wholesale-invalidate the table cache. - $this->logger->warning( + $this->loggerStrategy->warning( 'Generation token read failed — using an ephemeral token for this operation.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); @@ -505,7 +513,7 @@ protected function currentGeneration(): string // Cache down: every operation mints its own token, so keys // never match and reads fall through to the database — // caching degrades to disabled instead of breaking reads. - $this->logger->warning( + $this->loggerStrategy->warning( 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', ['table' => $this->table->getName(), 'exception' => $e->getMessage()] ); diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 19bf4bc..e0a9148 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -160,8 +160,9 @@ public function create(array $attributes): DataModel $row = Arr::merge($attributes, $ids); $result = $this->modelAdapter->toModel($row); - // The insert is committed: the bump below is best-effort and must - // not fail the create or suppress RecordCreated. There is + // The insert is committed: the post-write invalidation below (a + // generation bump, or a set-level delete for opted-out tables) is + // best-effort and must not fail the create or suppress RecordCreated. There is // deliberately NO cache pre-warm: a model hydrated from write // attributes carries request-typed scalars instead of column types // (#29), and a pre-warm keyed under create's own post-insert bump @@ -222,7 +223,8 @@ public function deleteWhere(array $conditions): void // Alias entries pointing at deleted rows are left to self-heal: the // row read they resolve to misses and falls through to the database. - // The finally block guarantees the generation bump lands even when a + // The finally block guarantees the post-write invalidation (bump, or + // set-level delete for opted-out tables) lands even when a // later row's SQL delete throws — rows already deleted (and // broadcast) must not leave set-level caches serving stale data. try { @@ -357,7 +359,10 @@ protected function buildConditions(array $groups) /** * Lazily builds the per-table row-cache collaborator that owns every * cache context this trait uses (canonical row identities, alias - * entries, generation tokens). + * entries, generation tokens). Lazy `??=` instead of constructor + * injection because a trait cannot extend its consumer's constructor, + * and the factory needs $table/$model/$modelAdapter, which consumers + * set after construction. * * @see \PHPNomad\Database\Interfaces\RowCache */ @@ -456,13 +461,12 @@ protected function getModels(array $ids): array // one list read into 1+N database queries. foreach ($data as $row) { $model = $this->modelAdapter->toModel($row); - $identity = $this->rowCache()->rowIdentity($row); + $key = $this->rowCache()->identityKey($row); - if ($identity !== null) { - $hydrated[serialize($identity)] = $model; + if ($key !== null) { + $hydrated[$key] = $model; + $this->rowCache()->storeRow($row, $model, $generation); } - - $this->rowCache()->storeRow($row, $model, $generation); } } @@ -470,8 +474,7 @@ protected function getModels(array $ids): array // served directly; only ids skipped as already-cached consult the // cache (falling through to the database on a miss). return Arr::map($ids, function (array $id) use ($hydrated, $generation) { - $identity = $this->rowCache()->rowIdentity($id); - $key = $identity === null ? null : serialize($identity); + $key = $this->rowCache()->identityKey($id); if ($key !== null && array_key_exists($key, $hydrated)) { return $hydrated[$key]; @@ -594,12 +597,6 @@ public function updateCompound($ids, array $attributes): void // RecordNotFoundException before any write when the record is gone. $record = $this->findFromCompound($ids, $generation); - // Self-matches are filtered by the pre-read MODEL's identity, not - // the caller's key: a business-key caller re-sending the record's - // own unique values must not trip a spurious duplicate error just - // because their lookup key never equals a model identity. - $this->maybeThrowForDuplicateUniqueFields($attributes, $record->getIdentity()); - $identity = $this->resolveTableIdentity($ids, $generation); if ($identity === null) { @@ -615,12 +612,22 @@ public function updateCompound($ids, array $attributes): void )); } - // The SQL update targets the RESOLVED table identity: updateCompound's - // contract is "update THE record this key identifies" (the pre-read - // is limit(1) and one RecordUpdated fires), so a non-unique business - // key must never fan the write out to rows the invalidation below - // never saw. - $this->serviceProvider->queryStrategy->update($this->table, $identity, $attributes); + // Self-matches in the duplicate scan are filtered by TABLE identity + // (falling back to model identity only when an adapter cannot expose + // one): model identities can be shared by distinct rows, and a true + // duplicate must not hide behind one. + $this->maybeThrowForDuplicateUniqueFields($attributes, $identity, $record->getIdentity()); + + // The SQL update targets the RESOLVED table identity AND the + // caller's own lookup fields: the identity pins exactly one row (no + // non-unique-business-key fan-out), and keeping the caller's fields + // in the WHERE re-conditions the write on the key they asked for — + // a concurrent rotation between resolution and UPDATE makes this a + // no-op instead of updating a row that no longer carries the key. + // (QueryStrategy::update() returns void, so a no-op write still + // broadcasts; documented as a known limitation.) + $conditions = $this->rowCache()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); + $this->serviceProvider->queryStrategy->update($this->table, $conditions, $attributes); // The DB write is committed: the invalidation below is best-effort // (RowCache mutations swallow-and-log cache failures) and runs under @@ -774,23 +781,38 @@ protected function getDuplicates(array $data): array /** + * Guards unique-column groups. When updating, the record being updated + * is filtered out of the duplicate scan by TABLE identity — model + * identities can be a subset of the table's and therefore shared across + * distinct rows, so a model-identity self-match could hide a true + * duplicate. The model-identity comparison is only the fallback for + * adapters that cannot expose the table identity (that fallback CAN + * shadow a duplicate sharing the model identity; such adapters trade + * that for not tripping spurious self-duplicates). + * * @param array $data - * @param array|null $updateIdentity + * @param array|null $updateTableIdentity Canonical identity of the record being updated. + * @param array|null $updateModelIdentity Model identity of the record being updated. * @return void * @throws DuplicateEntryException * @throws DatastoreErrorException */ - protected function maybeThrowForDuplicateUniqueFields(array $data, ?array $updateIdentity = null): void + protected function maybeThrowForDuplicateUniqueFields(array $data, ?array $updateTableIdentity = null, ?array $updateModelIdentity = null): void { try { $duplicates = $this->getDuplicates($data); - // If an identity is provided, filter out items that have the provided identity. - if (!is_null($updateIdentity)) { - $duplicates = Arr::filter( - $duplicates, - fn(CanIdentify $existingItem) => !Arr::containsSameData($existingItem->getIdentity(), $updateIdentity) - ); + if ($updateTableIdentity !== null || $updateModelIdentity !== null) { + $duplicates = Arr::filter($duplicates, function (CanIdentify $existingItem) use ($updateTableIdentity, $updateModelIdentity) { + $existingTableIdentity = $this->rowCache()->rowIdentity($this->modelAdapter->toArray($existingItem)); + + if ($existingTableIdentity !== null && $updateTableIdentity !== null) { + return !Arr::containsSameData($existingTableIdentity, $updateTableIdentity); + } + + return $updateModelIdentity === null + || !Arr::containsSameData($existingItem->getIdentity(), $updateModelIdentity); + }); } } catch (RecordNotFoundException $e) { // Bail if no records were found. diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php index 09e0e65..5ad482d 100644 --- a/tests/Doubles/ExposedRowCache.php +++ b/tests/Doubles/ExposedRowCache.php @@ -10,9 +10,11 @@ * poisoning, and manual invalidation) without widening the production * contract. * - * Deliberate test-only exception to the "don't extend lib/ concretes" rule: - * the alternative is putting key plumbing back on the RowCache interface, - * which the production contract intentionally narrowed away. + * Tests normally avoid extending lib/ concretes (the ANTI-PATTERNS KB entry + * flags inheritance from package classes); this subclass is the deliberate + * exception, because the alternative is putting key plumbing back on the + * RowCache interface, which the production contract intentionally narrowed + * away. */ class ExposedRowCache extends DatastoreRowCache { diff --git a/tests/Doubles/FlakyCacheStrategy.php b/tests/Doubles/FlakyCacheStrategy.php index 8c65982..9061ae3 100644 --- a/tests/Doubles/FlakyCacheStrategy.php +++ b/tests/Doubles/FlakyCacheStrategy.php @@ -5,14 +5,33 @@ use RuntimeException; /** - * ArrayCacheStrategy whose WRITES (set/delete) can be switched to throw, - * simulating a cache backend dying mid-operation. Reads keep working so - * tests can prime state healthy, flip $failWrites, and prove the write - * paths degrade instead of breaking. + * ArrayCacheStrategy whose WRITES (set/delete) and/or READS (get/exists) + * can be switched to throw, simulating a cache backend dying mid-operation. + * Tests prime state healthy, flip a switch, and prove the datastore paths + * degrade instead of breaking. */ class FlakyCacheStrategy extends ArrayCacheStrategy { public bool $failWrites = false; + public bool $failReads = false; + + public function get(string $key) + { + if ($this->failReads) { + throw new RuntimeException('Simulated cache read failure.'); + } + + return parent::get($key); + } + + public function exists(string $key): bool + { + if ($this->failReads) { + throw new RuntimeException('Simulated cache read failure.'); + } + + return parent::exists($key); + } public function set(string $key, $value, ?int $ttl): void { diff --git a/tests/Doubles/HidingModelAdapter.php b/tests/Doubles/HidingModelAdapter.php new file mode 100644 index 0000000..f4800f4 --- /dev/null +++ b/tests/Doubles/HidingModelAdapter.php @@ -0,0 +1,23 @@ +row[$field] ?? null; + } + + public function toRow(): array + { + return $this->row; + } + + public function getIdentity(): array + { + return ['id' => $this->row['id'] ?? null]; + } +} diff --git a/tests/Doubles/IdentityRowModelAdapter.php b/tests/Doubles/IdentityRowModelAdapter.php new file mode 100644 index 0000000..91dd678 --- /dev/null +++ b/tests/Doubles/IdentityRowModelAdapter.php @@ -0,0 +1,23 @@ +toRow() : []; + } +} diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php index b188b20..364e2cb 100644 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -7,6 +7,9 @@ use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; use PHPNomad\Database\Tests\Doubles\ExposedRowCache; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; +use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; +use PHPNomad\Database\Tests\Doubles\IdentityRowModel; +use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; use PHPNomad\Database\Tests\TestCase; @@ -50,8 +53,8 @@ private function makeRowCache( $this->cacheableService, $logger ?? $this->createMock(LoggerStrategy::class), $table, - RowModel::class, - $adapter ?? new RowModelAdapter(), + IdentityRowModel::class, + $adapter ?? new IdentityRowModelAdapter(), $useGenerations ); } @@ -166,7 +169,7 @@ public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRo { $rowCache = $this->makeRowCache(['id']); - $this->assertSame($expected, $rowCache->matchesLookup(new RowModel($modelRow), $lookup)); + $this->assertSame($expected, $rowCache->matchesLookup(new IdentityRowModel($modelRow), $lookup)); } public function testMatchesLookupTreatsUnverifiableFieldAsStaleWithoutGenerations(): void @@ -179,7 +182,7 @@ public function testMatchesLookupTreatsUnverifiableFieldAsStaleWithoutGeneration $rowCache = $this->makeRowCache(['id'], false, $logger, new HidingModelAdapter()); $this->assertFalse( - $rowCache->matchesLookup(new RowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']), + $rowCache->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']), 'An unverifiable lookup passed on a generation-disabled table — read-time verification is its only rotation defense.' ); } @@ -189,7 +192,7 @@ public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void $rowCache = $this->makeRowCache(['id'], true, null, new HidingModelAdapter()); $this->assertTrue( - $rowCache->matchesLookup(new RowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']) + $rowCache->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']) ); } /** @@ -230,51 +233,14 @@ public function testInvalidateAfterWriteSwallowsCacheFailures(bool $useGeneratio $this->assertNull($rowCache->invalidateAfterWrite()); } -} - -class RowModel implements DataModel -{ - public function __construct(private array $row = []) - { - } - - public function toRow(): array + public function testInvalidateAfterWriteReturnsAFreshTokenWhenHealthy(): void { - return $this->row; - } - - public function getIdentity(): array - { - return ['id' => $this->row['id'] ?? null]; - } -} + // The contrast that makes the failure-case null meaningful: with a + // healthy cache and generations on, a write yields the new token. + $rowCache = $this->makeRowCache(['id'], true); -class RowModelAdapter implements ModelAdapter -{ - public function toModel(array $array): DataModel - { - return new RowModel($array); + $this->assertNotNull($rowCache->invalidateAfterWrite()); } - public function toArray(DataModel $model): array - { - return $model instanceof RowModel ? $model->toRow() : []; - } } -/** - * Adapter that exposes nothing — the narrow-serialization case lookup - * verification has to survive. - */ -class HidingModelAdapter implements ModelAdapter -{ - public function toModel(array $array): DataModel - { - return new RowModel($array); - } - - public function toArray(DataModel $model): array - { - return []; - } -} diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 02f0986..3271300 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -10,6 +10,9 @@ use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; use PHPNomad\Database\Tests\Doubles\ExposedRowCache; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; +use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; +use PHPNomad\Database\Tests\Doubles\IdentityRowModel; +use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; use PHPNomad\Database\Tests\Doubles\NoopQueryBuilder; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; @@ -21,6 +24,7 @@ use PHPNomad\Datastore\Events\RecordCreated; use PHPNomad\Datastore\Events\RecordDeleted; use PHPNomad\Datastore\Events\RecordUpdated; +use PHPNomad\Datastore\Exceptions\DuplicateEntryException; use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; @@ -100,13 +104,13 @@ private function makeHandler( new DatastoreRowCacheFactory($this->cacheableService, $logger) ); - $adapter = $adapter ?? new ArrayModelAdapter(); + $adapter = $adapter ?? new IdentityRowModelAdapter(); $this->prober = new ExposedRowCache( $this->cacheableService, $logger, $table, - AttrModel::class, + IdentityRowModel::class, $adapter, $useGenerations ); @@ -115,7 +119,7 @@ private function makeHandler( $serviceProvider, $table, $tableSchemaService, - AttrModel::class, + IdentityRowModel::class, $adapter, $useGenerations ); @@ -156,11 +160,15 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG $this->cacheableService->exists($this->prober->exposeRowContext(['orgId' => 1, 'id' => 42])), 'Row is not cached under its canonical table-identity context.' ); - // …and NOT under the model's own (subset) identity. + // …and NOT under the model's own (subset) identity. The probed shape + // mirrors the pre-PR key vocabulary as regression documentation; the + // exact entry count below is the general guard — one row entry plus + // the generation token when generations are on. $this->assertFalse( - $this->cacheableService->exists(['type' => AttrModel::class, 'identities' => ['id' => '42']]), + $this->cacheableService->exists(['type' => IdentityRowModel::class, 'identities' => ['id' => '42']]), 'Row leaked a cache entry keyed by the MODEL identity.' ); + $this->assertCount($useGenerations ? 2 : 1, $this->cacheStrategy->store, 'Unexpected cache entries beyond the row (and generation token).'); // A second identical read: findIds queries again (list SQL is not // cached), but the row itself is served from cache — no SELECT *. @@ -188,11 +196,12 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $use $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->updateCompound(['keyHash' => 'abc'], ['status' => 'revoked']); - // The SQL update targets the RESOLVED identity, not the business - // key - updateCompound's contract is a single record, and a - // non-unique business key must not fan the write out past what the - // invalidation saw. - $this->assertSame([['id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); + // The SQL update targets the RESOLVED identity pinned together with + // the caller's business key: the identity prevents non-unique-key + // fan-out, and keeping the key in the WHERE turns a concurrent + // rotation into a no-op instead of updating a row that no longer + // carries the key. + $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); $this->assertFalse( $this->cacheableService->exists($this->prober->exposeRowContext(['id' => '7'])), 'Business-key update left the canonical row entry to serve stale reads.' @@ -433,7 +442,7 @@ public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGen // values, no cache normalization in the event contract. $deletions = $events->ofType(RecordDeleted::class); $this->assertCount(1, $deletions); - $this->assertSame(AttrModel::class, $deletions[0]->getType()); + $this->assertSame(IdentityRowModel::class, $deletions[0]->getType()); $this->assertSame(['orgId' => '1', 'id' => '42'], $deletions[0]->getIdentity()); } @@ -554,7 +563,7 @@ public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerati $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce())->method('warning'); - $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new HidingAdapter()); + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new HidingModelAdapter()); $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->findByCompound(['keyHash' => 'abc']); @@ -678,11 +687,98 @@ public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): $handler->updateCompound(['keyHash' => 'abc'], ['keyHash' => 'abc', 'status' => 'revoked']); - $this->assertSame([['id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); + $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); + } + + public function testReadsSurviveACacheThatFailsOnReads(): void + { + // Probe/read failures must degrade to database loads — never break + // the read. Covers readRow (canonical), the alias read, and the + // generation-token read in one lookup flow. + $flaky = $this->useFlakyCache(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce())->method('warning'); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger); + + $flaky->failReads = true; + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $byKey = $handler->findByCompound(['keyHash' => 'abc']); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $byId = $handler->findByCompound(['id' => '7']); + + $this->assertSame('7', $byKey->get('id')); + $this->assertSame('active', $byId->get('status')); + } + + public function testEstimatedCountSurvivesACacheThatFailsOnReads(): void + { + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + $flaky->failReads = true; + $this->queryStrategy->estimatedCountValue = 9; + + $this->assertSame(9, $handler->getEstimatedCount()); + } + + public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void + { + // A read FAILURE mints an ephemeral token without persisting: when + // reads recover, the original token (and the entries keyed under it) + // must still be live. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Healthy read caches the row under the current token. + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + // During the blip, the read degrades to the database… + $flaky->failReads = true; + $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + + // …and after recovery the original entries are untouched and served. + $flaky->failReads = false; + $flaky->failWrites = false; + + foreach ($storeBefore as $key => $value) { + $this->assertArrayHasKey($key, $this->cacheStrategy->store, 'A read blip clobbered a healthy cache entry.'); + } + + $served = $handler->findByCompound(['id' => '7']); + $this->assertSame('active', $served->get('status')); + } + + public function testDuplicateOnADifferentRowSharingTheModelIdentityIsStillCaught(): void + { + // Model identity (id only) is a SUBSET of the table identity + // (orgId + id): a duplicate on org 2's row must not hide behind + // sharing org 1's model identity. + $handler = $this->makeHandler(['orgId', 'id'], 'test_records', true, null, null, null, [['keyHash']]); + + // Pre-read: org 1's row, resolved canonically. + $this->queryStrategy->queueQueryResult([['orgId' => '1', 'id' => '42', 'keyHash' => 'abc']]); + // Duplicate scan finds org 2's row carrying the same unique value — + // and the same MODEL identity (id 42). + $this->queryStrategy->queueQueryResult([['orgId' => '2', 'id' => '42']]); + $this->queryStrategy->queueQueryResult([['orgId' => '2', 'id' => '42', 'keyHash' => 'abc']]); + + $this->expectException(DuplicateEntryException::class); + + $handler->updateCompound(['orgId' => '1', 'id' => '42'], ['keyHash' => 'abc']); } } + + class CanonicalHandler { use WithDatastoreHandlerMethods; @@ -715,60 +811,3 @@ public function findByCompound(array $ids) return $this->findFromCompound($ids); } } - -/** - * Model whose own identity is deliberately a SUBSET of the table identity - * (like a model that omits a tenant column) — the trait must never key the - * cache off it. - */ -class AttrModel implements DataModel -{ - public function __construct(private array $row = []) - { - } - - public function get(string $field) - { - return $this->row[$field] ?? null; - } - - public function toRow(): array - { - return $this->row; - } - - public function getIdentity(): array - { - return ['id' => $this->row['id'] ?? null]; - } -} - -class ArrayModelAdapter implements ModelAdapter -{ - public function toModel(array $array): DataModel - { - return new AttrModel($array); - } - - public function toArray(DataModel $model): array - { - return $model instanceof AttrModel ? $model->toRow() : []; - } -} - -/** - * Adapter that exposes nothing — the narrow-serialization case alias - * verification must treat as unverifiable. - */ -class HidingAdapter implements ModelAdapter -{ - public function toModel(array $array): DataModel - { - return new AttrModel($array); - } - - public function toArray(DataModel $model): array - { - return []; - } -} From c014ffb33ed2f4bbce479404663e9819e3f4e582 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 04:41:50 -0400 Subject: [PATCH 11/26] fix(cache): keep list survivors when rows vanish mid-read; raw values in SQL conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-16 audit findings — second consecutive majorless round; three code-smell minors plus mechanical convergence items. - A row deleted concurrently between the id query and hydration threw RecordNotFoundException out of getModels' per-id fallback, which where()'s catch turned into an EMPTY result — discarding rows that still exist (reproduced by the reviewer; pre-existing shape, but this PR rewrote the path). Vanished rows are now skipped per-id; a new test pins the survivor being served. - Cache-key stringification no longer leaks into SQL: identity resolution returns raw caller/row values for the UPDATE's WHERE, and the cache delete canonicalizes separately via rowIdentity(). Alias- resolved identities remain the stored canonical strings (the only form available without a query) — documented, with the caller's raw business key co-conditioning the WHERE. - Builder hygiene finished: findIds resets the shared clause builder (destructive deletes select through it) and the remaining fresh builds (getModels' batch SELECT, queryRowAndModel) reset the shared query builder, completing the pattern the round-8 fix started. - Contract docs: the interface header now defers failure semantics to each method (it contradicted snapshotGeneration's failure-vs-miss distinction); bumpGeneration's @return describes the contract rather than current call-site state; log contexts carry the throwable class; the stray docblock indent passes cs-fixer. - Tests: the entry-count ternary moved into a data provider (the no-conditionals rule); the dual-read outage test split per read path; logger expectations constrained to their expected messages. 74 tests, 156 assertions, green. Co-Authored-By: Claude Fable 5 --- lib/Interfaces/RowCache.php | 10 +-- lib/Services/DatastoreRowCache.php | 31 ++++---- lib/Traits/WithDatastoreHandlerMethods.php | 52 +++++++++++--- tests/Unit/Traits/CanonicalRowCacheTest.php | 80 ++++++++++++++++++--- 4 files changed, 134 insertions(+), 39 deletions(-) diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index f7c8ddb..5aa1026 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -15,11 +15,11 @@ * contract supplies the operations it composes. * * No-throw obligation: consumers call these methods UNGUARDED on read and - * write paths alike. Implementations must treat cache-layer failures as - * misses on reads (hasRow, resolveAliasedIdentity, snapshotGeneration) and - * swallow-and-log them on mutations (storeRow, storeAlias, deleteRow, - * deleteAlias, invalidateAfterWrite) — a throwing implementation breaks - * datastore reads and deletes during a cache outage. + * write paths alike, so cache-layer failures must never escape — each + * method documents its own degradation (reads generally degrade to misses, + * mutations swallow-and-log, and snapshotGeneration distinguishes a read + * FAILURE from a miss). A throwing implementation breaks datastore reads + * and deletes during a cache outage. * * @see \PHPNomad\Database\Services\DatastoreRowCache the default implementation */ diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index a03359f..f757ad1 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -105,7 +105,7 @@ public function rowIdentity(array $row): ?array return $this->stringifyScalars($identity); } - /** @inheritDoc */ + /** @inheritDoc */ public function identityKey(array $row): ?string { $identity = $this->rowIdentity($row); @@ -183,7 +183,7 @@ public function storeRow(array $row, $model, ?string $generation = null): void } catch (Throwable $e) { $this->loggerStrategy->warning( 'Could not cache a row — the next read will hit the database.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } @@ -196,7 +196,7 @@ public function storeAlias(array $ids, array $identity, ?string $generation = nu } catch (Throwable $e) { $this->loggerStrategy->warning( 'Could not cache an alias — the next lookup will re-resolve from the database.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } @@ -209,7 +209,7 @@ public function deleteRow(array $identity, ?string $generation = null): void } catch (Throwable $e) { $this->loggerStrategy->error( 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } @@ -222,7 +222,7 @@ public function deleteAlias(array $ids, ?string $generation = null): void } catch (Throwable $e) { $this->loggerStrategy->error( 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } @@ -278,7 +278,7 @@ protected function guardedReadThrough(array $context, callable $fallback) if ($resolved) { $this->loggerStrategy->warning( 'Cache store failed after a successful load — serving the loaded value uncached.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return $value; @@ -290,7 +290,7 @@ protected function guardedReadThrough(array $context, callable $fallback) $this->loggerStrategy->warning( 'Cache read failed — loading directly from the fallback.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return $fallback(); @@ -313,7 +313,7 @@ public function hasRow(array $identityRow, ?string $generation = null): bool // to the database. $this->loggerStrategy->warning( 'Row cache probe failed — treating the row as uncached.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return false; @@ -390,7 +390,7 @@ public function invalidateAfterWrite(): ?string } catch (Throwable $e) { $this->loggerStrategy->error( 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return null; @@ -410,7 +410,7 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): // uncached instead of breaking the lookup. $this->loggerStrategy->warning( 'Alias cache read failed — treating the alias as missing.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return null; @@ -452,9 +452,10 @@ public function snapshotGeneration(): ?string * Replaces the table's generation token. Called after every successful * write. A no-op when generations are disabled for this table. * - * @return string|null The freshly minted token (currently unconsumed — - * available to implementations that add post-write - * cache writes); null when generations are disabled. + * @return string|null The freshly minted token — propagated through + * invalidateAfterWrite() for implementations that add + * post-write cache writes; null when generations are + * disabled. */ protected function bumpGeneration(): ?string { @@ -498,7 +499,7 @@ protected function currentGeneration(): string // a healthy token and wholesale-invalidate the table cache. $this->loggerStrategy->warning( 'Generation token read failed — using an ephemeral token for this operation.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); return $this->mintGeneration(); @@ -515,7 +516,7 @@ protected function currentGeneration(): string // caching degrades to disabled instead of breaking reads. $this->loggerStrategy->warning( 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index e0a9148..f3b5057 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -265,7 +265,7 @@ public function deleteWhere(array $conditions): void } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->error( 'A RecordDeleted listener failed; remaining deletion events still fire.', - ['table' => $this->table->getName(), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); } } @@ -403,6 +403,8 @@ protected function shouldUseTableGenerations(): bool */ public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array { + $this->serviceProvider->clauseBuilder->reset()->useTable($this->table); + $this->serviceProvider->queryBuilder ->reset() ->from($this->table) @@ -448,6 +450,7 @@ protected function getModels(array $ids): array // Get the things that aren't in the cache. $data = $this->serviceProvider->queryStrategy->query( $this->serviceProvider->queryBuilder + ->reset() ->from($this->table) ->select('*') ->where($clauseBuilder->andWhere($this->table->getFieldsForIdentity(), 'IN', ...$idsToQuery)) @@ -473,15 +476,29 @@ protected function getModels(array $ids): array // Return the rows in the requested order: just-hydrated models are // served directly; only ids skipped as already-cached consult the // cache (falling through to the database on a miss). - return Arr::map($ids, function (array $id) use ($hydrated, $generation) { + $models = []; + + foreach ($ids as $id) { $key = $this->rowCache()->identityKey($id); if ($key !== null && array_key_exists($key, $hydrated)) { - return $hydrated[$key]; + $models[] = $hydrated[$key]; + + continue; } - return $this->findFromCompound($id, $generation); - }); + try { + $models[] = $this->findFromCompound($id, $generation); + } catch (RecordNotFoundException $e) { + // The row vanished between the id query and hydration (a + // concurrent delete). Skip it — letting this escape would + // collapse the WHOLE result to [] in where()'s catch, + // discarding rows that still exist. + continue; + } + } + + return $models; } /** @@ -567,6 +584,7 @@ protected function queryRowAndModel(array $ids): array $items = $this->serviceProvider->queryStrategy->query( $this->serviceProvider->queryBuilder + ->reset() ->select('*') ->from($this->table) ->where($clauseBuilder) @@ -616,7 +634,7 @@ public function updateCompound($ids, array $attributes): void // (falling back to model identity only when an adapter cannot expose // one): model identities can be shared by distinct rows, and a true // duplicate must not hide behind one. - $this->maybeThrowForDuplicateUniqueFields($attributes, $identity, $record->getIdentity()); + $this->maybeThrowForDuplicateUniqueFields($attributes, $this->rowCache()->rowIdentity($identity), $record->getIdentity()); // The SQL update targets the RESOLVED table identity AND the // caller's own lookup fields: the identity pins exactly one row (no @@ -634,7 +652,11 @@ public function updateCompound($ids, array $attributes): void // the pre-write generation — the one readers wrote their entries // with. The bump closes the cache-aside race; precise deletes carry // tables that opt out of generations. - $this->rowCache()->deleteRow($identity, $generation); + $canonicalIdentity = $this->rowCache()->rowIdentity($identity); + + if ($canonicalIdentity !== null) { + $this->rowCache()->deleteRow($canonicalIdentity, $generation); + } if (!$this->rowCache()->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's @@ -665,13 +687,23 @@ public function updateCompound($ids, array $attributes): void */ protected function resolveTableIdentity(array $ids, ?string $generation = null): ?array { + $identityFields = array_flip($this->table->getFieldsForIdentity()); + + // Raw caller/row values are preferred: this identity feeds the SQL + // WHERE, and cache-key stringification is cache vocabulary that + // should not leak into driver-typed comparisons. (The cache delete + // canonicalizes separately via rowIdentity().) if ($this->rowCache()->isTableIdentity($ids)) { - return $this->rowCache()->rowIdentity($ids); + return array_intersect_key($ids, $identityFields); } $aliased = $this->rowCache()->resolveAliasedIdentity($ids, $generation); if ($aliased !== null) { + // Alias entries store the canonical (stringified) identity — the + // only form available without a query. The merged WHERE keeps + // the caller's raw business key alongside it, and SQL drivers + // coerce numeric strings. return $aliased; } @@ -683,7 +715,9 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): try { [$row] = $this->queryRowAndModel($ids); - return $this->rowCache()->rowIdentity($row); + $identity = array_intersect_key($row, $identityFields); + + return count($identity) === count($identityFields) ? $identity : null; } catch (RecordNotFoundException $e) { return null; } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 3271300..277e7c4 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -141,9 +141,9 @@ public function generationModes(): array } /** - * @dataProvider generationModes + * @dataProvider generationModesWithRowEntryCounts */ - public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useGenerations): void + public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useGenerations, int $expectedEntries): void { $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations); @@ -168,7 +168,7 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG $this->cacheableService->exists(['type' => IdentityRowModel::class, 'identities' => ['id' => '42']]), 'Row leaked a cache entry keyed by the MODEL identity.' ); - $this->assertCount($useGenerations ? 2 : 1, $this->cacheStrategy->store, 'Unexpected cache entries beyond the row (and generation token).'); + $this->assertCount($expectedEntries, $this->cacheStrategy->store, 'Unexpected cache entries beyond the row (and generation token).'); // A second identical read: findIds queries again (list SQL is not // cached), but the row itself is served from cache — no SELECT *. @@ -388,6 +388,19 @@ public function generationModesWithExpectedEntries(): array ]; } + /** + * One cached row plus the generation-token entry the ON mode keeps. + * + * @return array + */ + public function generationModesWithRowEntryCounts(): array + { + return [ + 'generations on (production default)' => [true, 2], + 'generations off (opt-out)' => [false, 1], + ]; + } + /** * @dataProvider generationModesWithExpectedEntries */ @@ -561,7 +574,9 @@ public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerati // rows can't be verified. On a generation-disabled table the alias // must not be trusted: every lookup re-resolves from the database. $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('warning'); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('could not be verified')); $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new HidingModelAdapter()); @@ -690,14 +705,20 @@ public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); } - public function testReadsSurviveACacheThatFailsOnReads(): void + public function testBusinessKeyReadSurvivesACacheThatFailsOnReads(): void { - // Probe/read failures must degrade to database loads — never break - // the read. Covers readRow (canonical), the alias read, and the - // generation-token read in one lookup flow. + // Alias-read and token-read failures must degrade to database + // loads — never break the lookup. $flaky = $this->useFlakyCache(); $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('warning'); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->logicalOr( + $this->stringContains('read failed'), + $this->stringContains('store failed'), + $this->stringContains('Could not cache'), + $this->stringContains('Could not persist') + )); $handler = $this->makeHandler(['id'], 'test_records', true, $logger); @@ -706,10 +727,30 @@ public function testReadsSurviveACacheThatFailsOnReads(): void $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $byKey = $handler->findByCompound(['keyHash' => 'abc']); + $this->assertSame('7', $byKey->get('id')); + } + + public function testCanonicalReadSurvivesACacheThatFailsOnReads(): void + { + // A broken read-through probe must load directly from the database. + $flaky = $this->useFlakyCache(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->logicalOr( + $this->stringContains('read failed'), + $this->stringContains('store failed'), + $this->stringContains('Could not cache'), + $this->stringContains('Could not persist') + )); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger); + + $flaky->failReads = true; + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $byId = $handler->findByCompound(['id' => '7']); - $this->assertSame('7', $byKey->get('id')); $this->assertSame('active', $byId->get('status')); } @@ -775,6 +816,25 @@ public function testDuplicateOnADifferentRowSharingTheModelIdentityIsStillCaught $handler->updateCompound(['orgId' => '1', 'id' => '42'], ['keyHash' => 'abc']); } + public function testConcurrentlyDeletedRowDoesNotCollapseTheListResult(): void + { + // A row deleted between the id query and hydration must be skipped — + // not allowed to throw RecordNotFoundException into where()'s catch, + // which would discard rows that still exist. + $handler = $this->makeHandler(['id'], 'test_records', true); + + // findIds sees two rows; the batch SELECT only finds one (row 2 + // vanished); the per-id fallback for row 2 also finds nothing. + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->queueQueryResult([['id' => '1', 'name' => 'survivor']]); + $this->queryStrategy->queueQueryResult([]); + + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); + + $this->assertCount(1, $models, 'A concurrently deleted row collapsed the whole result.'); + $this->assertSame('survivor', $models[0]->get('name')); + } + } From c62f69ce30463071871869dccc2c768e99e56fdf Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 05:17:33 -0400 Subject: [PATCH 12/26] test(cache): prove deleteWhere's failure guarantees; skip write-backs under ephemeral tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-18 audit findings — five passes (stack elevator fully clean), zero majors for the third consecutive round. - deleteWhere's documented guarantees now have proof: a mid-loop SQL delete failure still lands the post-write invalidation and still announces completed rows (throw-on-nth-delete double), and a throwing RecordDeleted listener is isolated per-event without suppressing siblings (throw-once event double). create()/updateCompound() broadcasts document that single-record events intentionally keep the pre-PR propagate contract — there are no siblings to protect. - Generation read-blips mint PREFIXED ephemeral tokens and storeRow/ storeAlias skip write-backs under them: entries keyed under a token nobody can ever read are garbage written at read-traffic rate during a sustained blip. - findIds() rides initiateQuery() (whose select already defaults to the identity fields), and the clause-builder reset folds into initiateQuery() — one bootstrap for every query path instead of a drift-prone copy. - composer declares php >= 8.0 (locale-independent float casting in cache keys; the suite already used 8.0 syntax). Docs: the duplicate- filter signature repurposing joins the Breaking list; the ExposedRowCache rationale is stated inline; resolveAliasedIdentity documents its read-failure null; bumpGeneration's timing wording matches deleteWhere's partial-failure reality; the read-outage tests collapse into one provider-driven test. 76 tests, 161 assertions, green. Co-Authored-By: Claude Fable 5 --- composer.json | 1 + composer.lock | 6 +- lib/Interfaces/RowCache.php | 3 +- lib/Services/DatastoreRowCache.php | 35 +++++++- lib/Traits/WithDatastoreHandlerMethods.php | 28 +++---- tests/Doubles/ExposedRowCache.php | 10 +-- tests/Doubles/ScriptedQueryStrategy.php | 10 +++ tests/Doubles/ThrowingOnceEventStrategy.php | 26 ++++++ tests/Unit/Traits/CanonicalRowCacheTest.php | 90 +++++++++++++++------ 9 files changed, 153 insertions(+), 56 deletions(-) create mode 100644 tests/Doubles/ThrowingOnceEventStrategy.php diff --git a/composer.json b/composer.json index 5f4b9a8..fa9f308 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ } ], "require": { + "php": ">=8.0", "phpnomad/utils": "^1.0", "phpnomad/cache": "^1.0", "phpnomad/chrono": "^1.0", diff --git a/composer.lock b/composer.lock index 2e10cd9..2d48302 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e3f52915fd10478ce9e56c205ed6d38e", + "content-hash": "c58a27037c0a810f516bc4e820498220", "packages": [ { "name": "phpnomad/cache", @@ -4949,7 +4949,9 @@ "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": {}, + "platform": { + "php": ">=8.0" + }, "platform-dev": {}, "plugin-api-version": "2.9.0" } diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index 5aa1026..b637241 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -55,7 +55,8 @@ public function identityKey(array $row): ?string; /** * Reads the identity an alias entry points at, validated against the - * table's identity shape. Null on miss or malformed value. + * table's identity shape. Null on miss, malformed value, or a + * cache-layer read failure (logged). * * @param array $ids * @param string|null $generation Pre-query generation snapshot. diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index f757ad1..31faa8e 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -172,6 +172,10 @@ protected function tableContext(?string $generation = null): array /** @inheritDoc */ public function storeRow(array $row, $model, ?string $generation = null): void { + if ($this->isEphemeralGeneration($generation)) { + return; + } + $context = $this->rowContext($row, $generation); if ($context === null) { @@ -191,6 +195,10 @@ public function storeRow(array $row, $model, ?string $generation = null): void /** @inheritDoc */ public function storeAlias(array $ids, array $identity, ?string $generation = null): void { + if ($this->isEphemeralGeneration($generation)) { + return; + } + try { $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); } catch (Throwable $e) { @@ -449,8 +457,9 @@ public function snapshotGeneration(): ?string } /** - * Replaces the table's generation token. Called after every successful - * write. A no-op when generations are disabled for this table. + * Replaces the table's generation token. Called after every write that + * committed at least one row. A no-op when generations are disabled for + * this table. * * @return string|null The freshly minted token — propagated through * invalidateAfterWrite() for implementations that add @@ -502,7 +511,7 @@ protected function currentGeneration(): string ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); - return $this->mintGeneration(); + return $this->mintEphemeralGeneration(); } if (!is_string($token) || $token === '') { @@ -541,6 +550,26 @@ protected function mintGeneration(): string return bin2hex(random_bytes(8)); } + /** + * Mints a token for a single operation during a token-read outage. The + * prefix marks it so write-backs can skip: entries keyed under a token + * nobody else can ever read are pure garbage written at read-traffic + * rate. + */ + protected function mintEphemeralGeneration(): string + { + return 'ephemeral-' . bin2hex(random_bytes(8)); + } + + /** + * True when the snapshot was minted during a token-read outage and no + * cache entry keyed under it can ever be served. + */ + protected function isEphemeralGeneration(?string $generation): bool + { + return $generation !== null && strpos($generation, 'ephemeral-') === 0; + } + /** * Normalizes scalar values to strings — the single normalization rule * every cache key shape shares, so an int identity from a hydrated write diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index f3b5057..52eeaf0 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -171,6 +171,9 @@ public function create(array $attributes): DataModel // and is always correct. $this->rowCache()->invalidateAfterWrite(); + // Single-record broadcasts intentionally PROPAGATE listener + // exceptions (the pre-PR contract): there are no sibling events to + // protect, unlike deleteWhere's buffered bulk emission. $this->serviceProvider->eventStrategy->broadcast(new RecordCreated($result)); return $result; @@ -277,7 +280,7 @@ public function deleteWhere(array $conditions): void */ protected function initiateQuery(?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC', array $select = null) { - $this->serviceProvider->clauseBuilder->useTable($this->table); + $this->serviceProvider->clauseBuilder->reset()->useTable($this->table); $select = $select === null ? $this->table->getFieldsForIdentity() : $select; @@ -403,23 +406,9 @@ protected function shouldUseTableGenerations(): bool */ public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array { - $this->serviceProvider->clauseBuilder->reset()->useTable($this->table); - - $this->serviceProvider->queryBuilder - ->reset() - ->from($this->table) - ->select(...$this->table->getFieldsForIdentity()); - - - if ($limit) { - $this->serviceProvider->queryBuilder->limit($limit); - } - - if ($offset) { - $this->serviceProvider->queryBuilder->offset($offset); - } - - $this->buildConditions($conditions); + // Same bootstrap as where(): initiateQuery()'s select defaults to + // the identity fields, which is exactly this method's projection. + $this->initiateQuery($limit, $offset)->buildConditions($conditions); return $this->serviceProvider->queryStrategy->query($this->serviceProvider->queryBuilder); } @@ -668,7 +657,8 @@ public function updateCompound($ids, array $attributes): void // The event intentionally carries the caller's key — the lookup // contract they wrote against — not the cache-normalized identity - // the SQL targeted. + // the SQL targeted. Like create(), a single-record broadcast + // propagates listener exceptions; only bulk emission isolates them. $this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($this->model, $ids, $attributes)); } diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php index 5ad482d..4c4e03c 100644 --- a/tests/Doubles/ExposedRowCache.php +++ b/tests/Doubles/ExposedRowCache.php @@ -10,11 +10,11 @@ * poisoning, and manual invalidation) without widening the production * contract. * - * Tests normally avoid extending lib/ concretes (the ANTI-PATTERNS KB entry - * flags inheritance from package classes); this subclass is the deliberate - * exception, because the alternative is putting key plumbing back on the - * RowCache interface, which the production contract intentionally narrowed - * away. + * Tests normally avoid extending lib/ concretes — inheriting from package + * classes couples tests to internals and is the shape the coding standards + * steer away from. This subclass is the deliberate exception, because the + * alternative is putting key plumbing back on the RowCache interface, which + * the production contract intentionally narrowed away. */ class ExposedRowCache extends DatastoreRowCache { diff --git a/tests/Doubles/ScriptedQueryStrategy.php b/tests/Doubles/ScriptedQueryStrategy.php index 7bacf94..1028691 100644 --- a/tests/Doubles/ScriptedQueryStrategy.php +++ b/tests/Doubles/ScriptedQueryStrategy.php @@ -44,8 +44,18 @@ public function insert(Table $table, array $data): array return ['id' => 1]; } + /** @var int|null 1-indexed delete call that should throw. */ + public ?int $throwOnDeleteCall = null; + private int $deleteCalls = 0; + public function delete(Table $table, array $ids): void { + $this->deleteCalls++; + + if ($this->throwOnDeleteCall !== null && $this->deleteCalls === $this->throwOnDeleteCall) { + throw new LogicException('Simulated SQL delete failure on call ' . $this->deleteCalls . '.'); + } + $this->deletes[] = $ids; } diff --git a/tests/Doubles/ThrowingOnceEventStrategy.php b/tests/Doubles/ThrowingOnceEventStrategy.php new file mode 100644 index 0000000..a65a431 --- /dev/null +++ b/tests/Doubles/ThrowingOnceEventStrategy.php @@ -0,0 +1,26 @@ +thrown) { + $this->thrown = true; + + throw new RuntimeException('Simulated listener failure.'); + } + } +} diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 277e7c4..5a1a1a3 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -19,6 +19,7 @@ use PHPNomad\Database\Tests\Doubles\RecordingEventStrategy; use PHPNomad\Database\Tests\Doubles\ScriptedQueryStrategy; use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; +use PHPNomad\Database\Tests\Doubles\ThrowingOnceEventStrategy; use PHPNomad\Database\Tests\TestCase; use PHPNomad\Database\Traits\WithDatastoreHandlerMethods; use PHPNomad\Datastore\Events\RecordCreated; @@ -705,34 +706,25 @@ public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['keyHash' => 'abc', 'status' => 'revoked']], $this->queryStrategy->updates[0]); } - public function testBusinessKeyReadSurvivesACacheThatFailsOnReads(): void + /** + * @return array + */ + public function readOutageLookups(): array { - // Alias-read and token-read failures must degrade to database - // loads — never break the lookup. - $flaky = $this->useFlakyCache(); - $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce()) - ->method('warning') - ->with($this->logicalOr( - $this->stringContains('read failed'), - $this->stringContains('store failed'), - $this->stringContains('Could not cache'), - $this->stringContains('Could not persist') - )); - - $handler = $this->makeHandler(['id'], 'test_records', true, $logger); - - $flaky->failReads = true; - - $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); - $byKey = $handler->findByCompound(['keyHash' => 'abc']); - - $this->assertSame('7', $byKey->get('id')); + return [ + 'business-key lookup (alias + token reads)' => [['keyHash' => 'abc'], 'id', '7'], + 'canonical lookup (read-through probe)' => [['id' => '7'], 'status', 'active'], + ]; } - public function testCanonicalReadSurvivesACacheThatFailsOnReads(): void + /** + * Probe/read failures must degrade to database loads — never break the + * lookup, whatever its shape. + * + * @dataProvider readOutageLookups + */ + public function testReadsSurviveACacheThatFailsOnReads(array $lookup, string $field, string $expected): void { - // A broken read-through probe must load directly from the database. $flaky = $this->useFlakyCache(); $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce()) @@ -749,9 +741,9 @@ public function testCanonicalReadSurvivesACacheThatFailsOnReads(): void $flaky->failReads = true; $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); - $byId = $handler->findByCompound(['id' => '7']); + $model = $handler->findByCompound($lookup); - $this->assertSame('active', $byId->get('status')); + $this->assertSame($expected, $model->get($field)); } public function testEstimatedCountSurvivesACacheThatFailsOnReads(): void @@ -835,6 +827,52 @@ public function testConcurrentlyDeletedRowDoesNotCollapseTheListResult(): void $this->assertSame('survivor', $models[0]->get('name')); } + public function testMidLoopSqlFailureStillInvalidatesAndAnnouncesCompletedDeletes(): void + { + // Row 1 deletes and must still bump the generation and broadcast, + // even though row 2's SQL delete throws. + $events = new RecordingEventStrategy(); + $handler = $this->makeHandler(['id'], 'test_records', true, null, $events); + + // Prime a token + a cached row so invalidation is observable. + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'doomed']]); + $handler->findByCompound(['id' => '1']); + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + $this->queryStrategy->throwOnDeleteCall = 2; + + try { + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + $this->fail('Expected the mid-loop SQL failure to propagate.'); + } catch (\LogicException $e) { + $this->assertSame([['id' => '1']], $this->queryStrategy->deletes, 'Row 1 was not deleted before the failure.'); + $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A completed delete was not announced after a mid-loop failure.'); + $this->assertFalse( + $this->cacheableService->exists($this->prober->exposeRowContext(['id' => '1'])), + 'The completed delete\'s row entry survived the failure.' + ); + } + } + + public function testThrowingListenerDoesNotSuppressRemainingDeleteBroadcasts(): void + { + // The FIRST RecordDeleted listener throws; the second row's event + // must still fire (and the failure is logged, not propagated). + $events = new ThrowingOnceEventStrategy(); + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('error') + ->with($this->stringContains('listener failed')); + + $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + + $this->queryStrategy->queueQueryResult([['id' => '1'], ['id' => '2']]); + + $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); + + $this->assertCount(2, $events->ofType(RecordDeleted::class), 'A throwing listener suppressed a sibling RecordDeleted.'); + } + } From a45116251760b87d2cd9446240b032870b624cdc Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 05:55:04 -0400 Subject: [PATCH 13/26] fix(ci): static data providers for PHPUnit 10; level-9-clean new files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-20 repo-consistency audit caught what every earlier round missed by trusting the local vendor PHPUnit: the repo's phpunit CI action runs PHPUnit >= 10, which requires STATIC data providers, so the branch's CI was red with 12 provider errors while 9.6 passed locally. All seven providers are static now (9.6 accepts both). The four new lib/ files also analyze clean at the repo's declared PHPStan level 9: ModelAdapter generics specified, context arrays carry value types, and the empty-identity guards are gone — the Table contract declares getFieldsForIdentity() non-empty-array, so the case was contractually impossible (its contract-violating mock test goes with it). From the rest of round 20 (five passes; every fix applied): - maybeThrowForDuplicateUniqueFields is RENAMED to maybeThrowForDuplicateUniqueFieldsExcluding: its second parameter had changed meaning, and a silent signature repurposing should fail loudly at stale call sites instead of misfiltering quietly. - Ephemeral generation snapshots now skip caching ENTIRELY: readRow/ readTableValue go straight to their fallback (no guaranteed-miss round trips, no unreachable miss-path stores) — the docs/code-smells lenses proved the prior claim only covered storeRow/storeAlias. Pinned by a service-level test. - The four mutation signatures require an explicit generation snapshot (no null default): the race-fence invariant is now enforced by the signature, not just the docblock. - matchesLookup's contract documents that adapter exceptions are domain errors and propagate; deleteRow's param vocabulary aligned; the last unconstrained logger expectation pinned; a dead failWrites reset removed; entry-count providers renamed to say what they count. 76 tests, 162 assertions, green locally. Co-Authored-By: Claude Fable 5 --- lib/Factories/DatastoreRowCacheFactory.php | 7 ++- lib/Interfaces/RowCache.php | 31 +++++++---- lib/Interfaces/RowCacheFactory.php | 2 +- lib/Services/DatastoreRowCache.php | 53 +++++++++++++------ lib/Traits/WithDatastoreHandlerMethods.php | 13 +++-- tests/Unit/Services/DatastoreRowCacheTest.php | 32 ++++++----- tests/Unit/Traits/CanonicalRowCacheTest.php | 17 +++--- 7 files changed, 102 insertions(+), 53 deletions(-) diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php index 41a864f..ee00fd7 100644 --- a/lib/Factories/DatastoreRowCacheFactory.php +++ b/lib/Factories/DatastoreRowCacheFactory.php @@ -7,6 +7,7 @@ use PHPNomad\Database\Interfaces\RowCacheFactory; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Services\DatastoreRowCache; +use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; use PHPNomad\Logger\Interfaces\LoggerStrategy; @@ -27,7 +28,11 @@ public function __construct(CacheableService $cacheableService, LoggerStrategy $ $this->loggerStrategy = $loggerStrategy; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param ModelAdapter $modelAdapter + */ public function make(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true): RowCache { return new DatastoreRowCache($this->cacheableService, $this->loggerStrategy, $table, $model, $modelAdapter, $useGenerations); diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php index b637241..d83910d 100644 --- a/lib/Interfaces/RowCache.php +++ b/lib/Interfaces/RowCache.php @@ -35,8 +35,7 @@ public function isTableIdentity(array $ids): bool; /** * Extracts the canonical identity from row data. Null (logged) when the - * row is missing an identity field, or (not logged) when the table - * declares no identity fields at all. + * row is missing an identity field. * * @param array $row * @return array|null Scalars stringified, table order. @@ -73,7 +72,8 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): * fallback's value when only the post-load store failed, and load * directly when the probe itself broke. Exceptions thrown BY the * fallback are domain errors (RecordNotFoundException) and must - * propagate untouched. + * propagate untouched. Under an ephemeral generation snapshot the cache + * is skipped entirely and the fallback serves the read. * * @param array $identity Canonical identity (from rowIdentity()). * @param string|null $generation Pre-query generation snapshot. @@ -109,7 +109,9 @@ public function readTableValue(callable $fallback); * ANY lookup field the adapter cannot expose makes the lookup * unverifiable and the alias is treated as stale, because this check is * their ONLY rotation defense; generation-enabled tables skip - * unverifiable fields (the bump covers rotation). + * unverifiable fields (the bump covers rotation). Exceptions thrown by + * the model adapter are domain errors and propagate (the same carve-out + * readRow() makes for its fallback). * * @param DataModel $model * @param array $ids The caller's lookup key. @@ -136,28 +138,37 @@ public function invalidateAfterWrite(): ?string; * write's bump, landing a stale row under the new generation — the exact * race generations exist to close. * + * Stores MUST no-op under an ephemeral generation snapshot (minted + * during a token-read outage): such entries can never be read back. + * * @param array $row * @param mixed $model - * @param string|null $generation Pre-query generation snapshot. + * @param string|null $generation Pre-query generation snapshot — REQUIRED + * (null only for generation-disabled tables): a token + * fetched at store time can postdate a concurrent + * write's bump, so the signature forces the caller to + * thread the snapshot it queried under. */ - public function storeRow(array $row, $model, ?string $generation = null): void; + public function storeRow(array $row, $model, ?string $generation): void; /** * Stores an alias entry pointing a business key at a canonical identity. * + * Stores MUST no-op under an ephemeral generation snapshot. + * * @param array $ids * @param array $identity * @param string|null $generation Pre-query generation snapshot. */ - public function storeAlias(array $ids, array $identity, ?string $generation = null): void; + public function storeAlias(array $ids, array $identity, ?string $generation): void; /** * Deletes the row entry for a canonical identity. * * @param array $identity - * @param string|null $generation The generation readers wrote under. + * @param string|null $generation Pre-query generation snapshot. */ - public function deleteRow(array $identity, ?string $generation = null): void; + public function deleteRow(array $identity, ?string $generation): void; /** * Deletes an alias entry. @@ -165,7 +176,7 @@ public function deleteRow(array $identity, ?string $generation = null): void; * @param array $ids * @param string|null $generation Pre-query generation snapshot. */ - public function deleteAlias(array $ids, ?string $generation = null): void; + public function deleteAlias(array $ids, ?string $generation): void; /** * Takes the generation snapshot an operation should key its contexts diff --git a/lib/Interfaces/RowCacheFactory.php b/lib/Interfaces/RowCacheFactory.php index 597a3fa..dfddb0c 100644 --- a/lib/Interfaces/RowCacheFactory.php +++ b/lib/Interfaces/RowCacheFactory.php @@ -17,7 +17,7 @@ interface RowCacheFactory /** * @param Table $table The table whose contexts the RowCache will build. * @param class-string $model The model class cache contexts are typed under. - * @param ModelAdapter $modelAdapter Used to verify alias-resolved rows against lookup values. + * @param ModelAdapter $modelAdapter Used to verify alias-resolved rows against lookup values. * @param bool $useGenerations Whether contexts carry a per-table generation * token. On: writes orphan all prior contexts at * once, closing the cache-aside write-back race. diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php index 31faa8e..416ca90 100644 --- a/lib/Services/DatastoreRowCache.php +++ b/lib/Services/DatastoreRowCache.php @@ -35,11 +35,15 @@ class DatastoreRowCache implements RowCache * @var class-string */ protected string $model; + /** + * @var ModelAdapter + */ protected ModelAdapter $modelAdapter; protected bool $useGenerations; /** * @param class-string $model + * @param ModelAdapter $modelAdapter */ public function __construct( CacheableService $cacheableService, @@ -60,10 +64,10 @@ public function __construct( /** @inheritDoc */ public function isTableIdentity(array $ids): bool { + // Table::getFieldsForIdentity() is contractually non-empty. $identityFields = $this->table->getFieldsForIdentity(); - return !empty($identityFields) - && count($ids) === count($identityFields) + return count($ids) === count($identityFields) && !array_diff(array_keys($ids), $identityFields); } @@ -83,10 +87,6 @@ public function rowIdentity(array $row): ?array { $identityFields = $this->table->getFieldsForIdentity(); - if (empty($identityFields)) { - return null; - } - $identity = []; foreach ($identityFields as $field) { @@ -119,7 +119,7 @@ public function identityKey(array $row): ?string * * @param array $row * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - * @return array|null Null when the row cannot produce a full identity. + * @return array|null Null when the row cannot produce a full identity. */ protected function rowContext(array $row, ?string $generation = null): ?array { @@ -134,6 +134,7 @@ protected function rowContext(array $row, ?string $generation = null): ?array * * @param array $identity * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + * @return array */ protected function identityContext(array $identity, ?string $generation = null): array { @@ -149,6 +150,7 @@ protected function identityContext(array $identity, ?string $generation = null): * * @param array $ids The caller's lookup key. * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. + * @return array */ protected function aliasContext(array $ids, ?string $generation = null): array { @@ -163,6 +165,7 @@ protected function aliasContext(array $ids, ?string $generation = null): array * The set-level context — one undiscriminated slot per table. * * @param string|null $generation Generation snapshot; taken fresh when omitted. + * @return array */ protected function tableContext(?string $generation = null): array { @@ -170,7 +173,7 @@ protected function tableContext(?string $generation = null): array } /** @inheritDoc */ - public function storeRow(array $row, $model, ?string $generation = null): void + public function storeRow(array $row, $model, ?string $generation): void { if ($this->isEphemeralGeneration($generation)) { return; @@ -193,7 +196,7 @@ public function storeRow(array $row, $model, ?string $generation = null): void } /** @inheritDoc */ - public function storeAlias(array $ids, array $identity, ?string $generation = null): void + public function storeAlias(array $ids, array $identity, ?string $generation): void { if ($this->isEphemeralGeneration($generation)) { return; @@ -210,7 +213,7 @@ public function storeAlias(array $ids, array $identity, ?string $generation = nu } /** @inheritDoc */ - public function deleteRow(array $identity, ?string $generation = null): void + public function deleteRow(array $identity, ?string $generation): void { try { $this->cacheableService->delete($this->identityContext($identity, $generation)); @@ -223,7 +226,7 @@ public function deleteRow(array $identity, ?string $generation = null): void } /** @inheritDoc */ - public function deleteAlias(array $ids, ?string $generation = null): void + public function deleteAlias(array $ids, ?string $generation): void { try { $this->cacheableService->delete($this->aliasContext($ids, $generation)); @@ -247,6 +250,13 @@ protected function deleteTableContext(): void /** @inheritDoc */ public function readRow(array $identity, ?string $generation, callable $fallback) { + if ($this->isEphemeralGeneration($generation)) { + // No key under an ephemeral token can ever be read back: skip + // the cache entirely — no guaranteed-miss round trip, and no + // unreachable garbage from the miss-path store. + return $fallback(); + } + return $this->guardedReadThrough($this->identityContext($identity, $generation), $fallback); } @@ -262,7 +272,7 @@ public function readRow(array $identity, ?string $generation, callable $fallback * - the cache probe threw before the fallback ran → load directly from * the fallback. * - * @param array $context + * @param array $context * @param callable $fallback * @return mixed */ @@ -337,7 +347,13 @@ public function hasRow(array $identityRow, ?string $generation = null): bool */ public function readTableValue(callable $fallback) { - return $this->guardedReadThrough($this->tableContext(), $fallback); + $generation = $this->snapshotGeneration(); + + if ($this->isEphemeralGeneration($generation)) { + return $fallback(); + } + + return $this->guardedReadThrough($this->tableContext($generation), $fallback); } /** @@ -438,6 +454,8 @@ public function resolveAliasedIdentity(array $ids, ?string $generation = null): * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. * * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin + * @param array $context + * @return array */ protected function withGeneration(array $context, ?string $generation = null): array { @@ -536,6 +554,8 @@ protected function currentGeneration(): string /** * The context the generation token itself lives under. Never carries a * generation — it IS the generation. + * + * @return array */ protected function generationContext(): array { @@ -552,9 +572,10 @@ protected function mintGeneration(): string /** * Mints a token for a single operation during a token-read outage. The - * prefix marks it so write-backs can skip: entries keyed under a token - * nobody else can ever read are pure garbage written at read-traffic - * rate. + * prefix marks it so every caching path skips: stores no-op and + * read-throughs go straight to their fallback, because entries keyed + * under a token nobody can ever read back are pure garbage written at + * read-traffic rate. */ protected function mintEphemeralGeneration(): string { diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 52eeaf0..fe79ac9 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -147,7 +147,7 @@ public function create(array $attributes): DataModel $this->maybeThrowForDuplicateIdentity($attributes, $fields); } - $this->maybeThrowForDuplicateUniqueFields($attributes); + $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes); // Apply PHP-side defaults so the values that land in the DB also land // in the in-memory model we hand back. This eliminates the post-insert @@ -623,7 +623,7 @@ public function updateCompound($ids, array $attributes): void // (falling back to model identity only when an adapter cannot expose // one): model identities can be shared by distinct rows, and a true // duplicate must not hide behind one. - $this->maybeThrowForDuplicateUniqueFields($attributes, $this->rowCache()->rowIdentity($identity), $record->getIdentity()); + $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes, $this->rowCache()->rowIdentity($identity), $record->getIdentity()); // The SQL update targets the RESOLVED table identity AND the // caller's own lookup fields: the identity pins exactly one row (no @@ -805,8 +805,11 @@ protected function getDuplicates(array $data): array /** - * Guards unique-column groups. When updating, the record being updated - * is filtered out of the duplicate scan by TABLE identity — model + * Guards unique-column groups. (Renamed from + * maybeThrowForDuplicateUniqueFields when its second parameter changed + * meaning, so stale call sites fail loudly instead of silently filtering + * self-matches by the wrong identity.) When updating, the record being + * updated is filtered out of the duplicate scan by TABLE identity — model * identities can be a subset of the table's and therefore shared across * distinct rows, so a model-identity self-match could hide a true * duplicate. The model-identity comparison is only the fallback for @@ -821,7 +824,7 @@ protected function getDuplicates(array $data): array * @throws DuplicateEntryException * @throws DatastoreErrorException */ - protected function maybeThrowForDuplicateUniqueFields(array $data, ?array $updateTableIdentity = null, ?array $updateModelIdentity = null): void + protected function maybeThrowForDuplicateUniqueFieldsExcluding(array $data, ?array $updateTableIdentity = null, ?array $updateModelIdentity = null): void { try { $duplicates = $this->getDuplicates($data); diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php index 364e2cb..51063e5 100644 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -94,18 +94,10 @@ public function testRowIdentityMissingFieldReturnsNullAndWarns(): void $this->assertNull($rowCache->rowIdentity(['id' => 42])); } - - public function testRowIdentityWithNoIdentityFieldsReturnsNull(): void - { - $rowCache = $this->makeRowCache([]); - - $this->assertNull($rowCache->rowIdentity(['id' => 42])); - } - /** * @return array */ - public function identityShapes(): array + public static function identityShapes(): array { return [ 'exact identity' => [['orgId' => '1', 'id' => '42'], true], @@ -143,7 +135,7 @@ public function testResolveAliasedIdentityReturnsStoredIdentity(): void { $rowCache = $this->makeRowCache(['id']); - $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7']); + $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7'], null); $this->assertSame(['id' => '7'], $rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); } @@ -151,7 +143,7 @@ public function testResolveAliasedIdentityReturnsStoredIdentity(): void /** * @return array */ - public function lookupComparisons(): array + public static function lookupComparisons(): array { return [ 'same-type match' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'abc'], true], @@ -198,7 +190,7 @@ public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void /** * @return array */ - public function generationModes(): array + public static function generationModes(): array { return [ 'generations on' => [true], @@ -242,5 +234,21 @@ public function testInvalidateAfterWriteReturnsAFreshTokenWhenHealthy(): void $this->assertNotNull($rowCache->invalidateAfterWrite()); } + public function testEphemeralSnapshotSkipsCachingEntirely(): void + { + // Nothing keyed under an ephemeral token can ever be read back, so + // stores must no-op and read-throughs must go straight to their + // fallback without touching the cache. + $rowCache = $this->makeRowCache(['id'], true); + + $rowCache->storeRow(['id' => '7', 'status' => 'active'], new IdentityRowModel(['id' => '7']), 'ephemeral-abc'); + $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7'], 'ephemeral-abc'); + + $served = $rowCache->readRow(['id' => '7'], 'ephemeral-abc', fn () => 'from-fallback'); + + $this->assertSame('from-fallback', $served); + $this->assertSame([], $this->cacheStrategy->store, 'An ephemeral snapshot produced cache entries nobody can ever read.'); + } + } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 5a1a1a3..d8b26e9 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -133,7 +133,7 @@ private function makeHandler( * * @return array */ - public function generationModes(): array + public static function generationModes(): array { return [ 'generations on (production default)' => [true], @@ -142,7 +142,7 @@ public function generationModes(): array } /** - * @dataProvider generationModesWithRowEntryCounts + * @dataProvider generationModesWithRowAndTokenEntryCounts */ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useGenerations, int $expectedEntries): void { @@ -381,7 +381,7 @@ public function testFirstReadAfterCreateHitsTheDatabase(): void * * @return array */ - public function generationModesWithExpectedEntries(): array + public static function generationModesWithTokenOnlyEntryCounts(): array { return [ 'generations on (production default)' => [true, 1], @@ -394,7 +394,7 @@ public function generationModesWithExpectedEntries(): array * * @return array */ - public function generationModesWithRowEntryCounts(): array + public static function generationModesWithRowAndTokenEntryCounts(): array { return [ 'generations on (production default)' => [true, 2], @@ -403,7 +403,7 @@ public function generationModesWithRowEntryCounts(): array } /** - * @dataProvider generationModesWithExpectedEntries + * @dataProvider generationModesWithTokenOnlyEntryCounts */ public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(bool $useGenerations, int $expectedEntries): void { @@ -464,7 +464,9 @@ public function testDeleteWhereBroadcastsWhenIdentityCannotBeDerived(): void { $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('warning'); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('missing an identity field')); $handler = $this->makeHandler(['orgId', 'id'], 'test_records', false, $logger, $events); @@ -709,7 +711,7 @@ public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): /** * @return array */ - public function readOutageLookups(): array + public static function readOutageLookups(): array { return [ 'business-key lookup (alias + token reads)' => [['keyHash' => 'abc'], 'id', '7'], @@ -779,7 +781,6 @@ public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void // …and after recovery the original entries are untouched and served. $flaky->failReads = false; - $flaky->failWrites = false; foreach ($storeBefore as $key => $value) { $this->assertArrayHasKey($key, $this->cacheStrategy->store, 'A read blip clobbered a healthy cache entry.'); From 20d0cde8ede388c20e88eaa0aef4a36d44f99df0 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 05:58:36 -0400 Subject: [PATCH 14/26] fix(ci): pin PHPUnit 9.6 and PHP 8.2 so the gates test what the package ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-through on the round-20 blocking CI finding: - php-actions/phpunit's default phar is PHPUnit 11+, which ignores @dataProvider ANNOTATIONS entirely — static providers alone weren't enough; provider-driven tests were silently invoked with zero args. The workflow now pins version 9.6, the major the package's dev dependencies actually resolve, so CI runs the same PHPUnit the suite is written for. - composer's platform is pinned to php 8.2.0 and require.php raised to >=8.2: phpnomad/chrono (a hard dependency) requires >=8.2 in every release, so 8.0/8.1 could never actually install this package — the lock was just hiding it until regenerated. Both workflows run php 8.2 now (the phpstan job's 8.1 pin was failing composer's platform check). - The chronically red spellcheck gate (failing on main for months) gets its seven missing README words added to .wordlist.txt. 76 tests, 162 assertions green locally on PHPUnit 9.6; the four new lib/ files are PHPStan level-9 clean. Co-Authored-By: Claude Fable 5 --- .github/workflows/phpstan.yml | 2 +- .github/workflows/phpunit.yml | 7 +- .wordlist.txt | 8 +- composer.json | 5 +- composer.lock | 991 +++++++++++++++++++++------------- 5 files changed, 639 insertions(+), 374 deletions(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3681a3d..0fb80bb 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -14,4 +14,4 @@ jobs: with: path: lib/ tests/ level: 9 - php_version: 8.1 \ No newline at end of file + php_version: 8.2 \ No newline at end of file diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 93c7b1a..9cb5f2f 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -14,4 +14,9 @@ jobs: - name: PHPUnit Tests uses: php-actions/phpunit@v3 with: - configuration: phpunit.xml \ No newline at end of file + configuration: phpunit.xml + # Pin to the PHPUnit major the package targets (composer dev deps + # resolve 9.6): the action's default phar is PHPUnit 11+, which + # ignores @dataProvider annotations and silently skips those tests. + version: "9.6" + php_version: "8.2" \ No newline at end of file diff --git a/.wordlist.txt b/.wordlist.txt index 7261aef..6d335fb 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -23,4 +23,10 @@ Traceback nodejs npm fediverse -readme \ No newline at end of file +readmegetUnprefixedName +lookups +phpnomad +PrimaryKeyFactory +schemas +txt +WithDatastoreHandlerMethods diff --git a/composer.json b/composer.json index fa9f308..f09a1e4 100644 --- a/composer.json +++ b/composer.json @@ -25,7 +25,7 @@ } ], "require": { - "php": ">=8.0", + "php": ">=8.2", "phpnomad/utils": "^1.0", "phpnomad/cache": "^1.0", "phpnomad/chrono": "^1.0", @@ -40,6 +40,9 @@ "config": { "allow-plugins": { "phpstan/extension-installer": true + }, + "platform": { + "php": "8.2.0" } } } diff --git a/composer.lock b/composer.lock index 2d48302..072f488 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,20 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c58a27037c0a810f516bc4e820498220", + "content-hash": "574441702fccdc43a506b58ea9565a21", "packages": [ { "name": "phpnomad/cache", - "version": "1.0.2", + "version": "1.0.3", "source": { "type": "git", "url": "https://github.com/phpnomad/cache.git", - "reference": "5304a5cc0444e7ee3171b9dad43ee2d563e40a09" + "reference": "660875d83e7a7ed112371945fb8de86b48e3cdcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/cache/zipball/5304a5cc0444e7ee3171b9dad43ee2d563e40a09", - "reference": "5304a5cc0444e7ee3171b9dad43ee2d563e40a09", + "url": "https://api.github.com/repos/phpnomad/cache/zipball/660875d83e7a7ed112371945fb8de86b48e3cdcf", + "reference": "660875d83e7a7ed112371945fb8de86b48e3cdcf", "shasum": "" }, "require": { @@ -47,22 +47,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/cache/issues", - "source": "https://github.com/phpnomad/cache/tree/1.0.2" + "source": "https://github.com/phpnomad/cache/tree/1.0.3" }, - "time": "2025-01-15T11:37:08+00:00" + "time": "2026-06-12T10:56:20+00:00" }, { "name": "phpnomad/chrono", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpnomad/chrono.git", - "reference": "04a11f702e242a3042b7fe802706e935346fb1c3" + "reference": "acc1aab8b7b4b58ed3dc0d728862f63de13921cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/chrono/zipball/04a11f702e242a3042b7fe802706e935346fb1c3", - "reference": "04a11f702e242a3042b7fe802706e935346fb1c3", + "url": "https://api.github.com/repos/phpnomad/chrono/zipball/acc1aab8b7b4b58ed3dc0d728862f63de13921cc", + "reference": "acc1aab8b7b4b58ed3dc0d728862f63de13921cc", "shasum": "" }, "require": { @@ -100,22 +100,22 @@ ], "support": { "issues": "https://github.com/phpnomad/chrono/issues", - "source": "https://github.com/phpnomad/chrono/tree/1.0.0" + "source": "https://github.com/phpnomad/chrono/tree/1.0.1" }, - "time": "2026-05-27T19:22:35+00:00" + "time": "2026-06-12T10:56:24+00:00" }, { "name": "phpnomad/datastore", - "version": "2.0.0", + "version": "2.0.3", "source": { "type": "git", "url": "https://github.com/phpnomad/datastore.git", - "reference": "261897be5d3eb737b8d4db4ab235f87dc6da2fcb" + "reference": "b44d269215b47381132210b31af972d8728eb6bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/datastore/zipball/261897be5d3eb737b8d4db4ab235f87dc6da2fcb", - "reference": "261897be5d3eb737b8d4db4ab235f87dc6da2fcb", + "url": "https://api.github.com/repos/phpnomad/datastore/zipball/b44d269215b47381132210b31af972d8728eb6bd", + "reference": "b44d269215b47381132210b31af972d8728eb6bd", "shasum": "" }, "require-dev": { @@ -140,22 +140,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/datastore/issues", - "source": "https://github.com/phpnomad/datastore/tree/2.0.0" + "source": "https://github.com/phpnomad/datastore/tree/2.0.3" }, - "time": "2024-12-25T20:10:21+00:00" + "time": "2026-06-12T10:56:31+00:00" }, { "name": "phpnomad/enum-polyfill", - "version": "1.0.1", + "version": "1.0.2", "source": { "type": "git", "url": "https://github.com/phpnomad/enum-polyfill.git", - "reference": "0502689aa07ddeff67421ed57120f693f096a6ed" + "reference": "1c36f2af9d4ed098744fb615974a949383a28448" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/enum-polyfill/zipball/0502689aa07ddeff67421ed57120f693f096a6ed", - "reference": "0502689aa07ddeff67421ed57120f693f096a6ed", + "url": "https://api.github.com/repos/phpnomad/enum-polyfill/zipball/1c36f2af9d4ed098744fb615974a949383a28448", + "reference": "1c36f2af9d4ed098744fb615974a949383a28448", "shasum": "" }, "require": { @@ -183,9 +183,9 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/enum-polyfill/issues", - "source": "https://github.com/phpnomad/enum-polyfill/tree/1.0.1" + "source": "https://github.com/phpnomad/enum-polyfill/tree/1.0.2" }, - "time": "2025-01-13T11:51:33+00:00" + "time": "2026-06-12T10:56:45+00:00" }, { "name": "phpnomad/event", @@ -229,23 +229,23 @@ }, { "name": "phpnomad/logger", - "version": "1.0.0", + "version": "1.2.1", "source": { "type": "git", "url": "https://github.com/phpnomad/logger.git", - "reference": "4d6de87bc06ad5a70668578fa80bf9cd68fc58ec" + "reference": "82d294bf7720832edbff8714ccf21c468ee09be8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/logger/zipball/4d6de87bc06ad5a70668578fa80bf9cd68fc58ec", - "reference": "4d6de87bc06ad5a70668578fa80bf9cd68fc58ec", + "url": "https://api.github.com/repos/phpnomad/logger/zipball/82d294bf7720832edbff8714ccf21c468ee09be8", + "reference": "82d294bf7720832edbff8714ccf21c468ee09be8", "shasum": "" }, "require-dev": { - "phpnomad/di": "^1.0", + "phpnomad/di": "^2.0", "phpnomad/facade": "^1.0", "phpnomad/singleton": "^1.0", - "phpnomad/tests": "^0.1.0" + "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "type": "library", "autoload": { @@ -266,26 +266,26 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/logger/issues", - "source": "https://github.com/phpnomad/logger/tree/1.0.0" + "source": "https://github.com/phpnomad/logger/tree/1.2.1" }, - "time": "2024-12-18T17:18:00+00:00" + "time": "2026-06-12T10:57:22+00:00" }, { "name": "phpnomad/singleton", - "version": "1.0.0", + "version": "1.0.1", "source": { "type": "git", "url": "https://github.com/phpnomad/singleton.git", - "reference": "1770888712eec6d72c2e6f962d384fe6a2fb8307" + "reference": "3d5c64fa3670a1353d94efca9231e719af44d8b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/singleton/zipball/1770888712eec6d72c2e6f962d384fe6a2fb8307", - "reference": "1770888712eec6d72c2e6f962d384fe6a2fb8307", + "url": "https://api.github.com/repos/phpnomad/singleton/zipball/3d5c64fa3670a1353d94efca9231e719af44d8b6", + "reference": "3d5c64fa3670a1353d94efca9231e719af44d8b6", "shasum": "" }, "require-dev": { - "phpnomad/tests": "^0.1.0" + "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "type": "library", "autoload": { @@ -306,22 +306,22 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/singleton/issues", - "source": "https://github.com/phpnomad/singleton/tree/1.0.0" + "source": "https://github.com/phpnomad/singleton/tree/1.0.1" }, - "time": "2024-12-18T17:25:13+00:00" + "time": "2026-06-12T10:57:53+00:00" }, { "name": "phpnomad/utils", - "version": "1.0.2", + "version": "1.0.4", "source": { "type": "git", "url": "https://github.com/phpnomad/utils.git", - "reference": "00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55" + "reference": "ad1d0fc1f5a93f1a401205bce3f18da6020ba528" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpnomad/utils/zipball/00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55", - "reference": "00b053f8f3f87419d2d2a9e2e3a8285bb9e1ff55", + "url": "https://api.github.com/repos/phpnomad/utils/zipball/ad1d0fc1f5a93f1a401205bce3f18da6020ba528", + "reference": "ad1d0fc1f5a93f1a401205bce3f18da6020ba528", "shasum": "" }, "require-dev": { @@ -346,9 +346,9 @@ "homepage": "https://github.com/phpnomad/core", "support": { "issues": "https://github.com/phpnomad/utils/issues", - "source": "https://github.com/phpnomad/utils/tree/1.0.2" + "source": "https://github.com/phpnomad/utils/tree/1.0.4" }, - "time": "2026-01-12T21:59:57+00:00" + "time": "2026-06-12T10:58:10+00:00" }, { "name": "psr/clock", @@ -545,16 +545,16 @@ }, { "name": "composer/semver", - "version": "3.4.3", + "version": "3.4.4", "source": { "type": "git", "url": "https://github.com/composer/semver.git", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", - "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", "shasum": "" }, "require": { @@ -606,7 +606,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.4.3" + "source": "https://github.com/composer/semver/tree/3.4.4" }, "funding": [ { @@ -616,13 +616,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-09-19T14:15:21+00:00" + "time": "2025-08-20T19:15:30+00:00" }, { "name": "composer/xdebug-handler", @@ -760,6 +756,75 @@ ], "time": "2022-12-30T00:23:10+00:00" }, + { + "name": "ergebnis/agent-detector", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/ergebnis/agent-detector.git", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/e211f17928c8b95a51e06040792d57f5462fb271", + "reference": "e211f17928c8b95a51e06040792d57f5462fb271", + "shasum": "" + }, + "require": { + "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.51.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", + "fakerphp/faker": "^1.24.1", + "infection/infection": "^0.26.6", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.54", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + }, + "composer-normalize": { + "indent-size": 2, + "indent-style": "space" + } + }, + "autoload": { + "psr-4": { + "Ergebnis\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Andreas Möller", + "email": "am@localheinz.com", + "homepage": "https://localheinz.com" + } + ], + "description": "Provides a detector for detecting the presence of an agent.", + "homepage": "https://github.com/ergebnis/agent-detector", + "support": { + "issues": "https://github.com/ergebnis/agent-detector/issues", + "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md", + "source": "https://github.com/ergebnis/agent-detector" + }, + "time": "2026-05-07T08:19:07+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", @@ -809,16 +874,16 @@ }, { "name": "fidry/cpu-core-counter", - "version": "1.2.0", + "version": "1.3.0", "source": { "type": "git", "url": "https://github.com/theofidry/cpu-core-counter.git", - "reference": "8520451a140d3f46ac33042715115e290cf5785f" + "reference": "db9508f7b1474469d9d3c53b86f817e344732678" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", - "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678", + "reference": "db9508f7b1474469d9d3c53b86f817e344732678", "shasum": "" }, "require": { @@ -828,10 +893,10 @@ "fidry/makefile": "^0.2.0", "fidry/php-cs-fixer-config": "^1.1.2", "phpstan/extension-installer": "^1.2.0", - "phpstan/phpstan": "^1.9.2", - "phpstan/phpstan-deprecation-rules": "^1.0.0", - "phpstan/phpstan-phpunit": "^1.2.2", - "phpstan/phpstan-strict-rules": "^1.4.4", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^8.5.31 || ^9.5.26", "webmozarts/strict-phpunit": "^7.5" }, @@ -858,7 +923,7 @@ ], "support": { "issues": "https://github.com/theofidry/cpu-core-counter/issues", - "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0" }, "funding": [ { @@ -866,61 +931,63 @@ "type": "github" } ], - "time": "2024-08-06T10:04:20+00:00" + "time": "2025-08-14T07:29:31+00:00" }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.65.0", + "version": "v3.95.13", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f" + "reference": "ea941114a002eb5e5876f190223deec1066c482c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/79d4f3e77b250a7d8043d76c6af8f0695e8a469f", - "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c", + "reference": "ea941114a002eb5e5876f190223deec1066c482c", "shasum": "" }, "require": { - "clue/ndjson-react": "^1.0", + "clue/ndjson-react": "^1.3", "composer/semver": "^3.4", - "composer/xdebug-handler": "^3.0.3", + "composer/xdebug-handler": "^3.0.5", + "ergebnis/agent-detector": "^1.2", "ext-filter": "*", + "ext-hash": "*", "ext-json": "*", "ext-tokenizer": "*", - "fidry/cpu-core-counter": "^1.2", + "fidry/cpu-core-counter": "^1.3", "php": "^7.4 || ^8.0", - "react/child-process": "^0.6.5", - "react/event-loop": "^1.0", - "react/promise": "^2.0 || ^3.0", - "react/socket": "^1.0", - "react/stream": "^1.0", - "sebastian/diff": "^4.0 || ^5.0 || ^6.0", - "symfony/console": "^5.4 || ^6.0 || ^7.0", - "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", - "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", - "symfony/finder": "^5.4 || ^6.0 || ^7.0", - "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", - "symfony/polyfill-mbstring": "^1.28", - "symfony/polyfill-php80": "^1.28", - "symfony/polyfill-php81": "^1.28", - "symfony/process": "^5.4 || ^6.0 || ^7.0", - "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" + "react/child-process": "^0.6.6", + "react/event-loop": "^1.5", + "react/socket": "^1.16", + "react/stream": "^1.4", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", + "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.37", + "symfony/polyfill-php80": "^1.37", + "symfony/polyfill-php81": "^1.37", + "symfony/polyfill-php84": "^1.37", + "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0", + "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.4", - "infection/infection": "^0.29.8", - "justinrainbow/json-schema": "^5.3 || ^6.0", - "keradus/cli-executor": "^2.1", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", + "justinrainbow/json-schema": "^6.10.0", + "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", - "php-coveralls/php-coveralls": "^2.7", - "php-cs-fixer/accessible-object": "^1.1", - "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", - "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", - "phpunit/phpunit": "^9.6.21 || ^10.5.38 || ^11.4.3", - "symfony/var-dumper": "^5.4.47 || ^6.4.15 || ^7.1.8", - "symfony/yaml": "^5.4.45 || ^6.4.13 || ^7.1.6" + "php-coveralls/php-coveralls": "^2.9.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -935,7 +1002,7 @@ "PhpCsFixer\\": "src/" }, "exclude-from-classmap": [ - "src/Fixer/Internal/*" + "src/**/Internal/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -961,7 +1028,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.65.0" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13" }, "funding": [ { @@ -969,24 +1036,24 @@ "type": "github" } ], - "time": "2024-11-25T00:39:24+00:00" + "time": "2026-07-10T09:23:21+00:00" }, { "name": "hamcrest/hamcrest-php", - "version": "v2.0.1", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/hamcrest/hamcrest-php.git", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3" + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", - "reference": "8c3d0a3f6af734494ad8f6fbbee0ba92422859f3", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", "shasum": "" }, "require": { - "php": "^5.3|^7.0|^8.0" + "php": "^7.4|^8.0" }, "replace": { "cordoval/hamcrest-php": "*", @@ -994,8 +1061,8 @@ "kodova/hamcrest-php": "*" }, "require-dev": { - "phpunit/php-file-iterator": "^1.4 || ^2.0", - "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0" + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" }, "type": "library", "extra": { @@ -1018,9 +1085,9 @@ ], "support": { "issues": "https://github.com/hamcrest/hamcrest-php/issues", - "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.0.1" + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" }, - "time": "2020-07-09T08:09:16+00:00" + "time": "2025-04-30T06:54:44+00:00" }, { "name": "mockery/mockery", @@ -1107,16 +1174,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.12.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -1155,7 +1222,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -1163,24 +1230,23 @@ "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.3.1", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -1195,7 +1261,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -1219,9 +1285,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2024-10-08T18:51:32+00:00" + "time": "2026-07-04T14:30:18+00:00" }, { "name": "phar-io/manifest", @@ -1439,16 +1505,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.13", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" - }, + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", - "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -1493,7 +1554,7 @@ "type": "github" } ], - "time": "2024-12-17T17:00:20+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpstan/phpstan-mockery", @@ -1866,27 +1927,27 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.22", + "version": "9.6.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c" + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f80235cb4d3caa59ae09be3adf1ded27521d1a9c", - "reference": "f80235cb4d3caa59ae09be3adf1ded27521d1a9c", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=7.3", @@ -1897,11 +1958,11 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.8", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", - "sebastian/global-state": "^5.0.7", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", "sebastian/type": "^3.2.1", @@ -1949,23 +2010,15 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.22" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2024-12-05T13:48:26+00:00" + "time": "2026-07-06T14:48:07+00:00" }, { "name": "psr/container", @@ -2194,33 +2247,33 @@ }, { "name": "react/child-process", - "version": "v0.6.5", + "version": "v0.6.7", "source": { "type": "git", "url": "https://github.com/reactphp/child-process.git", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", - "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3", + "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3", "shasum": "" }, "require": { "evenement/evenement": "^3.0 || ^2.0 || ^1.0", "php": ">=5.3.0", "react/event-loop": "^1.2", - "react/stream": "^1.2" + "react/stream": "^1.4" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/socket": "^1.8", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/socket": "^1.16", "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" }, "type": "library", "autoload": { "psr-4": { - "React\\ChildProcess\\": "src" + "React\\ChildProcess\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -2257,32 +2310,28 @@ ], "support": { "issues": "https://github.com/reactphp/child-process/issues", - "source": "https://github.com/reactphp/child-process/tree/v0.6.5" + "source": "https://github.com/reactphp/child-process/tree/v0.6.7" }, "funding": [ { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2022-09-16T13:41:56+00:00" + "time": "2025-12-23T15:25:20+00:00" }, { "name": "react/dns", - "version": "v1.13.0", + "version": "v1.14.0", "source": { "type": "git", "url": "https://github.com/reactphp/dns.git", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", - "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", "shasum": "" }, "require": { @@ -2337,7 +2386,7 @@ ], "support": { "issues": "https://github.com/reactphp/dns/issues", - "source": "https://github.com/reactphp/dns/tree/v1.13.0" + "source": "https://github.com/reactphp/dns/tree/v1.14.0" }, "funding": [ { @@ -2345,20 +2394,20 @@ "type": "open_collective" } ], - "time": "2024-06-13T14:18:03+00:00" + "time": "2025-11-18T19:34:28+00:00" }, { "name": "react/event-loop", - "version": "v1.5.0", + "version": "v1.6.0", "source": { "type": "git", "url": "https://github.com/reactphp/event-loop.git", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", - "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", "shasum": "" }, "require": { @@ -2409,7 +2458,7 @@ ], "support": { "issues": "https://github.com/reactphp/event-loop/issues", - "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" }, "funding": [ { @@ -2417,27 +2466,27 @@ "type": "open_collective" } ], - "time": "2023-11-13T13:48:05+00:00" + "time": "2025-11-17T20:46:25+00:00" }, { "name": "react/promise", - "version": "v3.2.0", + "version": "v3.3.0", "source": { "type": "git", "url": "https://github.com/reactphp/promise.git", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", - "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", "shasum": "" }, "require": { "php": ">=7.1.0" }, "require-dev": { - "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpstan/phpstan": "1.12.28 || 1.4.10", "phpunit/phpunit": "^9.6 || ^7.5" }, "type": "library", @@ -2482,7 +2531,7 @@ ], "support": { "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v3.2.0" + "source": "https://github.com/reactphp/promise/tree/v3.3.0" }, "funding": [ { @@ -2490,20 +2539,20 @@ "type": "open_collective" } ], - "time": "2024-05-24T10:39:05+00:00" + "time": "2025-08-19T18:57:03+00:00" }, { "name": "react/socket", - "version": "v1.16.0", + "version": "v1.17.0", "source": { "type": "git", "url": "https://github.com/reactphp/socket.git", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", - "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", "shasum": "" }, "require": { @@ -2562,7 +2611,7 @@ ], "support": { "issues": "https://github.com/reactphp/socket/issues", - "source": "https://github.com/reactphp/socket/tree/v1.16.0" + "source": "https://github.com/reactphp/socket/tree/v1.17.0" }, "funding": [ { @@ -2570,7 +2619,7 @@ "type": "open_collective" } ], - "time": "2024-07-26T10:38:09+00:00" + "time": "2025-11-19T20:47:34+00:00" }, { "name": "react/stream", @@ -2819,16 +2868,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -2881,15 +2930,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -3079,16 +3140,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -3144,28 +3205,40 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.7", + "version": "5.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9" + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", - "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", "shasum": "" }, "require": { @@ -3208,15 +3281,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2024-03-02T06:35:11+00:00" + "time": "2025-08-10T07:10:35+00:00" }, { "name": "sebastian/lines-of-code", @@ -3389,16 +3474,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "4.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", "shasum": "" }, "require": { @@ -3440,15 +3525,27 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T06:07:39+00:00" + "time": "2025-08-10T06:57:39+00:00" }, { "name": "sebastian/resource-operations", @@ -3615,23 +3712,24 @@ }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", + "reference": "92f58bc4bf97a92ed1b9f367f0cd44f20bde0e87", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -3645,16 +3743,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3688,7 +3786,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.4.14" }, "funding": [ { @@ -3699,25 +3797,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-11T03:49:26+00:00" + "time": "2026-06-16T11:50:14+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", "shasum": "" }, "require": { @@ -3725,12 +3827,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3755,7 +3857,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" }, "funding": [ { @@ -3766,25 +3868,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.2.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", - "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", + "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", "shasum": "" }, "require": { @@ -3801,13 +3907,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3835,7 +3942,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" }, "funding": [ { @@ -3846,25 +3953,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-06-06T11:10:32+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", - "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", "shasum": "" }, "require": { @@ -3873,12 +3984,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -3911,7 +4022,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" }, "funding": [ { @@ -3922,25 +4033,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-06-05T06:23:12+00:00" }, { "name": "symfony/filesystem", - "version": "v7.2.0", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -3949,7 +4064,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3977,7 +4092,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.2.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -3988,32 +4103,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:15:23+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", - "version": "v7.2.0", + "version": "v7.4.14", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" + "reference": "13b38720174286f55d1761152b575a8d1436fc25" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", - "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4041,7 +4160,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.2.0" + "source": "https://github.com/symfony/finder/tree/v7.4.14" }, "funding": [ { @@ -4052,25 +4171,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-23T06:56:12+00:00" + "time": "2026-06-27T08:31:18+00:00" }, { "name": "symfony/options-resolver", - "version": "v7.2.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", - "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", "shasum": "" }, "require": { @@ -4108,7 +4231,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" }, "funding": [ { @@ -4119,25 +4242,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-20T11:17:29+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -4187,7 +4314,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -4198,25 +4325,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -4265,7 +4396,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -4276,25 +4407,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.31.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -4346,7 +4481,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -4357,28 +4492,33 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.31.0", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { + "ext-iconv": "*", "php": ">=7.2" }, "provide": { @@ -4426,7 +4566,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -4437,25 +4577,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.31.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -4506,7 +4650,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -4517,25 +4661,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.31.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", + "reference": "6bfb9c766cacffbc8e118cb87217d08ed84e5cd7", "shasum": "" }, "require": { @@ -4582,7 +4730,87 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:45:58+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -4593,25 +4821,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/process", - "version": "v7.2.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", - "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -4643,7 +4875,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.0" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -4654,25 +4886,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-06T14:24:19+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.7.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", "shasum": "" }, "require": { @@ -4685,12 +4921,12 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "3.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" } }, "autoload": { @@ -4726,7 +4962,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" }, "funding": [ { @@ -4737,25 +4973,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2026-06-16T09:55:08+00:00" }, { "name": "symfony/stopwatch", - "version": "v7.2.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "696f418b0d722a4225e1c3d95489d262971ca924" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/696f418b0d722a4225e1c3d95489d262971ca924", - "reference": "696f418b0d722a4225e1c3d95489d262971ca924", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { @@ -4788,7 +5028,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v7.2.0" + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" }, "funding": [ { @@ -4799,31 +5039,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v7.2.0", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", - "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -4831,12 +5076,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4875,7 +5119,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.2.0" + "source": "https://github.com/symfony/string/tree/v7.4.13" }, "funding": [ { @@ -4886,25 +5130,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-13T13:31:26+00:00" + "time": "2026-05-23T15:23:29+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -4933,7 +5181,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -4941,7 +5189,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -4950,8 +5198,11 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0" + "php": ">=8.2" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, "plugin-api-version": "2.9.0" } From 31ce6acb4f7bd774dcdbae3190e45f5d257c48d9 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 06:03:59 -0400 Subject: [PATCH 15/26] chore(analysis): all PR-authored files pass PHPStan level 9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every file this PR authors — the four lib/ files, both new test classes, and all eleven test doubles — analyzes clean at the repo's declared level 9 (context arrays carry value types, ModelAdapter generics specified against DataModel, expose helpers return non-null by contract, event recorder ofType() is generic, model narrowing uses assertions instead of loose DataModel calls). Also repairs the .wordlist.txt entry that glued onto a missing trailing newline and adds the remaining README words the chronically red spellcheck gate flags on main. The repo-wide phpstan gate remains red from pre-existing debt in files this PR doesn't author (the legacy trait test and older lib/ files) — same as main. 76 tests, 163 assertions, green. Co-Authored-By: Claude Fable 5 --- .wordlist.txt | 7 +++- tests/Doubles/ExposedRowCache.php | 18 +++++++- tests/Doubles/HidingModelAdapter.php | 8 ++++ tests/Doubles/IdentityRowModel.php | 12 ++++++ tests/Doubles/IdentityRowModelAdapter.php | 8 ++++ tests/Doubles/RecordingEventStrategy.php | 5 ++- tests/Doubles/ScriptedQueryStrategy.php | 23 ++++++++-- tests/Doubles/SerializingCachePolicy.php | 12 ++++++ tests/Unit/Services/DatastoreRowCacheTest.php | 13 +++++- tests/Unit/Traits/CanonicalRowCacheTest.php | 42 +++++++++++++++---- 10 files changed, 130 insertions(+), 18 deletions(-) diff --git a/.wordlist.txt b/.wordlist.txt index 6d335fb..e17c4de 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -23,10 +23,15 @@ Traceback nodejs npm fediverse -readmegetUnprefixedName +readme +getUnprefixedName lookups phpnomad PrimaryKeyFactory schemas txt WithDatastoreHandlerMethods +datastore +PHPNomad +PostDatabaseDatastoreHandler +TableSchemaService diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php index 4c4e03c..68e2732 100644 --- a/tests/Doubles/ExposedRowCache.php +++ b/tests/Doubles/ExposedRowCache.php @@ -18,11 +18,25 @@ */ class ExposedRowCache extends DatastoreRowCache { - public function exposeRowContext(array $row): ?array + /** + * @param array $row + * @return array + */ + public function exposeRowContext(array $row): array { - return $this->rowContext($row); + $context = $this->rowContext($row); + + if ($context === null) { + throw new \RuntimeException('Test row cannot produce a full identity: ' . json_encode($row)); + } + + return $context; } + /** + * @param array $ids + * @return array + */ public function exposeAliasContext(array $ids): array { return $this->aliasContext($ids); diff --git a/tests/Doubles/HidingModelAdapter.php b/tests/Doubles/HidingModelAdapter.php index f4800f4..198fec2 100644 --- a/tests/Doubles/HidingModelAdapter.php +++ b/tests/Doubles/HidingModelAdapter.php @@ -8,14 +8,22 @@ /** * Adapter that exposes nothing — the narrow-serialization case lookup * verification and identity derivation have to survive. + * + * @implements ModelAdapter */ class HidingModelAdapter implements ModelAdapter { + /** + * @param array $array + */ public function toModel(array $array): DataModel { return new IdentityRowModel($array); } + /** + * @return array + */ public function toArray(DataModel $model): array { return []; diff --git a/tests/Doubles/IdentityRowModel.php b/tests/Doubles/IdentityRowModel.php index 2a67275..249c003 100644 --- a/tests/Doubles/IdentityRowModel.php +++ b/tests/Doubles/IdentityRowModel.php @@ -11,20 +11,32 @@ */ class IdentityRowModel implements DataModel { + /** + * @param array $row + */ public function __construct(private array $row = []) { } + /** + * @return mixed + */ public function get(string $field) { return $this->row[$field] ?? null; } + /** + * @return array + */ public function toRow(): array { return $this->row; } + /** + * @return array + */ public function getIdentity(): array { return ['id' => $this->row['id'] ?? null]; diff --git a/tests/Doubles/IdentityRowModelAdapter.php b/tests/Doubles/IdentityRowModelAdapter.php index 91dd678..1ffc947 100644 --- a/tests/Doubles/IdentityRowModelAdapter.php +++ b/tests/Doubles/IdentityRowModelAdapter.php @@ -8,14 +8,22 @@ /** * Adapter for IdentityRowModel that exposes the full row — the standard * full-serialization case. + * + * @implements ModelAdapter */ class IdentityRowModelAdapter implements ModelAdapter { + /** + * @param array $array + */ public function toModel(array $array): DataModel { return new IdentityRowModel($array); } + /** + * @return array + */ public function toArray(DataModel $model): array { return $model instanceof IdentityRowModel ? $model->toRow() : []; diff --git a/tests/Doubles/RecordingEventStrategy.php b/tests/Doubles/RecordingEventStrategy.php index f7206ac..89c5ebe 100644 --- a/tests/Doubles/RecordingEventStrategy.php +++ b/tests/Doubles/RecordingEventStrategy.php @@ -22,8 +22,9 @@ public function broadcast(Event $event): void /** * All recorded broadcasts of one event class, in broadcast order. * - * @param class-string $eventClass - * @return Event[] + * @template T of Event + * @param class-string $eventClass + * @return array */ public function ofType(string $eventClass): array { diff --git a/tests/Doubles/ScriptedQueryStrategy.php b/tests/Doubles/ScriptedQueryStrategy.php index 1028691..39a2732 100644 --- a/tests/Doubles/ScriptedQueryStrategy.php +++ b/tests/Doubles/ScriptedQueryStrategy.php @@ -14,20 +14,26 @@ */ class ScriptedQueryStrategy implements QueryStrategy { - /** @var array[] */ + /** @var array>> */ private array $queryResults = []; public int $queryCount = 0; public int $estimatedCountValue = 0; - /** @var array[] */ + /** @var array>> */ public array $updates = []; - /** @var array[] */ + /** @var array> */ public array $deletes = []; + /** + * @param array> $result + */ public function queueQueryResult(array $result): void { $this->queryResults[] = $result; } + /** + * @return array> + */ public function query(QueryBuilder $builder): array { $this->queryCount++; @@ -39,6 +45,10 @@ public function query(QueryBuilder $builder): array return array_shift($this->queryResults); } + /** + * @param array $data + * @return array + */ public function insert(Table $table, array $data): array { return ['id' => 1]; @@ -48,6 +58,9 @@ public function insert(Table $table, array $data): array public ?int $throwOnDeleteCall = null; private int $deleteCalls = 0; + /** + * @param array $ids + */ public function delete(Table $table, array $ids): void { $this->deleteCalls++; @@ -59,6 +72,10 @@ public function delete(Table $table, array $ids): void $this->deletes[] = $ids; } + /** + * @param array $ids + * @param array $data + */ public function update(Table $table, array $ids, array $data): void { $this->updates[] = [$ids, $data]; diff --git a/tests/Doubles/SerializingCachePolicy.php b/tests/Doubles/SerializingCachePolicy.php index 084e39b..7ceb0a9 100644 --- a/tests/Doubles/SerializingCachePolicy.php +++ b/tests/Doubles/SerializingCachePolicy.php @@ -11,21 +11,33 @@ */ class SerializingCachePolicy implements CachePolicy { + /** + * @param array $context + */ public function shouldCache(string $operation, array $context = []): bool { return true; } + /** + * @param array $context + */ public function getCacheKey(array $context): string { return md5(serialize($context)); } + /** + * @param array $context + */ public function getTtl(array $context = []): ?int { return 60; } + /** + * @param array $context + */ public function shouldInvalidate(string $operation, array $context = []): bool { return true; diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php index 51063e5..402d189 100644 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ b/tests/Unit/Services/DatastoreRowCacheTest.php @@ -39,6 +39,10 @@ protected function setUp(): void ); } + /** + * @param array $identityFields + * @param ModelAdapter|null $adapter + */ private function makeRowCache( array $identityFields, bool $useGenerations = false, @@ -95,7 +99,7 @@ public function testRowIdentityMissingFieldReturnsNullAndWarns(): void $this->assertNull($rowCache->rowIdentity(['id' => 42])); } /** - * @return array + * @return array, 1: bool}> */ public static function identityShapes(): array { @@ -111,6 +115,8 @@ public static function identityShapes(): array /** * @dataProvider identityShapes + * + * @param array $ids */ public function testIsTableIdentityMatchesFieldSetsNotOrder(array $ids, bool $expected): void { @@ -141,7 +147,7 @@ public function testResolveAliasedIdentityReturnsStoredIdentity(): void } /** - * @return array + * @return array, 1: array, 2: bool}> */ public static function lookupComparisons(): array { @@ -156,6 +162,9 @@ public static function lookupComparisons(): array /** * @dataProvider lookupComparisons + * + * @param array $modelRow + * @param array $lookup */ public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRow, array $lookup, bool $expected): void { diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index d8b26e9..b268511 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -76,6 +76,11 @@ protected function setUp(): void $this->queryStrategy = new ScriptedQueryStrategy(); } + /** + * @param array $identityFields + * @param ModelAdapter|null $adapter + * @param array> $uniqueColumns + */ private function makeHandler( array $identityFields, string $tableName = 'test_records', @@ -213,7 +218,9 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $use $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'revoked']]); $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'keyHash', 'operator' => '=', 'value' => 'abc']]]]); - $this->assertSame('revoked', $models[0]->get('status')); + $revoked = $models[0]; + \assert($revoked instanceof IdentityRowModel); + $this->assertSame('revoked', $revoked->get('status')); } /** @@ -237,7 +244,7 @@ public function testBusinessKeyLookupIsServedByAliasWithoutRequery(bool $useGene * Seeds keyHash abc → id 7, kills row 7 out from under the alias, and * scripts the re-resolution to id 9 — the shared healing sequence. */ - private function healRotatedAlias(CanonicalHandler $handler): DataModel + private function healRotatedAlias(CanonicalHandler $handler): IdentityRowModel { // Seed: keyHash abc → id 7. $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); @@ -523,6 +530,7 @@ public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(): void $flaky->failWrites = true; $created = $handler->create(['name' => 'survivor']); + \assert($created instanceof IdentityRowModel); $this->assertSame('survivor', $created->get('name')); $this->assertCount(1, $events->ofType(RecordCreated::class), 'A cache outage suppressed RecordCreated for a committed insert.'); @@ -684,8 +692,10 @@ public function testListReadStaysBatchedWhenCacheWritesFail(): void $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); $this->assertCount(2, $models); - $this->assertSame('first', $models[0]->get('name')); - $this->assertSame('second', $models[1]->get('name')); + [$first, $second] = $models; + \assert($first instanceof IdentityRowModel && $second instanceof IdentityRowModel); + $this->assertSame('first', $first->get('name')); + $this->assertSame('second', $second->get('name')); $this->assertSame(2, $this->queryStrategy->queryCount, 'A cache outage degraded a batched list read into per-row queries.'); } @@ -709,7 +719,7 @@ public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): } /** - * @return array + * @return array, 1: string, 2: string}> */ public static function readOutageLookups(): array { @@ -724,6 +734,8 @@ public static function readOutageLookups(): array * lookup, whatever its shape. * * @dataProvider readOutageLookups + * + * @param array $lookup */ public function testReadsSurviveACacheThatFailsOnReads(array $lookup, string $field, string $expected): void { @@ -825,7 +837,9 @@ public function testConcurrentlyDeletedRowDoesNotCollapseTheListResult(): void $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '!=', 'value' => '']]]]); $this->assertCount(1, $models, 'A concurrently deleted row collapsed the whole result.'); - $this->assertSame('survivor', $models[0]->get('name')); + $survivor = $models[0]; + \assert($survivor instanceof IdentityRowModel); + $this->assertSame('survivor', $survivor->get('name')); } public function testMidLoopSqlFailureStillInvalidatesAndAnnouncesCompletedDeletes(): void @@ -845,7 +859,8 @@ public function testMidLoopSqlFailureStillInvalidatesAndAnnouncesCompletedDelete try { $handler->deleteWhere([['column' => 'status', 'operator' => '=', 'value' => 'doomed']]); $this->fail('Expected the mid-loop SQL failure to propagate.'); - } catch (\LogicException $e) { + } catch (\Throwable $e) { + $this->assertInstanceOf(\LogicException::class, $e); $this->assertSame([['id' => '1']], $this->queryStrategy->deletes, 'Row 1 was not deleted before the failure.'); $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A completed delete was not announced after a mid-loop failure.'); $this->assertFalse( @@ -884,6 +899,10 @@ class CanonicalHandler private bool $useGenerations; + /** + * @param class-string<\PHPNomad\Datastore\Interfaces\DataModel> $model + * @param ModelAdapter<\PHPNomad\Datastore\Interfaces\DataModel> $modelAdapter + */ public function __construct( DatabaseServiceProvider $serviceProvider, Table $table, @@ -905,8 +924,15 @@ protected function shouldUseTableGenerations(): bool return $this->useGenerations; } + /** + * @param array $ids + * @return IdentityRowModel + */ public function findByCompound(array $ids) { - return $this->findFromCompound($ids); + $model = $this->findFromCompound($ids); + \assert($model instanceof IdentityRowModel); + + return $model; } } From d40e7acf0af5051f09192d663e49751616eb6dde Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 06:06:39 -0400 Subject: [PATCH 16/26] chore(analysis): type the datastore trait's public surface; finish the wordlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trait's untyped legacy signatures were phpstan's biggest single source — re-reported once per consuming class, which is how this PR's new trait consumers pushed the repo count above main's. With the public surface annotated, the branch analyzes at 194 errors repo-wide against main's 233: the PR now strictly improves the (still red, pre-existing) phpstan gate. The remaining debt is legacy mixed-flow inside untouched methods like buildConditions() — a remediation project, not this PR. Spellcheck's remaining three README words join the wordlist. Co-Authored-By: Claude Fable 5 --- .wordlist.txt | 3 + lib/Traits/WithDatastoreHandlerMethods.php | 84 ++++++++++++++++------ 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/.wordlist.txt b/.wordlist.txt index e17c4de..df0b114 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -35,3 +35,6 @@ datastore PHPNomad PostDatabaseDatastoreHandler TableSchemaService +DateModifiedFactory +IdentifiableDatabaseDatastoreHandler +PostsTable diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index fe79ac9..7a4cba9 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -43,7 +43,12 @@ public function getEstimatedCount(): int }); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function where(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { try { @@ -56,7 +61,12 @@ public function where(array $conditions, ?int $limit = null, ?int $offset = null } } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function andWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { return $this->where([ @@ -67,7 +77,12 @@ public function andWhere(array $conditions, ?int $limit = null, ?int $offset = n ], $limit, $offset, $orderBy, $order); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + * @return DataModel[] + */ public function orWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array { return $this->where([ @@ -78,7 +93,11 @@ public function orWhere(array $conditions, ?int $limit = null, ?int $offset = nu ], $limit, $offset, $orderBy, $order); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + */ public function countWhere(array $conditions): int { $this->initiateQuery( @@ -102,7 +121,11 @@ public function countWhere(array $conditions): int return Arr::get($result, 'count', 0); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + */ public function countAndWhere(array $conditions): int { return $this->countWhere([ @@ -113,7 +136,11 @@ public function countAndWhere(array $conditions): int ]); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array> $conditions + */ public function countOrWhere(array $conditions): int { return $this->countWhere([ @@ -124,7 +151,11 @@ public function countOrWhere(array $conditions): int ]); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param mixed $value + */ public function findBy(string $field, $value): DataModel { $result = $this->andWhere([['column' => $field, 'operator' => '=', 'value' => $value]], 1); @@ -136,7 +167,11 @@ public function findBy(string $field, $value): DataModel return Arr::get($result, 0); } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array $attributes + */ public function create(array $attributes): DataModel { $fields = $this->table->getFieldsForIdentity(); @@ -208,7 +243,7 @@ protected function applyPhpDefaults(array $attributes): array * Each deleted row broadcasts a RecordDeleted carrying its raw identity * row; no matching rows is a silent no-op. * - * @param array $conditions + * @param array> $conditions * @return void * @throws DatastoreErrorException */ @@ -398,10 +433,10 @@ protected function shouldUseTableGenerations(): bool /** - * @param array $conditions + * @param array> $conditions * @param int|null $limit * @param int|null $offset - * @return array + * @return array> * @throws DatastoreErrorException */ public function findIds(array $conditions, ?int $limit = null, ?int $offset = null): array @@ -416,7 +451,7 @@ public function findIds(array $conditions, ?int $limit = null, ?int $offset = nu /** * Gets the models for the given identity rows, read-through cached. * - * @param array[] $ids Identity rows from findIds() (values arrive DB-typed). + * @param array> $ids Identity rows from findIds() (values arrive DB-typed). * @return DataModel[] */ protected function getModels(array $ids): array @@ -593,7 +628,12 @@ protected function queryRowAndModel(array $ids): array return [$item, $this->modelAdapter->toModel($item)]; } - /** @inheritDoc */ + /** + * @inheritDoc + * + * @param array $ids + * @param array $attributes + */ public function updateCompound($ids, array $attributes): void { $generation = $this->rowCache()->snapshotGeneration(); @@ -714,8 +754,8 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): } /** - * @param array $attributes - * @param array $fields + * @param array $attributes + * @param array $fields * @return void * @throws DatastoreErrorException * @throws DuplicateEntryException @@ -748,9 +788,9 @@ protected function maybeThrowForDuplicateIdentity(array $attributes, array $fiel } /** - * @param array $attributes - * @param array $fields - * @return array + * @param array $attributes + * @param array $fields + * @return array */ protected function removeIdentifiableFields(array $attributes, array $fields): array { @@ -760,7 +800,7 @@ protected function removeIdentifiableFields(array $attributes, array $fields): a /** * Looks up records to check if a record with the specified unique columns already exists. * - * @param array $data + * @param array $data * @return DataModel[] List of existing items that match the unique constraints. * @throws DatastoreErrorException * @throws RecordNotFoundException @@ -817,9 +857,9 @@ protected function getDuplicates(array $data): array * shadow a duplicate sharing the model identity; such adapters trade * that for not tripping spurious self-duplicates). * - * @param array $data - * @param array|null $updateTableIdentity Canonical identity of the record being updated. - * @param array|null $updateModelIdentity Model identity of the record being updated. + * @param array $data + * @param array|null $updateTableIdentity Canonical identity of the record being updated. + * @param array|null $updateModelIdentity Model identity of the record being updated. * @return void * @throws DuplicateEntryException * @throws DatastoreErrorException From 83586f5107a7b60bf91688a8e1ef1f49b8d97d3d Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 06:07:31 -0400 Subject: [PATCH 17/26] chore(spellcheck): last two README words Co-Authored-By: Claude Fable 5 --- .wordlist.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.wordlist.txt b/.wordlist.txt index df0b114..ed014e1 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -38,3 +38,5 @@ TableSchemaService DateModifiedFactory IdentifiableDatabaseDatastoreHandler PostsTable +DatabaseServiceProvider +DateCreatedFactory From 0d2842a5b61eb567503880c6732a08519975f873 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 07:35:59 -0400 Subject: [PATCH 18/26] refactor(cache): reshape to the documented Novatorius class patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alex's review: Novatorius has a closed vocabulary of class types, and classes outside it are wrong. The prior shape wasn't in it — DatastoreRowCache mixed two patterns in one class (context conversion + cache orchestration, violating the one-pattern rule), RowCache was a 13-method speculative contract with a single implementation, and RowCacheFactory was a factory interface wrapping a trivial new (the docs reserve factories for constructing complex objects). The reshape, mechanics unchanged: - RowCacheContextAdapter (lib/Adapters) — a pure Adapter converting rows, identities, lookup keys, and models into the cache-context vocabulary: rowIdentity/rawIdentity/identityKey, row/alias/table/ generation contexts, generation-token formats, and tri-state matchesLookup. No I/O, no logger; public and directly testable, which also kills the ExposedRowCache extends-a-lib-concrete exception. - Cache orchestration returns to the datastore handler trait, driving CacheableService — the existing documented Service for cache I/O — directly: generation snapshots/bumps, guarded read-throughs, swallow-and-log entry mutations, alias resolution, ephemeral-token skipping, and the unverifiable-lookup policy. Every behavior and failure guarantee from the audit rounds is preserved and still covered by the same tests. - Deleted: RowCache, RowCacheFactory, DatastoreRowCacheFactory, DatastoreRowCache, ExposedRowCache. DatabaseServiceProvider returns to its original six-argument constructor — two breaking changes (provider arg, factory contract) fall out of the PR entirely. - Tests follow the shapes: RowCacheContextAdapterTest covers the pure conversions; the trait scenario tests probe cache slots through the adapter plus a live-token helper. 73 tests, 159 assertions, green; adapter files PHPStan level-9 clean; repo-wide analysis stays below main (195 vs 233). Co-Authored-By: Claude Fable 5 --- lib/Adapters/RowCacheContextAdapter.php | 317 +++++++++ lib/Factories/DatastoreRowCacheFactory.php | 40 -- lib/Interfaces/RowCache.php | 193 ------ lib/Interfaces/RowCacheFactory.php | 32 - lib/Providers/DatabaseServiceProvider.php | 16 +- lib/Services/DatastoreRowCache.php | 616 ------------------ lib/Traits/WithDatastoreHandlerMethods.php | 480 ++++++++++++-- tests/Doubles/ExposedRowCache.php | 44 -- .../Adapters/RowCacheContextAdapterTest.php | 177 +++++ tests/Unit/Services/DatastoreRowCacheTest.php | 263 -------- tests/Unit/Traits/CanonicalRowCacheTest.php | 74 ++- .../WithDatastoreHandlerMethodsTest.php | 22 +- 12 files changed, 981 insertions(+), 1293 deletions(-) create mode 100644 lib/Adapters/RowCacheContextAdapter.php delete mode 100644 lib/Factories/DatastoreRowCacheFactory.php delete mode 100644 lib/Interfaces/RowCache.php delete mode 100644 lib/Interfaces/RowCacheFactory.php delete mode 100644 lib/Services/DatastoreRowCache.php delete mode 100644 tests/Doubles/ExposedRowCache.php create mode 100644 tests/Unit/Adapters/RowCacheContextAdapterTest.php delete mode 100644 tests/Unit/Services/DatastoreRowCacheTest.php diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php new file mode 100644 index 0000000..c3691a3 --- /dev/null +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -0,0 +1,317 @@ + + */ + protected string $model; + + /** + * @var ModelAdapter + */ + protected ModelAdapter $modelAdapter; + protected bool $useGenerations; + + /** + * @param class-string $model + * @param ModelAdapter $modelAdapter Used to verify models against lookup values. + * @param bool $useGenerations Whether contexts carry a per-table generation token. + */ + public function __construct(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true) + { + $this->table = $table; + $this->model = $model; + $this->modelAdapter = $modelAdapter; + $this->useGenerations = $useGenerations; + } + + /** + * Whether contexts built by this adapter carry a generation token. + */ + public function usesGenerations(): bool + { + return $this->useGenerations; + } + + /** + * True when the given compound key is exactly the table's identity field + * set (order-insensitive). + * + * @param array $ids + */ + public function isTableIdentity(array $ids): bool + { + $identityFields = $this->table->getFieldsForIdentity(); + + return count($ids) === count($identityFields) + && !array_diff(array_keys($ids), $identityFields); + } + + /** + * Converts row data to its canonical identity: the table's identity + * fields, in the table's declared order, with scalar values normalized + * to strings (an int identity from a hydrated write and a string + * identity from the query strategy must be the same identity). + * + * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). + * @return array|null Null when the row is missing an identity field — a + * partial context must never become a cache key. (Pure: + * the caller logs.) + */ + public function rowIdentity(array $row): ?array + { + $identity = []; + + foreach ($this->table->getFieldsForIdentity() as $field) { + if (!array_key_exists($field, $row)) { + return null; + } + + $identity[$field] = $row[$field]; + } + + return $this->stringifyScalars($identity); + } + + /** + * Converts row data to its raw table-identity projection — same fields + * as rowIdentity(), original value types. This feeds SQL conditions, + * where cache-key stringification must not leak into driver-typed + * comparisons. + * + * @param array $row + * @return array|null + */ + public function rawIdentity(array $row): ?array + { + $identity = []; + + foreach ($this->table->getFieldsForIdentity() as $field) { + if (!array_key_exists($field, $row)) { + return null; + } + + $identity[$field] = $row[$field]; + } + + return $identity; + } + + /** + * Opaque equality key for a row's canonical identity — two rows are the + * same record exactly when their identity keys match. + * + * @param array $row + * @return string|null Null when the row cannot produce a full identity. + */ + public function identityKey(array $row): ?string + { + $identity = $this->rowIdentity($row); + + return $identity === null ? null : serialize($identity); + } + + /** + * Converts row data to the ONE cache context the row is stored under. + * + * @param array $row + * @param string|null $generation Pre-query generation snapshot. + * @return array|null Null when the row cannot produce a full identity. + */ + public function rowContext(array $row, ?string $generation = null): ?array + { + $identity = $this->rowIdentity($row); + + return $identity === null ? null : $this->identityContext($identity, $generation); + } + + /** + * Wraps an already-canonical identity (from rowIdentity()) in the row + * cache context. + * + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function identityContext(array $identity, ?string $generation = null): array + { + return $this->withGeneration(['type' => $this->model, 'identities' => $this->stringifyScalars($identity)], $generation); + } + + /** + * Converts a business-key lookup to its alias context: a pointer slot + * from the lookup to the row's canonical identity. Aliases store + * identities, never row data, so a stale alias self-heals — the row read + * it points to misses and falls through to the database. + * + * @param array $ids The caller's lookup key. + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function aliasContext(array $ids, ?string $generation = null): array + { + $normalized = $this->stringifyScalars($ids); + + ksort($normalized); + + return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); + } + + /** + * The set-level context — one undiscriminated slot per table + * (estimatedCount today). A second whole-table value would need a + * discriminator added to the context. + * + * @param string|null $generation Pre-query generation snapshot. + * @return array + */ + public function tableContext(?string $generation = null): array + { + return $this->withGeneration(['type' => $this->model], $generation); + } + + /** + * The context the generation token itself lives under. Never carries a + * generation — it IS the generation. + * + * @return array + */ + public function generationContext(): array + { + return ['type' => $this->model, 'generation' => true]; + } + + /** + * Folds a generation token into a cache context. The token is replaced + * on every write, which orphans all previously written contexts for the + * table at once — the transaction-free invalidation primitive the whole + * design leans on. + * + * @param array $context + * @param string|null $generation Pre-query generation snapshot; required + * when generations are on (the handler + * threads it), ignored when off. + * @return array + * + * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin + */ + public function withGeneration(array $context, ?string $generation): array + { + if (!$this->useGenerations || $generation === null) { + return $context; + } + + $context['gen'] = $generation; + + return $context; + } + + /** + * Mints an opaque, unique generation token. + */ + public function mintGeneration(): string + { + return bin2hex(random_bytes(8)); + } + + /** + * Mints a token for a single operation during a token-read outage. The + * prefix marks it so every caching path skips: entries keyed under a + * token nobody can ever read back are pure garbage. + */ + public function mintEphemeralGeneration(): string + { + return 'ephemeral-' . bin2hex(random_bytes(8)); + } + + /** + * True when the snapshot was minted during a token-read outage and no + * cache entry keyed under it can ever be served. + */ + public function isEphemeralGeneration(?string $generation): bool + { + return $generation !== null && strpos($generation, 'ephemeral-') === 0; + } + + /** + * Compares a model's converted data against the caller's lookup values — + * the guard against an alias whose business key was rotated out from + * under it by an identity-keyed update. + * + * Tri-state so the caller owns policy: true = verified match, + * false = verified mismatch, null = unverifiable (the model adapter + * exposes none of the lookup fields). Exceptions thrown by the model + * adapter are domain errors and propagate. + * + * @param DataModel $model + * @param array $ids The caller's lookup key. + */ + public function matchesLookup(DataModel $model, array $ids): ?bool + { + $data = $this->modelAdapter->toArray($model); + $verified = 0; + + foreach ($ids as $field => $value) { + if (!array_key_exists($field, $data)) { + continue; + } + + $verified++; + + // Normalize both sides through the same rule cache keys use, so + // this comparison can never drift from key equality. + [$actual, $expected] = array_values($this->stringifyScalars([ + 'actual' => $data[$field], + 'expected' => $value, + ])); + + if ($actual !== $expected) { + return false; + } + } + + return $verified === 0 ? null : true; + } + + /** + * Normalizes scalar values to strings — the single normalization rule + * every cache key shape shares. + * + * @param array $values + * @return array Same keys; scalar values stringified. + */ + protected function stringifyScalars(array $values): array + { + foreach ($values as $key => $value) { + if (is_bool($value)) { + // (string) false is '' — explicit '0' keeps booleans from + // colliding with empty strings in cache keys. + $values[$key] = $value ? '1' : '0'; + } elseif (is_scalar($value)) { + $values[$key] = (string) $value; + } + } + + return $values; + } +} diff --git a/lib/Factories/DatastoreRowCacheFactory.php b/lib/Factories/DatastoreRowCacheFactory.php deleted file mode 100644 index ee00fd7..0000000 --- a/lib/Factories/DatastoreRowCacheFactory.php +++ /dev/null @@ -1,40 +0,0 @@ -cacheableService = $cacheableService; - $this->loggerStrategy = $loggerStrategy; - } - - /** - * @inheritDoc - * - * @param ModelAdapter $modelAdapter - */ - public function make(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true): RowCache - { - return new DatastoreRowCache($this->cacheableService, $this->loggerStrategy, $table, $model, $modelAdapter, $useGenerations); - } -} diff --git a/lib/Interfaces/RowCache.php b/lib/Interfaces/RowCache.php deleted file mode 100644 index d83910d..0000000 --- a/lib/Interfaces/RowCache.php +++ /dev/null @@ -1,193 +0,0 @@ - $ids - */ - public function isTableIdentity(array $ids): bool; - - /** - * Extracts the canonical identity from row data. Null (logged) when the - * row is missing an identity field. - * - * @param array $row - * @return array|null Scalars stringified, table order. - */ - public function rowIdentity(array $row): ?array; - - /** - * Opaque equality key for a row's canonical identity — two rows are the - * same record exactly when their identity keys match. Consumers use this - * for local dedupe so identity equality stays defined in one place. - * - * @param array $row - * @return string|null Null when the row cannot produce a full identity. - */ - public function identityKey(array $row): ?string; - - /** - * Reads the identity an alias entry points at, validated against the - * table's identity shape. Null on miss, malformed value, or a - * cache-layer read failure (logged). - * - * @param array $ids - * @param string|null $generation Pre-query generation snapshot. - * @return array|null - */ - public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array; - - /** - * Read-through for a canonical row: serves the cached model or runs the - * fallback and caches its result — every row cache read AND the miss-path - * write happen behind this contract. - * - * Failure semantics: cache-layer failures must never surface — serve the - * fallback's value when only the post-load store failed, and load - * directly when the probe itself broke. Exceptions thrown BY the - * fallback are domain errors (RecordNotFoundException) and must - * propagate untouched. Under an ephemeral generation snapshot the cache - * is skipped entirely and the fallback serves the read. - * - * @param array $identity Canonical identity (from rowIdentity()). - * @param string|null $generation Pre-query generation snapshot. - * @param callable $fallback Loads the model on miss; its result is cached. - * @return mixed The cached or freshly loaded model. - */ - public function readRow(array $identity, ?string $generation, callable $fallback); - - /** - * Whether a row entry exists for the given identity row. False when the - * row cannot produce a full identity (it can never have been cached). - * - * @param array $identityRow - * @param string|null $generation Pre-query generation snapshot. - */ - public function hasRow(array $identityRow, ?string $generation = null): bool; - - /** - * Read-through for the table's single set-level value: serves the cached - * value or runs the fallback and caches its result. One undiscriminated - * slot per table — a second whole-table value would need a discriminator - * added to the context. Same failure semantics as readRow(). - * - * @param callable $fallback Computes the value on miss; its result is cached. - * @return mixed - */ - public function readTableValue(callable $fallback); - - /** - * True when the model still carries the caller's lookup values — - * the guard against an alias whose business key was rotated out from - * under it by an identity-keyed update. For generation-disabled tables - * ANY lookup field the adapter cannot expose makes the lookup - * unverifiable and the alias is treated as stale, because this check is - * their ONLY rotation defense; generation-enabled tables skip - * unverifiable fields (the bump covers rotation). Exceptions thrown by - * the model adapter are domain errors and propagate (the same carve-out - * readRow() makes for its fallback). - * - * @param DataModel $model - * @param array $ids The caller's lookup key. - */ - public function matchesLookup(DataModel $model, array $ids): bool; - - /** - * The one post-write invalidation call: bumps the generation when - * generations are on, precisely deletes the set-level context when they - * are off. Every successful write must end with this. - * - * @return string|null The fresh generation token (for post-write cache - * writes), or null when generations are disabled. - */ - public function invalidateAfterWrite(): ?string; - - /** - * Stores a row's model under its canonical row context. Skips (logged) - * when the row cannot produce a full identity. - * - * Contract-level invariant for ANY generation-aware implementation: read - * paths MUST pass the generation snapshot they took before querying the - * database. A token fetched at store time can postdate a concurrent - * write's bump, landing a stale row under the new generation — the exact - * race generations exist to close. - * - * Stores MUST no-op under an ephemeral generation snapshot (minted - * during a token-read outage): such entries can never be read back. - * - * @param array $row - * @param mixed $model - * @param string|null $generation Pre-query generation snapshot — REQUIRED - * (null only for generation-disabled tables): a token - * fetched at store time can postdate a concurrent - * write's bump, so the signature forces the caller to - * thread the snapshot it queried under. - */ - public function storeRow(array $row, $model, ?string $generation): void; - - /** - * Stores an alias entry pointing a business key at a canonical identity. - * - * Stores MUST no-op under an ephemeral generation snapshot. - * - * @param array $ids - * @param array $identity - * @param string|null $generation Pre-query generation snapshot. - */ - public function storeAlias(array $ids, array $identity, ?string $generation): void; - - /** - * Deletes the row entry for a canonical identity. - * - * @param array $identity - * @param string|null $generation Pre-query generation snapshot. - */ - public function deleteRow(array $identity, ?string $generation): void; - - /** - * Deletes an alias entry. - * - * @param array $ids - * @param string|null $generation Pre-query generation snapshot. - */ - public function deleteAlias(array $ids, ?string $generation): void; - - /** - * Takes the generation snapshot an operation should key its contexts - * under — once, before any database query. Null when generations are - * disabled. - * - * Failure is NOT a miss here: a missing token may be minted and - * persisted, but a token that could not be READ must yield an ephemeral - * token that is never persisted — persisting on a read blip would let - * every reader clobber a healthy token and wholesale-invalidate the - * table cache. - */ - public function snapshotGeneration(): ?string; -} diff --git a/lib/Interfaces/RowCacheFactory.php b/lib/Interfaces/RowCacheFactory.php deleted file mode 100644 index dfddb0c..0000000 --- a/lib/Interfaces/RowCacheFactory.php +++ /dev/null @@ -1,32 +0,0 @@ - $model The model class cache contexts are typed under. - * @param ModelAdapter $modelAdapter Used to verify alias-resolved rows against lookup values. - * @param bool $useGenerations Whether contexts carry a per-table generation - * token. On: writes orphan all prior contexts at - * once, closing the cache-aside write-back race. - * Off: higher hit rate on write-hot tables, with - * the race only TTL-bounded and invalidation - * relying on precise deletes plus read-time - * lookup verification. - * - * @see \PHPNomad\Database\Traits\WithDatastoreHandlerMethods::shouldUseTableGenerations() the per-handler override point - */ - public function make(Table $table, string $model, ModelAdapter $modelAdapter, bool $useGenerations = true): RowCache; -} diff --git a/lib/Providers/DatabaseServiceProvider.php b/lib/Providers/DatabaseServiceProvider.php index b36234b..b6cfd4b 100644 --- a/lib/Providers/DatabaseServiceProvider.php +++ b/lib/Providers/DatabaseServiceProvider.php @@ -6,7 +6,6 @@ use PHPNomad\Database\Interfaces\ClauseBuilder; use PHPNomad\Database\Interfaces\QueryBuilder; use PHPNomad\Database\Interfaces\QueryStrategy; -use PHPNomad\Database\Interfaces\RowCacheFactory; use PHPNomad\Events\Interfaces\EventStrategy; use PHPNomad\Logger\Interfaces\LoggerStrategy; @@ -16,20 +15,15 @@ class DatabaseServiceProvider public QueryStrategy $queryStrategy; /** - * Kept on the bundle as an extension surface for downstream handlers - * with caching needs beyond row caching. All row and alias caching in - * the package's datastore flows routes through the RowCache built by - * $rowCacheFactory (schema lookups cache separately via - * TableSchemaService) — never cache rows or aliases against this - * directly; build or extend a RowCache instead, or invalidation cannot - * name your keys. + * Row and alias caching in the datastore flows builds every context + * through RowCacheContextAdapter — never cache rows or aliases against + * this directly with hand-built keys, or invalidation cannot name them. */ public CacheableService $cacheableService; public QueryBuilder $queryBuilder; public ClauseBuilder $clauseBuilder; public EventStrategy $eventStrategy; - public RowCacheFactory $rowCacheFactory; public function __construct( LoggerStrategy $loggerStrategy, @@ -37,8 +31,7 @@ public function __construct( QueryBuilder $queryBuilder, ClauseBuilder $clauseBuilder, CacheableService $cacheableService, - EventStrategy $eventStrategy, - RowCacheFactory $rowCacheFactory + EventStrategy $eventStrategy ) { $this->clauseBuilder = $clauseBuilder; @@ -47,6 +40,5 @@ public function __construct( $this->queryBuilder = $queryBuilder; $this->cacheableService = $cacheableService; $this->eventStrategy = $eventStrategy; - $this->rowCacheFactory = $rowCacheFactory; } } \ No newline at end of file diff --git a/lib/Services/DatastoreRowCache.php b/lib/Services/DatastoreRowCache.php deleted file mode 100644 index 416ca90..0000000 --- a/lib/Services/DatastoreRowCache.php +++ /dev/null @@ -1,616 +0,0 @@ - - */ - protected string $model; - /** - * @var ModelAdapter - */ - protected ModelAdapter $modelAdapter; - protected bool $useGenerations; - - /** - * @param class-string $model - * @param ModelAdapter $modelAdapter - */ - public function __construct( - CacheableService $cacheableService, - LoggerStrategy $loggerStrategy, - Table $table, - string $model, - ModelAdapter $modelAdapter, - bool $useGenerations = true - ) { - $this->cacheableService = $cacheableService; - $this->loggerStrategy = $loggerStrategy; - $this->table = $table; - $this->model = $model; - $this->modelAdapter = $modelAdapter; - $this->useGenerations = $useGenerations; - } - - /** @inheritDoc */ - public function isTableIdentity(array $ids): bool - { - // Table::getFieldsForIdentity() is contractually non-empty. - $identityFields = $this->table->getFieldsForIdentity(); - - return count($ids) === count($identityFields) - && !array_diff(array_keys($ids), $identityFields); - } - - /** - * Extracts the canonical identity from row data: the table's identity - * fields, in the table's declared order, with scalar values normalized to - * strings (an int identity from a hydrated write and a string identity - * from the query strategy must be the same identity). - * - * Returns null (and logs) when the row is missing an identity field — - * a partial context must never be used as a cache key. - * - * @param array $row Row data (a DB row, an identity row, or write attributes merged with insert ids). - * @return array|null The canonical identity (scalars stringified, table order), or null. - */ - public function rowIdentity(array $row): ?array - { - $identityFields = $this->table->getFieldsForIdentity(); - - $identity = []; - - foreach ($identityFields as $field) { - if (!array_key_exists($field, $row)) { - $this->loggerStrategy->warning( - 'Cannot derive a canonical cache identity — row is missing an identity field.', - ['table' => $this->table->getName(), 'missingField' => $field, 'rowFields' => array_keys($row)] - ); - - return null; - } - - $identity[$field] = $row[$field]; - } - - return $this->stringifyScalars($identity); - } - - /** @inheritDoc */ - public function identityKey(array $row): ?string - { - $identity = $this->rowIdentity($row); - - return $identity === null ? null : serialize($identity); - } - - /** - * Builds the ONE cache context a row is stored under, derived from row - * data rather than from whatever shape the caller asked for. - * - * @param array $row - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - * @return array|null Null when the row cannot produce a full identity. - */ - protected function rowContext(array $row, ?string $generation = null): ?array - { - $identity = $this->rowIdentity($row); - - return $identity === null ? null : $this->identityContext($identity, $generation); - } - - /** - * Wraps an already-canonical identity (from rowIdentity()) in the row - * cache context. - * - * @param array $identity - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - * @return array - */ - protected function identityContext(array $identity, ?string $generation = null): array - { - return $this->withGeneration(['type' => $this->model, 'identities' => $identity], $generation); - } - - /** - * Builds the cache context for an alias entry: a pointer from a - * business-key lookup (any compound key that is not the table identity) - * to the row's canonical identity. Aliases store identities, never row - * data, so a stale alias self-heals: the row read it points to misses and - * falls through to the database. - * - * @param array $ids The caller's lookup key. - * @param string|null $generation Generation snapshot to key under; taken fresh when omitted. - * @return array - */ - protected function aliasContext(array $ids, ?string $generation = null): array - { - $normalized = $this->stringifyScalars($ids); - - ksort($normalized); - - return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); - } - - /** - * The set-level context — one undiscriminated slot per table. - * - * @param string|null $generation Generation snapshot; taken fresh when omitted. - * @return array - */ - protected function tableContext(?string $generation = null): array - { - return $this->withGeneration(['type' => $this->model], $generation); - } - - /** @inheritDoc */ - public function storeRow(array $row, $model, ?string $generation): void - { - if ($this->isEphemeralGeneration($generation)) { - return; - } - - $context = $this->rowContext($row, $generation); - - if ($context === null) { - return; - } - - try { - $this->cacheableService->set($context, $model); - } catch (Throwable $e) { - $this->loggerStrategy->warning( - 'Could not cache a row — the next read will hit the database.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } - } - - /** @inheritDoc */ - public function storeAlias(array $ids, array $identity, ?string $generation): void - { - if ($this->isEphemeralGeneration($generation)) { - return; - } - - try { - $this->cacheableService->set($this->aliasContext($ids, $generation), $identity); - } catch (Throwable $e) { - $this->loggerStrategy->warning( - 'Could not cache an alias — the next lookup will re-resolve from the database.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } - } - - /** @inheritDoc */ - public function deleteRow(array $identity, ?string $generation): void - { - try { - $this->cacheableService->delete($this->identityContext($identity, $generation)); - } catch (Throwable $e) { - $this->loggerStrategy->error( - 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } - } - - /** @inheritDoc */ - public function deleteAlias(array $ids, ?string $generation): void - { - try { - $this->cacheableService->delete($this->aliasContext($ids, $generation)); - } catch (Throwable $e) { - $this->loggerStrategy->error( - 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } - } - - /** - * Deletes the set-level context. invalidateAfterWrite() calls this for - * generation-disabled tables, where no bump exists to orphan it. - */ - protected function deleteTableContext(): void - { - $this->cacheableService->delete($this->tableContext()); - } - - /** @inheritDoc */ - public function readRow(array $identity, ?string $generation, callable $fallback) - { - if ($this->isEphemeralGeneration($generation)) { - // No key under an ephemeral token can ever be read back: skip - // the cache entirely — no guaranteed-miss round trip, and no - // unreachable garbage from the miss-path store. - return $fallback(); - } - - return $this->guardedReadThrough($this->identityContext($identity, $generation), $fallback); - } - - /** - * Read-through that survives a failing cache backend without masking - * domain exceptions. Three outcomes are distinguished by where the - * failure happened relative to the fallback: - * - * - fallback loaded, then the cache store threw → return the loaded - * value (the cache write was best-effort); - * - the fallback itself threw → propagate untouched (that is a domain - * error like RecordNotFoundException, not a cache problem); - * - the cache probe threw before the fallback ran → load directly from - * the fallback. - * - * @param array $context - * @param callable $fallback - * @return mixed - */ - protected function guardedReadThrough(array $context, callable $fallback) - { - $started = false; - $resolved = false; - $value = null; - - $capturing = function () use ($fallback, &$started, &$resolved, &$value) { - $started = true; - $value = $fallback(); - $resolved = true; - - return $value; - }; - - try { - return $this->cacheableService->getWithCache(Operation::Read, $context, $capturing); - } catch (Throwable $e) { - if ($resolved) { - $this->loggerStrategy->warning( - 'Cache store failed after a successful load — serving the loaded value uncached.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return $value; - } - - if ($started) { - throw $e; - } - - $this->loggerStrategy->warning( - 'Cache read failed — loading directly from the fallback.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return $fallback(); - } - } - - /** @inheritDoc */ - public function hasRow(array $identityRow, ?string $generation = null): bool - { - $context = $this->rowContext($identityRow, $generation); - - if ($context === null) { - return false; - } - - try { - return $this->cacheableService->exists($context); - } catch (Throwable $e) { - // A probe failure reads as uncached — the caller falls through - // to the database. - $this->loggerStrategy->warning( - 'Row cache probe failed — treating the row as uncached.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return false; - } - } - - /** - * @inheritDoc - * - * The table context is a single undiscriminated slot: it holds exactly - * one set-level value per table (estimatedCount today). A second value - * would need a discriminator added to the context. - */ - public function readTableValue(callable $fallback) - { - $generation = $this->snapshotGeneration(); - - if ($this->isEphemeralGeneration($generation)) { - return $fallback(); - } - - return $this->guardedReadThrough($this->tableContext($generation), $fallback); - } - - /** - * @inheritDoc - * - * On generation-enabled tables, fields the adapter does not expose are - * skipped — the bump already covers rotation, and models with narrower - * serialization should not lose alias caching over it. On - * generation-disabled tables ANY unverifiable field fails verification - * (with a warning): this check is the only rotation defense those tables - * have, and a field that cannot be checked is exactly the field a - * rotation may have changed. - */ - public function matchesLookup(DataModel $model, array $ids): bool - { - $data = $this->modelAdapter->toArray($model); - - foreach ($ids as $field => $value) { - if (!array_key_exists($field, $data)) { - if ($this->useGenerations) { - continue; - } - - $this->loggerStrategy->warning( - 'Alias lookup field could not be verified — the model adapter does not expose it; treating the alias as stale.', - ['table' => $this->table->getName(), 'field' => $field] - ); - - return false; - } - - // Normalize both sides through the same rule cache keys use, so - // this comparison can never drift from key equality. - [$actual, $expected] = array_values($this->stringifyScalars([ - 'actual' => $data[$field], - 'expected' => $value, - ])); - - if ($actual !== $expected) { - return false; - } - } - - return true; - } - - /** @inheritDoc */ - public function invalidateAfterWrite(): ?string - { - try { - if (!$this->useGenerations) { - $this->deleteTableContext(); - - return null; - } - - return $this->bumpGeneration(); - } catch (Throwable $e) { - $this->loggerStrategy->error( - 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return null; - } - } - - /** @inheritDoc */ - public function resolveAliasedIdentity(array $ids, ?string $generation = null): ?array - { - try { - $aliased = $this->cacheableService->get($this->aliasContext($ids, $generation)); - } catch (CachedItemNotFoundException $e) { - return null; - } catch (Throwable $e) { - // A cache-layer failure reads as a miss: every alias caller has - // a database fallback, so a throwing backend degrades to - // uncached instead of breaking the lookup. - $this->loggerStrategy->warning( - 'Alias cache read failed — treating the alias as missing.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return null; - } - - return (is_array($aliased) && $this->isTableIdentity($aliased)) ? $aliased : null; - } - - /** - * Folds the current table generation into a cache context. The generation - * is an opaque token every writer replaces, which makes ALL previously - * written contexts for this table unreachable at once — O(1) table-wide - * invalidation with no transactions required, and the close for the - * cache-aside race where a slow reader SETs a stale row back after a - * writer invalidated it (the stale SET lands under the old generation). - * - * @param string|null $generation Snapshot to fold in; fetched fresh when omitted. - * - * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin - * @param array $context - * @return array - */ - protected function withGeneration(array $context, ?string $generation = null): array - { - if (!$this->useGenerations) { - return $context; - } - - $context['gen'] = $generation ?? $this->currentGeneration(); - - return $context; - } - - /** @inheritDoc */ - public function snapshotGeneration(): ?string - { - return $this->useGenerations ? $this->currentGeneration() : null; - } - - /** - * Replaces the table's generation token. Called after every write that - * committed at least one row. A no-op when generations are disabled for - * this table. - * - * @return string|null The freshly minted token — propagated through - * invalidateAfterWrite() for implementations that add - * post-write cache writes; null when generations are - * disabled. - */ - protected function bumpGeneration(): ?string - { - if (!$this->useGenerations) { - return null; - } - - $token = $this->mintGeneration(); - - $this->cacheableService->set($this->generationContext(), $token); - - return $token; - } - - /** - * Reads the current generation token for this table, minting one when - * absent (first read, or after eviction — both simply start a new - * generation with a cold table cache). - * - * Deliberately NOT getWithCache(): a policy whose shouldCache() declines - * this context would re-mint a token on every read, silently defeating - * generation stability, and each re-mint would broadcast CacheMissed - * noise. The token must live outside policy discretion. - * - * The get-then-set is last-write-wins rather than add-if-absent, and - * that is safe: every minter SETs its token before performing its DB - * read, and tokens are random — concurrent re-mints can only orphan - * each other's fresh entries (a cold-start hit-rate cost), never revive - * a stale one. - */ - protected function currentGeneration(): string - { - try { - $token = $this->cacheableService->get($this->generationContext()); - } catch (CachedItemNotFoundException $e) { - $token = null; - } catch (Throwable $e) { - // A read FAILURE (not a miss) mints an ephemeral token for this - // operation only, WITHOUT persisting it: if reads blip while - // writes still work, persisting would let every reader clobber - // a healthy token and wholesale-invalidate the table cache. - $this->loggerStrategy->warning( - 'Generation token read failed — using an ephemeral token for this operation.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - - return $this->mintEphemeralGeneration(); - } - - if (!is_string($token) || $token === '') { - $token = $this->mintGeneration(); - - try { - $this->cacheableService->set($this->generationContext(), $token); - } catch (Throwable $e) { - // Cache down: every operation mints its own token, so keys - // never match and reads fall through to the database — - // caching degrades to disabled instead of breaking reads. - $this->loggerStrategy->warning( - 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } - } - - return $token; - } - - /** - * The context the generation token itself lives under. Never carries a - * generation — it IS the generation. - * - * @return array - */ - protected function generationContext(): array - { - return ['type' => $this->model, 'generation' => true]; - } - - /** - * Mints an opaque, unique generation token. - */ - protected function mintGeneration(): string - { - return bin2hex(random_bytes(8)); - } - - /** - * Mints a token for a single operation during a token-read outage. The - * prefix marks it so every caching path skips: stores no-op and - * read-throughs go straight to their fallback, because entries keyed - * under a token nobody can ever read back are pure garbage written at - * read-traffic rate. - */ - protected function mintEphemeralGeneration(): string - { - return 'ephemeral-' . bin2hex(random_bytes(8)); - } - - /** - * True when the snapshot was minted during a token-read outage and no - * cache entry keyed under it can ever be served. - */ - protected function isEphemeralGeneration(?string $generation): bool - { - return $generation !== null && strpos($generation, 'ephemeral-') === 0; - } - - /** - * Normalizes scalar values to strings — the single normalization rule - * every cache key shape shares, so an int identity from a hydrated write - * and a string identity from the query strategy produce the same key. - * - * @param array $values - * @return array Same keys; scalar values stringified. - */ - protected function stringifyScalars(array $values): array - { - foreach ($values as $key => $value) { - if (is_bool($value)) { - // (string) false is '' — explicit '0' keeps booleans from - // colliding with empty strings in cache keys. - $values[$key] = $value ? '1' : '0'; - } elseif (is_scalar($value)) { - $values[$key] = (string) $value; - } - } - - return $values; - } -} diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 7a4cba9..105cfb7 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -8,7 +8,9 @@ use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Providers\DatabaseServiceProvider; -use PHPNomad\Database\Interfaces\RowCache; +use PHPNomad\Cache\Enums\Operation; +use PHPNomad\Cache\Exceptions\CachedItemNotFoundException; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Datastore\Exceptions\DatastoreErrorException; use PHPNomad\Datastore\Exceptions\DuplicateEntryException; @@ -31,14 +33,14 @@ trait WithDatastoreHandlerMethods */ protected string $model; protected ModelAdapter $modelAdapter; - protected ?RowCache $rowCacheService = null; + protected ?RowCacheContextAdapter $rowCacheContextAdapter = null; /** * @inheritDoc */ public function getEstimatedCount(): int { - return $this->rowCache()->readTableValue(function () { + return $this->readTableValueThrough(function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -204,7 +206,7 @@ public function create(array $attributes): DataModel // can seed the newest generation with a row another writer already // overwrote. The first read after create costs one DB round-trip // and is always correct. - $this->rowCache()->invalidateAfterWrite(); + $this->invalidateAfterWrite(); // Single-record broadcasts intentionally PROPAGATE listener // exceptions (the pre-PR contract): there are no sibling events to @@ -255,7 +257,7 @@ public function deleteWhere(array $conditions): void return; } - $generation = $this->rowCache()->snapshotGeneration(); + $generation = $this->snapshotGeneration(); $deleted = false; $broadcastQueue = []; @@ -273,12 +275,12 @@ public function deleteWhere(array $conditions): void // outside the matched set. $this->serviceProvider->queryStrategy->delete($this->table, $identityRow); - $identity = $this->rowCache()->rowIdentity($identityRow); + $identity = $this->deriveRowIdentity($identityRow); if ($identity !== null) { - // Swallow-and-log inside RowCache: cache trouble cannot - // abort the remaining SQL deletes. - $this->rowCache()->deleteRow($identity, $generation); + // deleteRowEntry() swallows-and-logs: cache trouble + // cannot abort the remaining SQL deletes. + $this->deleteRowEntry($identity, $generation); } $broadcastQueue[] = $identityRow; @@ -286,7 +288,7 @@ public function deleteWhere(array $conditions): void } } finally { if ($deleted) { - $this->rowCache()->invalidateAfterWrite(); + $this->invalidateAfterWrite(); } // Broadcasts are buffered and emitted after the SQL work so a @@ -395,18 +397,15 @@ protected function buildConditions(array $groups) } /** - * Lazily builds the per-table row-cache collaborator that owns every - * cache context this trait uses (canonical row identities, alias - * entries, generation tokens). Lazy `??=` instead of constructor - * injection because a trait cannot extend its consumer's constructor, - * and the factory needs $table/$model/$modelAdapter, which consumers - * set after construction. - * - * @see \PHPNomad\Database\Interfaces\RowCache + * Lazily builds the adapter that converts rows, identities, and lookup + * keys into the cache-context vocabulary. Lazy `??=` because a trait + * cannot extend its consumer's constructor, and the adapter derives + * entirely from $table/$model/$modelAdapter, which consumers set after + * construction. */ - protected function rowCache(): RowCache + protected function cacheContext(): RowCacheContextAdapter { - return $this->rowCacheService ??= $this->serviceProvider->rowCacheFactory->make( + return $this->rowCacheContextAdapter ??= new RowCacheContextAdapter( $this->table, $this->model, $this->modelAdapter, @@ -421,16 +420,366 @@ protected function rowCache(): RowCache * * What opting out costs: the cache-aside read-back race is only * TTL-bounded (a slow reader can write a just-invalidated row back), and - * set-level caches plus rotated aliases fall to precise handling - * (RowCache::invalidateAfterWrite()'s set-level delete and the read-time - * RowCache::matchesLookup() verification in findFromCompound()) instead - * of being orphaned wholesale by the bump. + * set-level caches plus rotated aliases fall to precise handling (the + * set-level delete in invalidateAfterWrite() and the read-time lookup + * verification in findFromCompound()) instead of being orphaned + * wholesale by the bump. */ protected function shouldUseTableGenerations(): bool { return true; } + /** + * Derives the canonical identity from row data, logging when the row + * cannot produce one (the adapter conversion itself is pure). + * + * @param array $row + * @return array|null + */ + protected function deriveRowIdentity(array $row): ?array + { + $identity = $this->cacheContext()->rowIdentity($row); + + if ($identity === null) { + $this->serviceProvider->loggerStrategy->warning( + 'Cannot derive a canonical cache identity — row is missing an identity field.', + ['table' => $this->table->getName(), 'rowFields' => array_keys($row)] + ); + } + + return $identity; + } + + /** + * Takes the generation snapshot an operation keys its contexts under — + * once, before any database query. One snapshot per operation is both + * the race fence (a stale write-back keyed pre-write can never collide + * with post-bump reader keys) and the round-trip bound. Null when + * generations are disabled. + */ + protected function snapshotGeneration(): ?string + { + return $this->shouldUseTableGenerations() ? $this->currentGeneration() : null; + } + + /** + * Reads the current generation token, minting one when absent. + * + * Deliberately NOT getWithCache(): a policy whose shouldCache() declines + * this context would re-mint a token on every read, silently defeating + * generation stability. The get-then-set is last-write-wins and safe: + * every minter SETs before its DB read and tokens are random, so + * concurrent re-mints can only orphan each other's fresh entries, never + * revive a stale one. + */ + protected function currentGeneration(): string + { + $context = $this->cacheContext()->generationContext(); + + try { + $token = $this->serviceProvider->cacheableService->get($context); + } catch (CachedItemNotFoundException $e) { + $token = null; + } catch (Throwable $e) { + // A read FAILURE (not a miss) mints an ephemeral token for this + // operation only, WITHOUT persisting: if reads blip while writes + // still work, persisting would let every reader clobber a + // healthy token and wholesale-invalidate the table cache. + $this->serviceProvider->loggerStrategy->warning( + 'Generation token read failed — using an ephemeral token for this operation.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return $this->cacheContext()->mintEphemeralGeneration(); + } + + if (!is_string($token) || $token === '') { + $token = $this->cacheContext()->mintGeneration(); + + try { + $this->serviceProvider->cacheableService->set($context, $token); + } catch (Throwable $e) { + // Cache down: every operation mints its own token, so keys + // never match and reads fall through to the database — + // caching degrades to disabled instead of breaking reads. + $this->serviceProvider->loggerStrategy->warning( + 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + } + } + + return $token; + } + + /** + * The one post-write invalidation step: bumps the generation when + * generations are on (orphaning every prior context for the table at + * once), precisely deletes the set-level context when they are off. + * Never throws — a cache failure after a committed database write must + * not fail the write, mask an in-flight exception, or suppress event + * broadcasts. + * + * @return string|null The fresh generation token, or null when + * generations are disabled or the cache failed. + */ + protected function invalidateAfterWrite(): ?string + { + try { + if (!$this->shouldUseTableGenerations()) { + $this->serviceProvider->cacheableService->delete($this->cacheContext()->tableContext(null)); + + return null; + } + + $token = $this->cacheContext()->mintGeneration(); + $this->serviceProvider->cacheableService->set($this->cacheContext()->generationContext(), $token); + + return $token; + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return null; + } + } + + /** + * Read-through for a canonical row entry. Under an ephemeral generation + * snapshot the cache is skipped entirely — no key under such a token can + * ever be read back. + * + * @param array $identity Canonical identity (from deriveRowIdentity()). + * @param string|null $generation Pre-query generation snapshot. + * @param callable $fallback Loads the model on miss; its result is cached. + * @return mixed + */ + protected function readRowThrough(array $identity, ?string $generation, callable $fallback) + { + if ($this->cacheContext()->isEphemeralGeneration($generation)) { + return $fallback(); + } + + return $this->guardedReadThrough($this->cacheContext()->identityContext($identity, $generation), $fallback); + } + + /** + * Read-through for the table's single set-level value (estimatedCount). + * + * @param callable $fallback + * @return mixed + */ + protected function readTableValueThrough(callable $fallback) + { + $generation = $this->snapshotGeneration(); + + if ($this->cacheContext()->isEphemeralGeneration($generation)) { + return $fallback(); + } + + return $this->guardedReadThrough($this->cacheContext()->tableContext($generation), $fallback); + } + + /** + * Read-through that survives a failing cache backend without masking + * domain exceptions. Three outcomes, distinguished by where the failure + * happened relative to the fallback: fallback loaded then the store + * threw → serve the loaded value; the fallback itself threw → propagate + * untouched (domain errors like RecordNotFoundException); the cache + * probe threw before the fallback ran → load directly. + * + * @param array $context + * @param callable $fallback + * @return mixed + */ + protected function guardedReadThrough(array $context, callable $fallback) + { + $started = false; + $resolved = false; + $value = null; + + $capturing = function () use ($fallback, &$started, &$resolved, &$value) { + $started = true; + $value = $fallback(); + $resolved = true; + + return $value; + }; + + try { + return $this->serviceProvider->cacheableService->getWithCache(Operation::Read, $context, $capturing); + } catch (Throwable $e) { + if ($resolved) { + $this->serviceProvider->loggerStrategy->warning( + 'Cache store failed after a successful load — serving the loaded value uncached.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return $value; + } + + if ($started) { + throw $e; + } + + $this->serviceProvider->loggerStrategy->warning( + 'Cache read failed — loading directly from the fallback.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return $fallback(); + } + } + + /** + * Whether a row entry exists for the given identity row. A probe failure + * reads as uncached — the caller falls through to the database. + * + * @param array $identityRow + * @param string|null $generation Pre-query generation snapshot. + */ + protected function hasRowEntry(array $identityRow, ?string $generation): bool + { + $context = $this->cacheContext()->rowContext($identityRow, $generation); + + if ($context === null) { + return false; + } + + try { + return $this->serviceProvider->cacheableService->exists($context); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Row cache probe failed — treating the row as uncached.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return false; + } + } + + /** + * Caches a row's model under its canonical context. No-ops under an + * ephemeral snapshot; swallows-and-logs cache failures — read paths must + * pass the snapshot they queried under (a token fetched at store time + * can postdate a concurrent write's bump and reopen the race). + * + * @param array $row + * @param DataModel $model + * @param string|null $generation Pre-query generation snapshot. + */ + protected function storeRowEntry(array $row, DataModel $model, ?string $generation): void + { + if ($this->cacheContext()->isEphemeralGeneration($generation)) { + return; + } + + $identity = $this->deriveRowIdentity($row); + + if ($identity === null) { + return; + } + + try { + $this->serviceProvider->cacheableService->set($this->cacheContext()->identityContext($identity, $generation), $model); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Could not cache a row — the next read will hit the database.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + } + } + + /** + * Stores an alias entry pointing a business key at a canonical identity. + * No-ops under an ephemeral snapshot; swallows-and-logs failures. + * + * @param array $ids + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + */ + protected function storeAliasEntry(array $ids, array $identity, ?string $generation): void + { + if ($this->cacheContext()->isEphemeralGeneration($generation)) { + return; + } + + try { + $this->serviceProvider->cacheableService->set($this->cacheContext()->aliasContext($ids, $generation), $identity); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Could not cache an alias — the next lookup will re-resolve from the database.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + } + } + + /** + * Deletes the row entry for a canonical identity; swallows-and-logs. + * + * @param array $identity + * @param string|null $generation Pre-query generation snapshot. + */ + protected function deleteRowEntry(array $identity, ?string $generation): void + { + try { + $this->serviceProvider->cacheableService->delete($this->cacheContext()->identityContext($identity, $generation)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + } + } + + /** + * Deletes an alias entry; swallows-and-logs. + * + * @param array $ids + * @param string|null $generation Pre-query generation snapshot. + */ + protected function deleteAliasEntry(array $ids, ?string $generation): void + { + try { + $this->serviceProvider->cacheableService->delete($this->cacheContext()->aliasContext($ids, $generation)); + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->error( + 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + } + } + + /** + * Reads the identity an alias entry points at, validated against the + * table's identity shape. Null on miss, malformed value, or a + * cache-layer read failure (logged). + * + * @param array $ids + * @param string|null $generation Pre-query generation snapshot. + * @return array|null + */ + protected function resolveAliasedIdentity(array $ids, ?string $generation): ?array + { + try { + $aliased = $this->serviceProvider->cacheableService->get($this->cacheContext()->aliasContext($ids, $generation)); + } catch (CachedItemNotFoundException $e) { + return null; + } catch (Throwable $e) { + $this->serviceProvider->loggerStrategy->warning( + 'Alias cache read failed — treating the alias as missing.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); + + return null; + } + + return (is_array($aliased) && $this->cacheContext()->isTableIdentity($aliased)) ? $aliased : null; + } + /** * @param array> $conditions @@ -457,14 +806,14 @@ public function findIds(array $conditions, ?int $limit = null, ?int $offset = nu protected function getModels(array $ids): array { // One generation snapshot for the whole operation, taken BEFORE any - // database read — see RowCache::storeRow() for why this must not be + // database read — see storeRowEntry() for why this must not be // re-fetched at write-back time. - $generation = $this->rowCache()->snapshotGeneration(); + $generation = $this->snapshotGeneration(); // Filter out the items that are currently in the cache. $idsToQuery = Arr::filter( $ids, - fn (array $identityRow) => !$this->rowCache()->hasRow($identityRow, $generation) + fn (array $identityRow) => !$this->hasRowEntry($identityRow, $generation) ); $hydrated = []; @@ -488,11 +837,11 @@ protected function getModels(array $ids): array // one list read into 1+N database queries. foreach ($data as $row) { $model = $this->modelAdapter->toModel($row); - $key = $this->rowCache()->identityKey($row); + $key = $this->cacheContext()->identityKey($row); if ($key !== null) { $hydrated[$key] = $model; - $this->rowCache()->storeRow($row, $model, $generation); + $this->storeRowEntry($row, $model, $generation); } } } @@ -503,7 +852,7 @@ protected function getModels(array $ids): array $models = []; foreach ($ids as $id) { - $key = $this->rowCache()->identityKey($id); + $key = $this->cacheContext()->identityKey($id); if ($key !== null && array_key_exists($key, $hydrated)) { $models[] = $hydrated[$key]; @@ -542,19 +891,19 @@ protected function findFromCompound(array $ids, ?string $generation = null) throw new RecordNotFoundException('Record cannot be found, no IDs provided.'); } - $generation = $generation ?? $this->rowCache()->snapshotGeneration(); + $generation = $generation ?? $this->snapshotGeneration(); // Canonical lookup: the caller's key IS the table identity, so the // row entry can be addressed directly (after normalizing order/types). - if ($this->rowCache()->isTableIdentity($ids)) { - $identity = $this->rowCache()->rowIdentity($ids); + if ($this->cacheContext()->isTableIdentity($ids)) { + $identity = $this->deriveRowIdentity($ids); - return $this->rowCache()->readRow($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); + return $this->readRowThrough($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); } // Business-key lookup: resolve through an alias entry so the row is // still cached exactly once, under its canonical identity. - $aliasedIdentity = $this->rowCache()->resolveAliasedIdentity($ids, $generation); + $aliasedIdentity = $this->resolveAliasedIdentity($ids, $generation); if ($aliasedIdentity !== null) { try { @@ -566,25 +915,40 @@ protected function findFromCompound(array $ids, ?string $generation = null) // tables nothing else would ever notice — the alias would // keep serving fresh-looking rows for a key they no longer // carry. - if ($this->rowCache()->matchesLookup($model, $ids)) { + $matches = $this->cacheContext()->matchesLookup($model, $ids); + + if ($matches === null) { + // Unverifiable: the adapter exposes none of the lookup + // fields. With generations on, the bump covers rotation + // and the alias can be trusted; without them this check + // is the ONLY rotation defense, so the alias is stale. + if ($this->shouldUseTableGenerations()) { + return $model; + } + + $this->serviceProvider->loggerStrategy->warning( + 'Alias lookup could not be verified — the model adapter exposes none of the lookup fields; treating the alias as stale.', + ['table' => $this->table->getName(), 'lookupFields' => array_keys($ids)] + ); + } elseif ($matches === true) { return $model; } - $this->rowCache()->deleteAlias($ids, $generation); + $this->deleteAliasEntry($ids, $generation); } catch (RecordNotFoundException $e) { // Stale alias — the row it points at moved or died. Drop it // and re-resolve from the database. - $this->rowCache()->deleteAlias($ids, $generation); + $this->deleteAliasEntry($ids, $generation); } } [$row, $model] = $this->queryRowAndModel($ids); - $identity = $this->rowCache()->rowIdentity($row); + $identity = $this->deriveRowIdentity($row); if ($identity !== null) { - $this->rowCache()->storeAlias($ids, $identity, $generation); - $this->rowCache()->storeRow($row, $model, $generation); + $this->storeAliasEntry($ids, $identity, $generation); + $this->storeRowEntry($row, $model, $generation); } return $model; @@ -636,7 +1000,7 @@ protected function queryRowAndModel(array $ids): array */ public function updateCompound($ids, array $attributes): void { - $generation = $this->rowCache()->snapshotGeneration(); + $generation = $this->snapshotGeneration(); // The pre-read warms the alias (business-key callers) or the row // entry (canonical callers) — which is what makes the canonical @@ -663,7 +1027,7 @@ public function updateCompound($ids, array $attributes): void // (falling back to model identity only when an adapter cannot expose // one): model identities can be shared by distinct rows, and a true // duplicate must not hide behind one. - $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes, $this->rowCache()->rowIdentity($identity), $record->getIdentity()); + $this->maybeThrowForDuplicateUniqueFieldsExcluding($attributes, $this->deriveRowIdentity($identity), $record->getIdentity()); // The SQL update targets the RESOLVED table identity AND the // caller's own lookup fields: the identity pins exactly one row (no @@ -673,27 +1037,27 @@ public function updateCompound($ids, array $attributes): void // no-op instead of updating a row that no longer carries the key. // (QueryStrategy::update() returns void, so a no-op write still // broadcasts; documented as a known limitation.) - $conditions = $this->rowCache()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); + $conditions = $this->cacheContext()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); $this->serviceProvider->queryStrategy->update($this->table, $conditions, $attributes); // The DB write is committed: the invalidation below is best-effort - // (RowCache mutations swallow-and-log cache failures) and runs under + // (the entry mutations swallow-and-log cache failures) and runs under // the pre-write generation — the one readers wrote their entries // with. The bump closes the cache-aside race; precise deletes carry // tables that opt out of generations. - $canonicalIdentity = $this->rowCache()->rowIdentity($identity); + $canonicalIdentity = $this->deriveRowIdentity($identity); if ($canonicalIdentity !== null) { - $this->rowCache()->deleteRow($canonicalIdentity, $generation); + $this->deleteRowEntry($canonicalIdentity, $generation); } - if (!$this->rowCache()->isTableIdentity($ids)) { + if (!$this->cacheContext()->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's // business key or identity out from under it. - $this->rowCache()->deleteAlias($ids, $generation); + $this->deleteAliasEntry($ids, $generation); } - $this->rowCache()->invalidateAfterWrite(); + $this->invalidateAfterWrite(); // The event intentionally carries the caller's key — the lookup // contract they wrote against — not the cache-normalized identity @@ -717,17 +1081,15 @@ public function updateCompound($ids, array $attributes): void */ protected function resolveTableIdentity(array $ids, ?string $generation = null): ?array { - $identityFields = array_flip($this->table->getFieldsForIdentity()); - // Raw caller/row values are preferred: this identity feeds the SQL // WHERE, and cache-key stringification is cache vocabulary that // should not leak into driver-typed comparisons. (The cache delete - // canonicalizes separately via rowIdentity().) - if ($this->rowCache()->isTableIdentity($ids)) { - return array_intersect_key($ids, $identityFields); + // canonicalizes separately.) + if ($this->cacheContext()->isTableIdentity($ids)) { + return $this->cacheContext()->rawIdentity($ids); } - $aliased = $this->rowCache()->resolveAliasedIdentity($ids, $generation); + $aliased = $this->resolveAliasedIdentity($ids, $generation); if ($aliased !== null) { // Alias entries store the canonical (stringified) identity — the @@ -745,9 +1107,7 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): try { [$row] = $this->queryRowAndModel($ids); - $identity = array_intersect_key($row, $identityFields); - - return count($identity) === count($identityFields) ? $identity : null; + return $this->cacheContext()->rawIdentity($row); } catch (RecordNotFoundException $e) { return null; } @@ -871,7 +1231,7 @@ protected function maybeThrowForDuplicateUniqueFieldsExcluding(array $data, ?arr if ($updateTableIdentity !== null || $updateModelIdentity !== null) { $duplicates = Arr::filter($duplicates, function (CanIdentify $existingItem) use ($updateTableIdentity, $updateModelIdentity) { - $existingTableIdentity = $this->rowCache()->rowIdentity($this->modelAdapter->toArray($existingItem)); + $existingTableIdentity = $this->deriveRowIdentity($this->modelAdapter->toArray($existingItem)); if ($existingTableIdentity !== null && $updateTableIdentity !== null) { return !Arr::containsSameData($existingTableIdentity, $updateTableIdentity); diff --git a/tests/Doubles/ExposedRowCache.php b/tests/Doubles/ExposedRowCache.php deleted file mode 100644 index 68e2732..0000000 --- a/tests/Doubles/ExposedRowCache.php +++ /dev/null @@ -1,44 +0,0 @@ - $row - * @return array - */ - public function exposeRowContext(array $row): array - { - $context = $this->rowContext($row); - - if ($context === null) { - throw new \RuntimeException('Test row cannot produce a full identity: ' . json_encode($row)); - } - - return $context; - } - - /** - * @param array $ids - * @return array - */ - public function exposeAliasContext(array $ids): array - { - return $this->aliasContext($ids); - } -} diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php new file mode 100644 index 0000000..bd5b86d --- /dev/null +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -0,0 +1,177 @@ + $identityFields + * @param ModelAdapter|null $modelAdapter + */ + private function makeAdapter(array $identityFields, bool $useGenerations = false, ?ModelAdapter $modelAdapter = null): RowCacheContextAdapter + { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); + $table->method('getFieldsForIdentity')->willReturn($identityFields); + + return new RowCacheContextAdapter( + $table, + IdentityRowModel::class, + $modelAdapter ?? new IdentityRowModelAdapter(), + $useGenerations + ); + } + + public function testRowContextIsTypeStableAcrossIntAndStringIdentities(): void + { + // Regression: MySQL returns identity columns as strings, but hydrated + // rows can hold them as ints. Without normalization the same record + // produces two distinct cache contexts and invalidation misses one. + $adapter = $this->makeAdapter(['id']); + + $this->assertNotNull($adapter->rowContext(['id' => 123], null)); + $this->assertSame( + $adapter->rowContext(['id' => 123], null), + $adapter->rowContext(['id' => '123'], null) + ); + } + + public function testRowIdentityReordersToTableOrderAndStringifies(): void + { + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame( + ['orgId' => '1', 'id' => '42'], + $adapter->rowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) + ); + } + + public function testRowIdentityIsNullWhenAFieldIsMissing(): void + { + // Pure conversion: null, no side effects — the datastore handler + // owns the logging. + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertNull($adapter->rowIdentity(['id' => 42])); + } + + public function testRawIdentityKeepsOriginalValueTypes(): void + { + // Raw projections feed SQL conditions, where cache stringification + // must not leak into driver-typed comparisons. + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame( + ['orgId' => 1, 'id' => 42], + $adapter->rawIdentity(['id' => 42, 'orgId' => 1, 'name' => 'extra']) + ); + } + + /** + * @return array, 1: bool}> + */ + public static function identityShapes(): array + { + return [ + 'exact identity' => [['orgId' => '1', 'id' => '42'], true], + 'reordered identity' => [['id' => '42', 'orgId' => '1'], true], + 'subset' => [['id' => '42'], false], + 'superset' => [['orgId' => '1', 'id' => '42', 'name' => 'x'], false], + 'different fields' => [['keyHash' => 'abc', 'status' => 'active'], false], + 'empty' => [[], false], + ]; + } + + /** + * @dataProvider identityShapes + * + * @param array $ids + */ + public function testIsTableIdentityMatchesFieldSetsNotOrder(array $ids, bool $expected): void + { + $adapter = $this->makeAdapter(['orgId', 'id']); + + $this->assertSame($expected, $adapter->isTableIdentity($ids)); + } + + /** + * @return array, 1: array, 2: bool|null}> + */ + public static function lookupComparisons(): array + { + return [ + 'same-type match' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'abc'], true], + 'same-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'xyz'], false], + 'int model vs string lookup' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '7'], true], + 'string model vs int lookup' => [['id' => '7', 'keyHash' => 'abc'], ['id' => 7], true], + 'cross-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '8'], false], + 'bool model vs stored zero' => [['id' => 7, 'active' => false], ['active' => '0'], true], + ]; + } + + /** + * @dataProvider lookupComparisons + * + * @param array $modelRow + * @param array $lookup + */ + public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRow, array $lookup, ?bool $expected): void + { + $adapter = $this->makeAdapter(['id']); + + $this->assertSame($expected, $adapter->matchesLookup(new IdentityRowModel($modelRow), $lookup)); + } + + public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void + { + // Tri-state: the adapter reports "unverifiable"; the datastore + // handler owns the policy for what that means per generation mode. + $adapter = $this->makeAdapter(['id'], false, new HidingModelAdapter()); + + $this->assertNull($adapter->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc'])); + } + + public function testWithGenerationFoldsTheTokenOnlyWhenEnabledAndPresent(): void + { + $generational = $this->makeAdapter(['id'], true); + $plain = $this->makeAdapter(['id'], false); + + $this->assertSame( + ['type' => IdentityRowModel::class, 'gen' => 'token-1'], + $generational->tableContext('token-1') + ); + $this->assertSame( + ['type' => IdentityRowModel::class], + $generational->tableContext(null), + 'A null snapshot must not fetch or invent a token — the handler owns token I/O.' + ); + $this->assertSame( + ['type' => IdentityRowModel::class], + $plain->tableContext('token-1'), + 'Generation-disabled tables never carry tokens.' + ); + } + + public function testEphemeralTokensAreRecognizedAndDistinct(): void + { + $adapter = $this->makeAdapter(['id'], true); + + $this->assertTrue($adapter->isEphemeralGeneration($adapter->mintEphemeralGeneration())); + $this->assertFalse($adapter->isEphemeralGeneration($adapter->mintGeneration())); + $this->assertFalse($adapter->isEphemeralGeneration(null)); + } +} diff --git a/tests/Unit/Services/DatastoreRowCacheTest.php b/tests/Unit/Services/DatastoreRowCacheTest.php deleted file mode 100644 index 402d189..0000000 --- a/tests/Unit/Services/DatastoreRowCacheTest.php +++ /dev/null @@ -1,263 +0,0 @@ -cacheStrategy = new ArrayCacheStrategy(); - $this->cacheableService = new CacheableService( - new NullEventStrategy(), - $this->cacheStrategy, - new SerializingCachePolicy() - ); - } - - /** - * @param array $identityFields - * @param ModelAdapter|null $adapter - */ - private function makeRowCache( - array $identityFields, - bool $useGenerations = false, - ?LoggerStrategy $logger = null, - ?ModelAdapter $adapter = null - ): ExposedRowCache { - $table = $this->createMock(Table::class); - $table->method('getName')->willReturn('test_records'); - $table->method('getFieldsForIdentity')->willReturn($identityFields); - - return new ExposedRowCache( - $this->cacheableService, - $logger ?? $this->createMock(LoggerStrategy::class), - $table, - IdentityRowModel::class, - $adapter ?? new IdentityRowModelAdapter(), - $useGenerations - ); - } - - public function testRowContextIsTypeStableAcrossIntAndStringIdentities(): void - { - // Regression: MySQL returns identity columns as strings, but hydrated - // rows can hold them as ints. Without normalization the same record - // produces two distinct cache contexts and invalidation misses one. - $rowCache = $this->makeRowCache(['id']); - - $intContext = $rowCache->exposeRowContext(['id' => 123]); - $stringContext = $rowCache->exposeRowContext(['id' => '123']); - - $this->assertNotNull($intContext); - $this->assertSame($intContext, $stringContext); - } - - public function testRowIdentityReordersToTableOrderAndStringifies(): void - { - $rowCache = $this->makeRowCache(['orgId', 'id']); - - $this->assertSame( - ['orgId' => '1', 'id' => '42'], - $rowCache->rowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) - ); - } - - public function testRowIdentityMissingFieldReturnsNullAndWarns(): void - { - $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->once()) - ->method('warning') - ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('missingField')); - - $rowCache = $this->makeRowCache(['orgId', 'id'], false, $logger); - - $this->assertNull($rowCache->rowIdentity(['id' => 42])); - } - /** - * @return array, 1: bool}> - */ - public static function identityShapes(): array - { - return [ - 'exact identity' => [['orgId' => '1', 'id' => '42'], true], - 'reordered identity' => [['id' => '42', 'orgId' => '1'], true], - 'subset' => [['id' => '42'], false], - 'superset' => [['orgId' => '1', 'id' => '42', 'name' => 'x'], false], - 'different fields' => [['keyHash' => 'abc', 'status' => 'active'], false], - 'empty' => [[], false], - ]; - } - - /** - * @dataProvider identityShapes - * - * @param array $ids - */ - public function testIsTableIdentityMatchesFieldSetsNotOrder(array $ids, bool $expected): void - { - $rowCache = $this->makeRowCache(['orgId', 'id']); - - $this->assertSame($expected, $rowCache->isTableIdentity($ids)); - } - - public function testResolveAliasedIdentityRejectsMalformedValues(): void - { - $rowCache = $this->makeRowCache(['id']); - - // Something that is not a valid table identity sits under the alias - // key (a bug, a poisoned cache, an old format) — it must never be - // dereferenced as an identity. - $this->cacheableService->set($rowCache->exposeAliasContext(['keyHash' => 'abc']), 'not-an-identity'); - - $this->assertNull($rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); - } - - public function testResolveAliasedIdentityReturnsStoredIdentity(): void - { - $rowCache = $this->makeRowCache(['id']); - - $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7'], null); - - $this->assertSame(['id' => '7'], $rowCache->resolveAliasedIdentity(['keyHash' => 'abc'])); - } - - /** - * @return array, 1: array, 2: bool}> - */ - public static function lookupComparisons(): array - { - return [ - 'same-type match' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'abc'], true], - 'same-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['keyHash' => 'xyz'], false], - 'int model vs string lookup' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '7'], true], - 'string model vs int lookup' => [['id' => '7', 'keyHash' => 'abc'], ['id' => 7], true], - 'cross-type mismatch' => [['id' => 7, 'keyHash' => 'abc'], ['id' => '8'], false], - ]; - } - - /** - * @dataProvider lookupComparisons - * - * @param array $modelRow - * @param array $lookup - */ - public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRow, array $lookup, bool $expected): void - { - $rowCache = $this->makeRowCache(['id']); - - $this->assertSame($expected, $rowCache->matchesLookup(new IdentityRowModel($modelRow), $lookup)); - } - - public function testMatchesLookupTreatsUnverifiableFieldAsStaleWithoutGenerations(): void - { - $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->once()) - ->method('warning') - ->with($this->stringContains('could not be verified'), $this->arrayHasKey('field')); - - $rowCache = $this->makeRowCache(['id'], false, $logger, new HidingModelAdapter()); - - $this->assertFalse( - $rowCache->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']), - 'An unverifiable lookup passed on a generation-disabled table — read-time verification is its only rotation defense.' - ); - } - - public function testMatchesLookupSkipsUnverifiableFieldWithGenerations(): void - { - $rowCache = $this->makeRowCache(['id'], true, null, new HidingModelAdapter()); - - $this->assertTrue( - $rowCache->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc']) - ); - } - /** - * @return array - */ - public static function generationModes(): array - { - return [ - 'generations on' => [true], - 'generations off' => [false], - ]; - } - - /** - * The contract lists invalidateAfterWrite among the swallow-and-log - * mutations: consumers call it unguarded after committed writes, so the - * default implementation must never let a cache failure escape. - * - * @dataProvider generationModes - */ - public function testInvalidateAfterWriteSwallowsCacheFailures(bool $useGenerations): void - { - $flaky = new FlakyCacheStrategy(); - $this->cacheStrategy = $flaky; - $this->cacheableService = new CacheableService( - new NullEventStrategy(), - $flaky, - new SerializingCachePolicy() - ); - - $logger = $this->createMock(LoggerStrategy::class); - $logger->expects($this->atLeastOnce())->method('error'); - - $rowCache = $this->makeRowCache(['id'], $useGenerations, $logger); - - $flaky->failWrites = true; - - $this->assertNull($rowCache->invalidateAfterWrite()); - } - - public function testInvalidateAfterWriteReturnsAFreshTokenWhenHealthy(): void - { - // The contrast that makes the failure-case null meaningful: with a - // healthy cache and generations on, a write yields the new token. - $rowCache = $this->makeRowCache(['id'], true); - - $this->assertNotNull($rowCache->invalidateAfterWrite()); - } - - public function testEphemeralSnapshotSkipsCachingEntirely(): void - { - // Nothing keyed under an ephemeral token can ever be read back, so - // stores must no-op and read-throughs must go straight to their - // fallback without touching the cache. - $rowCache = $this->makeRowCache(['id'], true); - - $rowCache->storeRow(['id' => '7', 'status' => 'active'], new IdentityRowModel(['id' => '7']), 'ephemeral-abc'); - $rowCache->storeAlias(['keyHash' => 'abc'], ['id' => '7'], 'ephemeral-abc'); - - $served = $rowCache->readRow(['id' => '7'], 'ephemeral-abc', fn () => 'from-fallback'); - - $this->assertSame('from-fallback', $served); - $this->assertSame([], $this->cacheStrategy->store, 'An ephemeral snapshot produced cache entries nobody can ever read.'); - } - -} - diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index b268511..315ffad 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -4,11 +4,10 @@ use PHPNomad\Cache\Services\CacheableService; use PHPNomad\Database\Interfaces\Table; -use PHPNomad\Database\Factories\DatastoreRowCacheFactory; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; -use PHPNomad\Database\Tests\Doubles\ExposedRowCache; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; @@ -57,11 +56,11 @@ class CanonicalRowCacheTest extends TestCase private ScriptedQueryStrategy $queryStrategy; /** - * Context prober mirroring the last-built handler's row-cache config; - * computes the same cache slots (shared cache = shared generation token) - * without widening the production RowCache contract. + * Context adapter mirroring the last-built handler's configuration; + * computes the same cache slots (shared cache = shared generation token, + * read by the probe helpers below). */ - private ExposedRowCache $prober; + private RowCacheContextAdapter $contextAdapter; protected function setUp(): void { @@ -106,15 +105,12 @@ private function makeHandler( new NoopQueryBuilder(), new NoopClauseBuilder(), $this->cacheableService, - $events ?? new NullEventStrategy(), - new DatastoreRowCacheFactory($this->cacheableService, $logger) + $events ?? new NullEventStrategy() ); $adapter = $adapter ?? new IdentityRowModelAdapter(); - $this->prober = new ExposedRowCache( - $this->cacheableService, - $logger, + $this->contextAdapter = new RowCacheContextAdapter( $table, IdentityRowModel::class, $adapter, @@ -131,6 +127,46 @@ private function makeHandler( ); } + /** + * Reads the live generation token straight from the cache, the way the + * datastore handler would snapshot it. + */ + private function currentToken(): ?string + { + try { + $token = $this->cacheableService->get($this->contextAdapter->generationContext()); + } catch (\Throwable $e) { + return null; + } + + return is_string($token) ? $token : null; + } + + /** + * The cache slot a row currently lives under (live token included). + * + * @param array $row + * @return array + */ + private function probeRowContext(array $row): array + { + $context = $this->contextAdapter->rowContext($row, $this->currentToken()); + \assert($context !== null); + + return $context; + } + + /** + * The cache slot an alias currently lives under (live token included). + * + * @param array $ids + * @return array + */ + private function probeAliasContext(array $ids): array + { + return $this->contextAdapter->aliasContext($ids, $this->currentToken()); + } + /** * Both generation modes must exhibit identical canonical-keying behavior; * generations only change WHICH key a context hashes to, never the @@ -163,7 +199,7 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG // Cached under the TABLE identity (orgId + id, stringified, table order)… $this->assertTrue( - $this->cacheableService->exists($this->prober->exposeRowContext(['orgId' => 1, 'id' => 42])), + $this->cacheableService->exists($this->probeRowContext(['orgId' => 1, 'id' => 42])), 'Row is not cached under its canonical table-identity context.' ); // …and NOT under the model's own (subset) identity. The probed shape @@ -209,7 +245,7 @@ public function testUpdateByBusinessKeyInvalidatesTheCanonicalRowEntry(bool $use // carries the key. $this->assertSame([['keyHash' => 'abc', 'id' => '7'], ['status' => 'revoked']], $this->queryStrategy->updates[0]); $this->assertFalse( - $this->cacheableService->exists($this->prober->exposeRowContext(['id' => '7'])), + $this->cacheableService->exists($this->probeRowContext(['id' => '7'])), 'Business-key update left the canonical row entry to serve stale reads.' ); @@ -252,7 +288,7 @@ private function healRotatedAlias(CanonicalHandler $handler): IdentityRowModel // The row dies out from under the alias (deleted / re-keyed outside // this process). Drop the row entry to simulate; the alias remains. - $this->cacheableService->delete($this->prober->exposeRowContext(['id' => '7'])); + $this->cacheableService->delete($this->probeRowContext(['id' => '7'])); // Alias → id 7 → miss → DB says id 7 is gone… $this->queryStrategy->queueQueryResult([]); @@ -325,7 +361,7 @@ public function testGenerationBumpMakesLateStaleWriteUnreachable(): void $stale = $handler->findByCompound(['id' => '7']); // A slow reader computed its context BEFORE the write… - $staleContext = $this->prober->exposeRowContext(['id' => '7']); + $staleContext = $this->probeRowContext(['id' => '7']); // …the writer updates and bumps the generation (its pre-read is // served from the cache — no query)… @@ -417,7 +453,7 @@ public function testRowMissingAnIdentityFieldIsNotCachedAndWarns(bool $useGenera $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce()) ->method('warning') - ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('missingField')); + ->with($this->stringContains('missing an identity field'), $this->arrayHasKey('rowFields')); $handler = $this->makeHandler(['orgId', 'id'], 'test_records', $useGenerations, $logger); @@ -455,7 +491,7 @@ public function testDeleteWhereDeletesByTableIdentityAndInvalidates(bool $useGen $this->assertSame([['orgId' => '1', 'id' => '42']], $this->queryStrategy->deletes); $this->assertFalse( - $this->cacheableService->exists($this->prober->exposeRowContext(['orgId' => '1', 'id' => '42'])), + $this->cacheableService->exists($this->probeRowContext(['orgId' => '1', 'id' => '42'])), 'deleteWhere left the canonical row entry behind.' ); @@ -633,7 +669,7 @@ public function testRotatedAliasLookupSurfacesNotFoundNotCacheErrorsUnderWriteFa // entry so the next lookup takes the re-read path. $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->findByCompound(['keyHash' => 'abc']); - $this->cacheableService->delete($this->prober->exposeRowContext(['id' => '7'])); + $this->cacheableService->delete($this->probeRowContext(['id' => '7'])); $flaky->failWrites = true; @@ -864,7 +900,7 @@ public function testMidLoopSqlFailureStillInvalidatesAndAnnouncesCompletedDelete $this->assertSame([['id' => '1']], $this->queryStrategy->deletes, 'Row 1 was not deleted before the failure.'); $this->assertCount(1, $events->ofType(RecordDeleted::class), 'A completed delete was not announced after a mid-loop failure.'); $this->assertFalse( - $this->cacheableService->exists($this->prober->exposeRowContext(['id' => '1'])), + $this->cacheableService->exists($this->probeRowContext(['id' => '1'])), 'The completed delete\'s row entry survived the failure.' ); } diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 89c6358..585a7e5 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -6,8 +6,7 @@ use PHPNomad\Database\Factories\Column; use PHPNomad\Database\Interfaces\QueryStrategy; use PHPNomad\Database\Interfaces\Table; -use PHPNomad\Database\Factories\DatastoreRowCacheFactory; -use PHPNomad\Database\Tests\Doubles\ExposedRowCache; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; @@ -73,8 +72,7 @@ public function testCreateHydratesFromAttributesWithoutPostInsertRead(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) + $eventStrategy ); $handler = new DummyDatastoreHandler( @@ -137,8 +135,7 @@ public function testCreateAppliesPhpDefaultsForMissingColumns(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) + $eventStrategy ); $handler = new DummyDatastoreHandler( @@ -186,8 +183,7 @@ public function testCreateRespectsCallerProvidedValuesOverPhpDefaults(): void new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) + $eventStrategy ); $handler = new DummyDatastoreHandler( @@ -242,8 +238,7 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) + $eventStrategy ); $handler = new DummyDatastoreHandler( @@ -259,8 +254,8 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // The deleted cache context must match what the read path wrote, // which used the int identity from the hydrated row. - $prober = new ExposedRowCache($cacheableService, $loggerStrategy, $table, TestModel::class, $modelAdapter, false); - $expected = $prober->exposeRowContext(['id' => 42]); + $contextAdapter = new RowCacheContextAdapter($table, TestModel::class, $modelAdapter, false); + $expected = $contextAdapter->rowContext(['id' => 42], null); $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); $this->assertSame(['type' => TestModel::class], $deletedKeys[1]); @@ -294,8 +289,7 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( new NoopQueryBuilder(), new NoopClauseBuilder(), $cacheableService, - $eventStrategy, - new DatastoreRowCacheFactory($cacheableService, $loggerStrategy) + $eventStrategy ); $handler = new DummyDatastoreHandler( From ec42e9427f957debc0c27d0ea3bd0f84694bbd13 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 07:45:23 -0400 Subject: [PATCH 19/26] fix(cache): restore per-field lookup strictness; harden the reshaped seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slow-loop round after the pattern reshape — one reviewer held every class to the documented vocabulary, the other verified the refactor against the previously-proven behaviors. Both found real things: - The reshape's tri-state matchesLookup had silently WEAKENED the generation-off rotation defense: it returned unverifiable only when ZERO lookup fields were exposed, so a lookup with one hidden field and matching visible fields passed as verified (reviewer reproduced it). Restored the original strictness — a lookup is verified only when EVERY field checks out; any hidden field yields unverifiable, which the gens-off policy treats as stale. Pinned at both the adapter level and the datastore-flow level with a field-hiding adapter double. - withGeneration() no longer fails open: a generation-keyed table refuses (InvalidArgumentException) to build an unkeyed context no bump could ever orphan. - The generations flag is captured exactly once, at adapter construction — the trait's mode checks now read the adapter, so a dynamic shouldUseTableGenerations() override can't split-brain invalidation against context keying. - Token creation moved out of the adapter (creation is orchestration, not conversion — the adapters doc says pure); the adapter keeps only token FORMATS (ephemeral prefix, validity), and the README's phantom RowCacheFactory reference is corrected. 75 tests, 166 assertions, green; adapter and doubles level-9 clean. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- lib/Adapters/RowCacheContextAdapter.php | 50 +++++++++++-------- lib/Traits/WithDatastoreHandlerMethods.php | 23 ++++++--- tests/Doubles/FieldHidingModelAdapter.php | 40 +++++++++++++++ .../Adapters/RowCacheContextAdapterTest.php | 34 ++++++++++--- tests/Unit/Traits/CanonicalRowCacheTest.php | 26 ++++++++++ 6 files changed, 140 insertions(+), 35 deletions(-) create mode 100644 tests/Doubles/FieldHidingModelAdapter.php diff --git a/README.md b/README.md index 5f5f9f4..95e3eed 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The `QueryBuilder` turns that array into parameterized SQL and runs it through w - Extend `IdentifiableDatabaseDatastoreHandler` as the base for any handler keyed by a single `id` column - Use the `WithDatastoreHandlerMethods` trait for CRUD, cache reads, cache invalidation, and event dispatch - Build reads and writes through `QueryBuilder` and `ClauseBuilder` with condition arrays instead of raw SQL -- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, `LoggerStrategy`, and the `RowCacheFactory` that builds each table's row cache +- Inject `DatabaseServiceProvider` into every handler to access `QueryBuilder`, `QueryStrategy`, `ClauseBuilder`, `CacheableService`, `EventStrategy`, and `LoggerStrategy`; each handler derives its own `RowCacheContextAdapter` for cache-key vocabulary - Rows are cached under canonical table-identity keys with per-table generation tokens; override `shouldUseTableGenerations()` on a handler to trade the generation token's race protection for a higher hit rate on write-hot tables - Use column factories like `PrimaryKeyFactory`, `DateCreatedFactory`, `DateModifiedFactory`, and `ForeignKeyFactory` for common column patterns - Model many-to-many relationships with `JunctionTable`, which handles compound primary keys and foreign key constraints diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php index c3691a3..06c9548 100644 --- a/lib/Adapters/RowCacheContextAdapter.php +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -217,40 +217,48 @@ public function generationContext(): array */ public function withGeneration(array $context, ?string $generation): array { - if (!$this->useGenerations || $generation === null) { + if (!$this->useGenerations) { return $context; } + if ($generation === null) { + // A generation-keyed table must never build an unkeyed context: + // no bump could ever orphan it, and it would collide with the + // generation-disabled shape. Failing loudly beats fail-open. + throw new \InvalidArgumentException( + 'A generation snapshot is required to build cache contexts for a generation-keyed table.' + ); + } + $context['gen'] = $generation; return $context; } /** - * Mints an opaque, unique generation token. + * Prefix marking a token minted during a token-read outage. Entries + * keyed under such a token can never be read back, so every caching + * path skips them. */ - public function mintGeneration(): string - { - return bin2hex(random_bytes(8)); - } + public const EPHEMERAL_GENERATION_PREFIX = 'ephemeral-'; /** - * Mints a token for a single operation during a token-read outage. The - * prefix marks it so every caching path skips: entries keyed under a - * token nobody can ever read back are pure garbage. + * True when the snapshot was minted during a token-read outage and no + * cache entry keyed under it can ever be served. */ - public function mintEphemeralGeneration(): string + public function isEphemeralGeneration(?string $generation): bool { - return 'ephemeral-' . bin2hex(random_bytes(8)); + return $generation !== null && strpos($generation, self::EPHEMERAL_GENERATION_PREFIX) === 0; } /** - * True when the snapshot was minted during a token-read outage and no - * cache entry keyed under it can ever be served. + * True when a stored value reads as a usable generation token. + * + * @param mixed $token */ - public function isEphemeralGeneration(?string $generation): bool + public function isValidGeneration($token): bool { - return $generation !== null && strpos($generation, 'ephemeral-') === 0; + return is_string($token) && $token !== ''; } /** @@ -258,10 +266,12 @@ public function isEphemeralGeneration(?string $generation): bool * the guard against an alias whose business key was rotated out from * under it by an identity-keyed update. * - * Tri-state so the caller owns policy: true = verified match, - * false = verified mismatch, null = unverifiable (the model adapter - * exposes none of the lookup fields). Exceptions thrown by the model - * adapter are domain errors and propagate. + * Tri-state so the caller owns policy: true = every lookup field + * verified and matching, false = a verified field mismatched, null = + * not fully verifiable (the model adapter hides at least one lookup + * field — and a field that cannot be checked is exactly the field a + * rotation may have changed). Exceptions thrown by the model adapter + * are domain errors and propagate. * * @param DataModel $model * @param array $ids The caller's lookup key. @@ -290,7 +300,7 @@ public function matchesLookup(DataModel $model, array $ids): ?bool } } - return $verified === 0 ? null : true; + return $verified === count($ids) ? true : null; } /** diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 105cfb7..1706be8 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -451,6 +451,15 @@ protected function deriveRowIdentity(array $row): ?array return $identity; } + /** + * Mints an opaque, unique generation token — creation lives with the + * orchestration; the adapter only knows token FORMATS. + */ + protected function mintGenerationToken(): string + { + return bin2hex(random_bytes(8)); + } + /** * Takes the generation snapshot an operation keys its contexts under — * once, before any database query. One snapshot per operation is both @@ -460,7 +469,7 @@ protected function deriveRowIdentity(array $row): ?array */ protected function snapshotGeneration(): ?string { - return $this->shouldUseTableGenerations() ? $this->currentGeneration() : null; + return $this->cacheContext()->usesGenerations() ? $this->currentGeneration() : null; } /** @@ -491,11 +500,11 @@ protected function currentGeneration(): string ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] ); - return $this->cacheContext()->mintEphemeralGeneration(); + return RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . $this->mintGenerationToken(); } - if (!is_string($token) || $token === '') { - $token = $this->cacheContext()->mintGeneration(); + if (!$this->cacheContext()->isValidGeneration($token)) { + $token = $this->mintGenerationToken(); try { $this->serviceProvider->cacheableService->set($context, $token); @@ -527,13 +536,13 @@ protected function currentGeneration(): string protected function invalidateAfterWrite(): ?string { try { - if (!$this->shouldUseTableGenerations()) { + if (!$this->cacheContext()->usesGenerations()) { $this->serviceProvider->cacheableService->delete($this->cacheContext()->tableContext(null)); return null; } - $token = $this->cacheContext()->mintGeneration(); + $token = $this->mintGenerationToken(); $this->serviceProvider->cacheableService->set($this->cacheContext()->generationContext(), $token); return $token; @@ -922,7 +931,7 @@ protected function findFromCompound(array $ids, ?string $generation = null) // fields. With generations on, the bump covers rotation // and the alias can be trusted; without them this check // is the ONLY rotation defense, so the alias is stale. - if ($this->shouldUseTableGenerations()) { + if ($this->cacheContext()->usesGenerations()) { return $model; } diff --git a/tests/Doubles/FieldHidingModelAdapter.php b/tests/Doubles/FieldHidingModelAdapter.php new file mode 100644 index 0000000..6646470 --- /dev/null +++ b/tests/Doubles/FieldHidingModelAdapter.php @@ -0,0 +1,40 @@ + + */ +class FieldHidingModelAdapter implements ModelAdapter +{ + /** + * @param array $hiddenFields + */ + public function __construct(private array $hiddenFields = []) + { + } + + /** + * @param array $array + */ + public function toModel(array $array): DataModel + { + return new IdentityRowModel($array); + } + + /** + * @return array + */ + public function toArray(DataModel $model): array + { + $row = $model instanceof IdentityRowModel ? $model->toRow() : []; + + return array_diff_key($row, array_flip($this->hiddenFields)); + } +} diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index bd5b86d..949df60 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -4,6 +4,7 @@ use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Interfaces\Table; +use PHPNomad\Database\Tests\Doubles\FieldHidingModelAdapter; use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; @@ -136,6 +137,19 @@ public function testMatchesLookupComparesScalarsTypeInsensitively(array $modelRo $this->assertSame($expected, $adapter->matchesLookup(new IdentityRowModel($modelRow), $lookup)); } + public function testMatchesLookupIsNullWhenAnyFieldIsHidden(): void + { + // A field that cannot be checked is exactly the field a rotation may + // have changed: matching exposed fields must not upgrade a partially + // verifiable lookup to a verified match. + $adapter = $this->makeAdapter(['id'], false, new FieldHidingModelAdapter(['tenantId'])); + + $this->assertNull($adapter->matchesLookup( + new IdentityRowModel(['id' => 7, 'keyHash' => 'abc', 'tenantId' => 9]), + ['keyHash' => 'abc', 'tenantId' => 999999] + )); + } + public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void { // Tri-state: the adapter reports "unverifiable"; the datastore @@ -154,11 +168,12 @@ public function testWithGenerationFoldsTheTokenOnlyWhenEnabledAndPresent(): void ['type' => IdentityRowModel::class, 'gen' => 'token-1'], $generational->tableContext('token-1') ); - $this->assertSame( - ['type' => IdentityRowModel::class], - $generational->tableContext(null), - 'A null snapshot must not fetch or invent a token — the handler owns token I/O.' - ); + try { + $generational->tableContext(null); + $this->fail('A generation-keyed table accepted an unkeyed context.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('generation snapshot is required', $e->getMessage()); + } $this->assertSame( ['type' => IdentityRowModel::class], $plain->tableContext('token-1'), @@ -168,10 +183,15 @@ public function testWithGenerationFoldsTheTokenOnlyWhenEnabledAndPresent(): void public function testEphemeralTokensAreRecognizedAndDistinct(): void { + // The adapter owns token FORMATS; the datastore handler owns token + // creation. $adapter = $this->makeAdapter(['id'], true); - $this->assertTrue($adapter->isEphemeralGeneration($adapter->mintEphemeralGeneration())); - $this->assertFalse($adapter->isEphemeralGeneration($adapter->mintGeneration())); + $this->assertTrue($adapter->isEphemeralGeneration(RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . 'abc123')); + $this->assertFalse($adapter->isEphemeralGeneration('abc123')); $this->assertFalse($adapter->isEphemeralGeneration(null)); + $this->assertTrue($adapter->isValidGeneration('abc123')); + $this->assertFalse($adapter->isValidGeneration('')); + $this->assertFalse($adapter->isValidGeneration(null)); } } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 315ffad..2c86c43 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -8,6 +8,7 @@ use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; use PHPNomad\Database\Adapters\RowCacheContextAdapter; +use PHPNomad\Database\Tests\Doubles\FieldHidingModelAdapter; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; @@ -925,6 +926,31 @@ public function testThrowingListenerDoesNotSuppressRemainingDeleteBroadcasts(): $this->assertCount(2, $events->ofType(RecordDeleted::class), 'A throwing listener suppressed a sibling RecordDeleted.'); } + public function testPartiallyVerifiableAliasLookupIsNotTrustedWithoutGenerations(): void + { + // The adapter hides tenantId; keyHash matches. One hidden lookup + // field must be enough to distrust the alias on a + // generation-disabled table — the hidden field is exactly the one a + // rotation may have changed. + $logger = $this->createMock(LoggerStrategy::class); + $logger->expects($this->atLeastOnce()) + ->method('warning') + ->with($this->stringContains('could not be verified')); + + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new FieldHidingModelAdapter(['tenantId'])); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'tenantId' => '9', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc', 'tenantId' => '9']); + + // Second lookup re-resolves from the database instead of trusting + // the partially verifiable alias. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'tenantId' => '9', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc', 'tenantId' => '9']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame(2, $this->queryStrategy->queryCount, 'A partially verifiable alias was trusted on a generation-disabled table.'); + } + } From cc19b5adce24dd9edfbe28681d61064a7258a226 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 09:21:51 -0400 Subject: [PATCH 20/26] test(cache): restore reshape-lost coverage; chore: level-9 cleanup in the datastore trait Round-2 audit batch: - Restore tests lost in the pattern reshape: gens-on unverifiable-alias trust, malformed-alias rejection, ephemeral store no-op, gens-off write-outage variants, partial-field-hiding strictness (87 tests). - Narrow types in the trait for phpstan level 9: generation token handling, canonical read guards against non-model cache values, typed row extraction, DataModel narrowing in duplicate checks. - countWhere/deleteWhere inherit the vendor contract docblocks instead of conflicting local @param shapes. Co-Authored-By: Claude Fable 5 --- lib/Traits/WithDatastoreHandlerMethods.php | 71 ++++++++++------- .../Adapters/RowCacheContextAdapterTest.php | 78 +++++++++++++------ tests/Unit/Traits/CanonicalRowCacheTest.php | 59 ++++++++++++-- 3 files changed, 152 insertions(+), 56 deletions(-) diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 1706be8..748a8d3 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -32,6 +32,10 @@ trait WithDatastoreHandlerMethods * @var class-string */ protected string $model; + + /** + * @var ModelAdapter + */ protected ModelAdapter $modelAdapter; protected ?RowCacheContextAdapter $rowCacheContextAdapter = null; @@ -40,7 +44,7 @@ trait WithDatastoreHandlerMethods */ public function getEstimatedCount(): int { - return $this->readTableValueThrough(function () { + return (int) $this->readTableValueThrough(function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); } @@ -95,11 +99,7 @@ public function orWhere(array $conditions, ?int $limit = null, ?int $offset = nu ], $limit, $offset, $orderBy, $order); } - /** - * @inheritDoc - * - * @param array> $conditions - */ + /** @inheritDoc */ public function countWhere(array $conditions): int { $this->initiateQuery( @@ -161,12 +161,13 @@ public function countOrWhere(array $conditions): int public function findBy(string $field, $value): DataModel { $result = $this->andWhere([['column' => $field, 'operator' => '=', 'value' => $value]], 1); + $model = $result[0] ?? null; - if(empty($result)){ - throw new RecordNotFoundException("Could not find a record where $field equals $value"); + if (!$model instanceof DataModel) { + throw new RecordNotFoundException(sprintf('Could not find a record where %s equals %s.', $field, $this->encodeExceptionContext(['value' => $value]))); } - return Arr::get($result, 0); + return $model; } /** @@ -245,7 +246,6 @@ protected function applyPhpDefaults(array $attributes): array * Each deleted row broadcasts a RecordDeleted carrying its raw identity * row; no matching rows is a silent no-op. * - * @param array> $conditions * @return void * @throws DatastoreErrorException */ @@ -503,20 +503,22 @@ protected function currentGeneration(): string return RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . $this->mintGenerationToken(); } - if (!$this->cacheContext()->isValidGeneration($token)) { - $token = $this->mintGenerationToken(); + if (is_string($token) && $this->cacheContext()->isValidGeneration($token)) { + return $token; + } - try { - $this->serviceProvider->cacheableService->set($context, $token); - } catch (Throwable $e) { - // Cache down: every operation mints its own token, so keys - // never match and reads fall through to the database — - // caching degrades to disabled instead of breaking reads. - $this->serviceProvider->loggerStrategy->warning( - 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] - ); - } + $token = $this->mintGenerationToken(); + + try { + $this->serviceProvider->cacheableService->set($context, $token); + } catch (Throwable $e) { + // Cache down: every operation mints its own token, so keys + // never match and reads fall through to the database — + // caching degrades to disabled instead of breaking reads. + $this->serviceProvider->loggerStrategy->warning( + 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ); } return $token; @@ -907,7 +909,20 @@ protected function findFromCompound(array $ids, ?string $generation = null) if ($this->cacheContext()->isTableIdentity($ids)) { $identity = $this->deriveRowIdentity($ids); - return $this->readRowThrough($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); + if ($identity !== null) { + $model = $this->readRowThrough($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); + + // A cached value that is not a model (a poisoned or + // old-format entry) must never be served — fall through to + // the database. + if ($model instanceof DataModel) { + return $model; + } + } + + [, $freshModel] = $this->queryRowAndModel($ids); + + return $freshModel; } // Business-key lookup: resolve through an alias entry so the row is @@ -988,9 +1003,9 @@ protected function queryRowAndModel(array $ids): array ->limit(1) ); - $item = Arr::get($items, 0); + $item = $items[0] ?? null; - if (!$item) { + if (!is_array($item) || $item === []) { throw new RecordNotFoundException(sprintf( 'Record not found in table "%s" using lookup key %s.', $this->table->getName(), @@ -1240,7 +1255,9 @@ protected function maybeThrowForDuplicateUniqueFieldsExcluding(array $data, ?arr if ($updateTableIdentity !== null || $updateModelIdentity !== null) { $duplicates = Arr::filter($duplicates, function (CanIdentify $existingItem) use ($updateTableIdentity, $updateModelIdentity) { - $existingTableIdentity = $this->deriveRowIdentity($this->modelAdapter->toArray($existingItem)); + $existingTableIdentity = $existingItem instanceof DataModel + ? $this->deriveRowIdentity($this->modelAdapter->toArray($existingItem)) + : null; if ($existingTableIdentity !== null && $updateTableIdentity !== null) { return !Arr::containsSameData($existingTableIdentity, $updateTableIdentity); diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index 949df60..cedaba0 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -159,39 +159,71 @@ public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void $this->assertNull($adapter->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc'])); } - public function testWithGenerationFoldsTheTokenOnlyWhenEnabledAndPresent(): void + public function testGenerationKeyedContextsCarryTheSnapshot(): void { - $generational = $this->makeAdapter(['id'], true); - $plain = $this->makeAdapter(['id'], false); - $this->assertSame( ['type' => IdentityRowModel::class, 'gen' => 'token-1'], - $generational->tableContext('token-1') + $this->makeAdapter(['id'], true)->tableContext('token-1') ); - try { - $generational->tableContext(null); - $this->fail('A generation-keyed table accepted an unkeyed context.'); - } catch (\InvalidArgumentException $e) { - $this->assertStringContainsString('generation snapshot is required', $e->getMessage()); - } + } + + public function testGenerationKeyedTablesRefuseUnkeyedContexts(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('generation snapshot is required'); + + $this->makeAdapter(['id'], true)->tableContext(null); + } + + public function testGenerationDisabledTablesNeverCarryTokens(): void + { $this->assertSame( ['type' => IdentityRowModel::class], - $plain->tableContext('token-1'), - 'Generation-disabled tables never carry tokens.' + $this->makeAdapter(['id'], false)->tableContext('token-1') ); } - public function testEphemeralTokensAreRecognizedAndDistinct(): void + /** + * The adapter owns token FORMATS; the datastore handler owns creation. + * + * @return array + */ + public static function generationTokenShapes(): array + { + return [ + 'ephemeral-prefixed token' => ['ephemeral-abc123', true], + 'plain token' => ['abc123', false], + 'no token' => [null, false], + ]; + } + + /** + * @dataProvider generationTokenShapes + */ + public function testEphemeralTokensAreRecognizedByPrefix(?string $token, bool $expected): void { - // The adapter owns token FORMATS; the datastore handler owns token - // creation. - $adapter = $this->makeAdapter(['id'], true); + $this->assertSame($expected, $this->makeAdapter(['id'], true)->isEphemeralGeneration($token)); + } - $this->assertTrue($adapter->isEphemeralGeneration(RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . 'abc123')); - $this->assertFalse($adapter->isEphemeralGeneration('abc123')); - $this->assertFalse($adapter->isEphemeralGeneration(null)); - $this->assertTrue($adapter->isValidGeneration('abc123')); - $this->assertFalse($adapter->isValidGeneration('')); - $this->assertFalse($adapter->isValidGeneration(null)); + /** + * @return array + */ + public static function tokenValidityShapes(): array + { + return [ + 'non-empty string' => ['abc123', true], + 'empty string' => ['', false], + 'null' => [null, false], + ]; + } + + /** + * @dataProvider tokenValidityShapes + * + * @param mixed $token + */ + public function testTokenValidityRequiresANonEmptyString($token, bool $expected): void + { + $this->assertSame($expected, $this->makeAdapter(['id'], true)->isValidGeneration($token)); } } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 2c86c43..c1dc409 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -555,14 +555,17 @@ private function useFlakyCache(): FlakyCacheStrategy return $flaky; } - public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(): void + /** + * @dataProvider generationModes + */ + public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void { $flaky = $this->useFlakyCache(); $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce())->method('error'); - $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); $flaky->failWrites = true; @@ -573,14 +576,17 @@ public function testCreateSurvivesCacheWriteFailureAndStillBroadcasts(): void $this->assertCount(1, $events->ofType(RecordCreated::class), 'A cache outage suppressed RecordCreated for a committed insert.'); } - public function testUpdateCompoundSurvivesCacheWriteFailureAndStillBroadcasts(): void + /** + * @dataProvider generationModes + */ + public function testUpdateCompoundSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void { $flaky = $this->useFlakyCache(); $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce())->method('error'); - $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); // Prime the row while the cache is healthy so the pre-read hits. $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'active']]); @@ -594,14 +600,17 @@ public function testUpdateCompoundSurvivesCacheWriteFailureAndStillBroadcasts(): $this->assertCount(1, $events->ofType(RecordUpdated::class), 'A cache outage suppressed RecordUpdated for a committed update.'); } - public function testDeleteWhereSurvivesCacheWriteFailureAndStillBroadcasts(): void + /** + * @dataProvider generationModes + */ + public function testDeleteWhereSurvivesCacheWriteFailureAndStillBroadcasts(bool $useGenerations): void { $flaky = $this->useFlakyCache(); $events = new RecordingEventStrategy(); $logger = $this->createMock(LoggerStrategy::class); $logger->expects($this->atLeastOnce())->method('error'); - $handler = $this->makeHandler(['id'], 'test_records', true, $logger, $events); + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations, $logger, $events); // Prime a generation token while the cache is healthy. $this->queryStrategy->queueQueryResult([['id' => '7', 'status' => 'doomed']]); @@ -835,6 +844,10 @@ public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void $this->assertArrayHasKey($key, $this->cacheStrategy->store, 'A read blip clobbered a healthy cache entry.'); } + // And nothing NEW was written: stores no-op under ephemeral tokens, + // so a blip cannot fill the cache with unreachable entries. + $this->assertCount(count($storeBefore), $this->cacheStrategy->store, 'An ephemeral-token read wrote unreachable cache entries.'); + $served = $handler->findByCompound(['id' => '7']); $this->assertSame('active', $served->get('status')); } @@ -951,6 +964,40 @@ public function testPartiallyVerifiableAliasLookupIsNotTrustedWithoutGenerations $this->assertSame(2, $this->queryStrategy->queryCount, 'A partially verifiable alias was trusted on a generation-disabled table.'); } + public function testUnverifiableAliasLookupIsTrustedWithGenerations(): void + { + // With generations on, the bump covers rotation — an unverifiable + // alias (adapter exposes nothing) is trusted and served query-free. + $handler = $this->makeHandler(['id'], 'test_records', true, null, null, new HidingModelAdapter()); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['keyHash' => 'abc']); + + $before = $this->queryStrategy->queryCount; + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + $this->assertSame($before, $this->queryStrategy->queryCount, 'An unverifiable alias was re-resolved despite generation coverage.'); + } + + public function testMalformedAliasValueIsNeverDereferencedAsAnIdentity(): void + { + // Old-format or corrupted alias entries (anything that is not a + // valid table identity) must fall through to the database. + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Establish a token so the poisoned slot matches what reads probe. + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'seed']]); + $handler->findByCompound(['id' => '1']); + + $this->cacheableService->set($this->probeAliasContext(['keyHash' => 'abc']), 'not-an-identity'); + + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $model = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $model->get('id')); + } + } From 1548aaec2e8e14f33c297d924f0a26d64dee6f48 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 09:33:32 -0400 Subject: [PATCH 21/26] fix(cache): repair poisoned canonical entries; align naming with repo conventions Round-3 audit batch: - A poisoned canonical row entry is now evicted and re-warmed from the database instead of silently degrading every read of that row to a query until the generation bumps (mirrors the alias path's handling). - Adapter conversion methods renamed to the repo's toX() vocabulary (toRowIdentity, toRowContext, ...); trait accessor renamed to getCacheContextAdapter(). - HidingModelAdapter renamed OpaqueModelAdapter to stop being one word away from FieldHidingModelAdapter. - Logger context key 'exception' renamed 'exceptionMessage'; findBy() failure message adopts the sibling lookup-key phrasing. - Convention cleanup: imported InvalidArgumentException, class constant and double properties hoisted to the top of their class bodies, use-statement ordering restored, phpstan.yml php_version quoted. Co-Authored-By: Claude Fable 5 --- .github/workflows/phpstan.yml | 2 +- lib/Adapters/RowCacheContextAdapter.php | 43 ++++---- lib/Traits/WithDatastoreHandlerMethods.php | 100 ++++++++++-------- ...odelAdapter.php => OpaqueModelAdapter.php} | 2 +- tests/Doubles/ScriptedQueryStrategy.php | 7 +- .../Adapters/RowCacheContextAdapterTest.php | 22 ++-- tests/Unit/Traits/CanonicalRowCacheTest.php | 45 ++++++-- .../WithDatastoreHandlerMethodsTest.php | 4 +- 8 files changed, 129 insertions(+), 96 deletions(-) rename tests/Doubles/{HidingModelAdapter.php => OpaqueModelAdapter.php} (92%) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 0fb80bb..df951e8 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -14,4 +14,4 @@ jobs: with: path: lib/ tests/ level: 9 - php_version: 8.2 \ No newline at end of file + php_version: "8.2" \ No newline at end of file diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php index 06c9548..11d89b2 100644 --- a/lib/Adapters/RowCacheContextAdapter.php +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -2,6 +2,7 @@ namespace PHPNomad\Database\Adapters; +use InvalidArgumentException; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; @@ -19,6 +20,13 @@ */ class RowCacheContextAdapter { + /** + * Prefix marking a token minted during a token-read outage. Entries + * keyed under such a token can never be read back, so every caching + * path skips them. + */ + public const EPHEMERAL_GENERATION_PREFIX = 'ephemeral-'; + protected Table $table; /** @@ -78,7 +86,7 @@ public function isTableIdentity(array $ids): bool * partial context must never become a cache key. (Pure: * the caller logs.) */ - public function rowIdentity(array $row): ?array + public function toRowIdentity(array $row): ?array { $identity = []; @@ -95,14 +103,14 @@ public function rowIdentity(array $row): ?array /** * Converts row data to its raw table-identity projection — same fields - * as rowIdentity(), original value types. This feeds SQL conditions, + * as toRowIdentity(), original value types. This feeds SQL conditions, * where cache-key stringification must not leak into driver-typed * comparisons. * * @param array $row * @return array|null */ - public function rawIdentity(array $row): ?array + public function toRawIdentity(array $row): ?array { $identity = []; @@ -124,9 +132,9 @@ public function rawIdentity(array $row): ?array * @param array $row * @return string|null Null when the row cannot produce a full identity. */ - public function identityKey(array $row): ?string + public function toIdentityKey(array $row): ?string { - $identity = $this->rowIdentity($row); + $identity = $this->toRowIdentity($row); return $identity === null ? null : serialize($identity); } @@ -138,22 +146,22 @@ public function identityKey(array $row): ?string * @param string|null $generation Pre-query generation snapshot. * @return array|null Null when the row cannot produce a full identity. */ - public function rowContext(array $row, ?string $generation = null): ?array + public function toRowContext(array $row, ?string $generation = null): ?array { - $identity = $this->rowIdentity($row); + $identity = $this->toRowIdentity($row); - return $identity === null ? null : $this->identityContext($identity, $generation); + return $identity === null ? null : $this->toIdentityContext($identity, $generation); } /** - * Wraps an already-canonical identity (from rowIdentity()) in the row + * Wraps an already-canonical identity (from toRowIdentity()) in the row * cache context. * * @param array $identity * @param string|null $generation Pre-query generation snapshot. * @return array */ - public function identityContext(array $identity, ?string $generation = null): array + public function toIdentityContext(array $identity, ?string $generation = null): array { return $this->withGeneration(['type' => $this->model, 'identities' => $this->stringifyScalars($identity)], $generation); } @@ -168,7 +176,7 @@ public function identityContext(array $identity, ?string $generation = null): ar * @param string|null $generation Pre-query generation snapshot. * @return array */ - public function aliasContext(array $ids, ?string $generation = null): array + public function toAliasContext(array $ids, ?string $generation = null): array { $normalized = $this->stringifyScalars($ids); @@ -185,7 +193,7 @@ public function aliasContext(array $ids, ?string $generation = null): array * @param string|null $generation Pre-query generation snapshot. * @return array */ - public function tableContext(?string $generation = null): array + public function toTableContext(?string $generation = null): array { return $this->withGeneration(['type' => $this->model], $generation); } @@ -196,7 +204,7 @@ public function tableContext(?string $generation = null): array * * @return array */ - public function generationContext(): array + public function toGenerationContext(): array { return ['type' => $this->model, 'generation' => true]; } @@ -225,7 +233,7 @@ public function withGeneration(array $context, ?string $generation): array // A generation-keyed table must never build an unkeyed context: // no bump could ever orphan it, and it would collide with the // generation-disabled shape. Failing loudly beats fail-open. - throw new \InvalidArgumentException( + throw new InvalidArgumentException( 'A generation snapshot is required to build cache contexts for a generation-keyed table.' ); } @@ -235,13 +243,6 @@ public function withGeneration(array $context, ?string $generation): array return $context; } - /** - * Prefix marking a token minted during a token-read outage. Entries - * keyed under such a token can never be read back, so every caching - * path skips them. - */ - public const EPHEMERAL_GENERATION_PREFIX = 'ephemeral-'; - /** * True when the snapshot was minted during a token-read outage and no * cache entry keyed under it can ever be served. diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 748a8d3..00b663d 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -164,7 +164,7 @@ public function findBy(string $field, $value): DataModel $model = $result[0] ?? null; if (!$model instanceof DataModel) { - throw new RecordNotFoundException(sprintf('Could not find a record where %s equals %s.', $field, $this->encodeExceptionContext(['value' => $value]))); + throw new RecordNotFoundException(sprintf('Could not find a record in table "%s" using lookup key %s.', $this->table->getName(), $this->encodeExceptionContext([$field => $value]))); } return $model; @@ -305,7 +305,7 @@ public function deleteWhere(array $conditions): void } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->error( 'A RecordDeleted listener failed; remaining deletion events still fire.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } } @@ -403,7 +403,7 @@ protected function buildConditions(array $groups) * entirely from $table/$model/$modelAdapter, which consumers set after * construction. */ - protected function cacheContext(): RowCacheContextAdapter + protected function getCacheContextAdapter(): RowCacheContextAdapter { return $this->rowCacheContextAdapter ??= new RowCacheContextAdapter( $this->table, @@ -439,7 +439,7 @@ protected function shouldUseTableGenerations(): bool */ protected function deriveRowIdentity(array $row): ?array { - $identity = $this->cacheContext()->rowIdentity($row); + $identity = $this->getCacheContextAdapter()->toRowIdentity($row); if ($identity === null) { $this->serviceProvider->loggerStrategy->warning( @@ -469,7 +469,7 @@ protected function mintGenerationToken(): string */ protected function snapshotGeneration(): ?string { - return $this->cacheContext()->usesGenerations() ? $this->currentGeneration() : null; + return $this->getCacheContextAdapter()->usesGenerations() ? $this->currentGeneration() : null; } /** @@ -484,7 +484,7 @@ protected function snapshotGeneration(): ?string */ protected function currentGeneration(): string { - $context = $this->cacheContext()->generationContext(); + $context = $this->getCacheContextAdapter()->toGenerationContext(); try { $token = $this->serviceProvider->cacheableService->get($context); @@ -497,13 +497,13 @@ protected function currentGeneration(): string // healthy token and wholesale-invalidate the table cache. $this->serviceProvider->loggerStrategy->warning( 'Generation token read failed — using an ephemeral token for this operation.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . $this->mintGenerationToken(); } - if (is_string($token) && $this->cacheContext()->isValidGeneration($token)) { + if (is_string($token) && $this->getCacheContextAdapter()->isValidGeneration($token)) { return $token; } @@ -517,7 +517,7 @@ protected function currentGeneration(): string // caching degrades to disabled instead of breaking reads. $this->serviceProvider->loggerStrategy->warning( 'Could not persist a table generation token — caching is effectively disabled until the cache recovers.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } @@ -538,20 +538,20 @@ protected function currentGeneration(): string protected function invalidateAfterWrite(): ?string { try { - if (!$this->cacheContext()->usesGenerations()) { - $this->serviceProvider->cacheableService->delete($this->cacheContext()->tableContext(null)); + if (!$this->getCacheContextAdapter()->usesGenerations()) { + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toTableContext(null)); return null; } $token = $this->mintGenerationToken(); - $this->serviceProvider->cacheableService->set($this->cacheContext()->generationContext(), $token); + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toGenerationContext(), $token); return $token; } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->error( 'Post-write cache invalidation failed — cached rows may serve stale data until TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return null; @@ -570,11 +570,11 @@ protected function invalidateAfterWrite(): ?string */ protected function readRowThrough(array $identity, ?string $generation, callable $fallback) { - if ($this->cacheContext()->isEphemeralGeneration($generation)) { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { return $fallback(); } - return $this->guardedReadThrough($this->cacheContext()->identityContext($identity, $generation), $fallback); + return $this->guardedReadThrough($this->getCacheContextAdapter()->toIdentityContext($identity, $generation), $fallback); } /** @@ -587,11 +587,11 @@ protected function readTableValueThrough(callable $fallback) { $generation = $this->snapshotGeneration(); - if ($this->cacheContext()->isEphemeralGeneration($generation)) { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { return $fallback(); } - return $this->guardedReadThrough($this->cacheContext()->tableContext($generation), $fallback); + return $this->guardedReadThrough($this->getCacheContextAdapter()->toTableContext($generation), $fallback); } /** @@ -626,7 +626,7 @@ protected function guardedReadThrough(array $context, callable $fallback) if ($resolved) { $this->serviceProvider->loggerStrategy->warning( 'Cache store failed after a successful load — serving the loaded value uncached.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return $value; @@ -638,7 +638,7 @@ protected function guardedReadThrough(array $context, callable $fallback) $this->serviceProvider->loggerStrategy->warning( 'Cache read failed — loading directly from the fallback.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return $fallback(); @@ -654,7 +654,7 @@ protected function guardedReadThrough(array $context, callable $fallback) */ protected function hasRowEntry(array $identityRow, ?string $generation): bool { - $context = $this->cacheContext()->rowContext($identityRow, $generation); + $context = $this->getCacheContextAdapter()->toRowContext($identityRow, $generation); if ($context === null) { return false; @@ -665,7 +665,7 @@ protected function hasRowEntry(array $identityRow, ?string $generation): bool } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->warning( 'Row cache probe failed — treating the row as uncached.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return false; @@ -684,7 +684,7 @@ protected function hasRowEntry(array $identityRow, ?string $generation): bool */ protected function storeRowEntry(array $row, DataModel $model, ?string $generation): void { - if ($this->cacheContext()->isEphemeralGeneration($generation)) { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { return; } @@ -695,11 +695,11 @@ protected function storeRowEntry(array $row, DataModel $model, ?string $generati } try { - $this->serviceProvider->cacheableService->set($this->cacheContext()->identityContext($identity, $generation), $model); + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toIdentityContext($identity, $generation), $model); } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->warning( 'Could not cache a row — the next read will hit the database.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } } @@ -714,16 +714,16 @@ protected function storeRowEntry(array $row, DataModel $model, ?string $generati */ protected function storeAliasEntry(array $ids, array $identity, ?string $generation): void { - if ($this->cacheContext()->isEphemeralGeneration($generation)) { + if ($this->getCacheContextAdapter()->isEphemeralGeneration($generation)) { return; } try { - $this->serviceProvider->cacheableService->set($this->cacheContext()->aliasContext($ids, $generation), $identity); + $this->serviceProvider->cacheableService->set($this->getCacheContextAdapter()->toAliasContext($ids, $generation), $identity); } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->warning( 'Could not cache an alias — the next lookup will re-resolve from the database.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } } @@ -737,11 +737,11 @@ protected function storeAliasEntry(array $ids, array $identity, ?string $generat protected function deleteRowEntry(array $identity, ?string $generation): void { try { - $this->serviceProvider->cacheableService->delete($this->cacheContext()->identityContext($identity, $generation)); + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toIdentityContext($identity, $generation)); } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->error( 'Could not delete a cached row — it may serve stale data until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } } @@ -755,11 +755,11 @@ protected function deleteRowEntry(array $identity, ?string $generation): void protected function deleteAliasEntry(array $ids, ?string $generation): void { try { - $this->serviceProvider->cacheableService->delete($this->cacheContext()->aliasContext($ids, $generation)); + $this->serviceProvider->cacheableService->delete($this->getCacheContextAdapter()->toAliasContext($ids, $generation)); } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->error( 'Could not delete a cached alias — it may serve a stale identity until the generation bump or TTL.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); } } @@ -776,19 +776,19 @@ protected function deleteAliasEntry(array $ids, ?string $generation): void protected function resolveAliasedIdentity(array $ids, ?string $generation): ?array { try { - $aliased = $this->serviceProvider->cacheableService->get($this->cacheContext()->aliasContext($ids, $generation)); + $aliased = $this->serviceProvider->cacheableService->get($this->getCacheContextAdapter()->toAliasContext($ids, $generation)); } catch (CachedItemNotFoundException $e) { return null; } catch (Throwable $e) { $this->serviceProvider->loggerStrategy->warning( 'Alias cache read failed — treating the alias as missing.', - ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exception' => $e->getMessage()] + ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); return null; } - return (is_array($aliased) && $this->cacheContext()->isTableIdentity($aliased)) ? $aliased : null; + return (is_array($aliased) && $this->getCacheContextAdapter()->isTableIdentity($aliased)) ? $aliased : null; } @@ -848,7 +848,7 @@ protected function getModels(array $ids): array // one list read into 1+N database queries. foreach ($data as $row) { $model = $this->modelAdapter->toModel($row); - $key = $this->cacheContext()->identityKey($row); + $key = $this->getCacheContextAdapter()->toIdentityKey($row); if ($key !== null) { $hydrated[$key] = $model; @@ -863,7 +863,7 @@ protected function getModels(array $ids): array $models = []; foreach ($ids as $id) { - $key = $this->cacheContext()->identityKey($id); + $key = $this->getCacheContextAdapter()->toIdentityKey($id); if ($key !== null && array_key_exists($key, $hydrated)) { $models[] = $hydrated[$key]; @@ -906,18 +906,26 @@ protected function findFromCompound(array $ids, ?string $generation = null) // Canonical lookup: the caller's key IS the table identity, so the // row entry can be addressed directly (after normalizing order/types). - if ($this->cacheContext()->isTableIdentity($ids)) { + if ($this->getCacheContextAdapter()->isTableIdentity($ids)) { $identity = $this->deriveRowIdentity($ids); if ($identity !== null) { $model = $this->readRowThrough($identity, $generation, fn () => $this->queryRowAndModel($ids)[1]); // A cached value that is not a model (a poisoned or - // old-format entry) must never be served — fall through to - // the database. + // old-format entry) must never be served — evict it and + // repair the slot from the database, mirroring how the + // alias path handles malformed entries. if ($model instanceof DataModel) { return $model; } + + $this->deleteRowEntry($identity, $generation); + + [$freshRow, $freshModel] = $this->queryRowAndModel($ids); + $this->storeRowEntry($freshRow, $freshModel, $generation); + + return $freshModel; } [, $freshModel] = $this->queryRowAndModel($ids); @@ -939,14 +947,14 @@ protected function findFromCompound(array $ids, ?string $generation = null) // tables nothing else would ever notice — the alias would // keep serving fresh-looking rows for a key they no longer // carry. - $matches = $this->cacheContext()->matchesLookup($model, $ids); + $matches = $this->getCacheContextAdapter()->matchesLookup($model, $ids); if ($matches === null) { // Unverifiable: the adapter exposes none of the lookup // fields. With generations on, the bump covers rotation // and the alias can be trusted; without them this check // is the ONLY rotation defense, so the alias is stale. - if ($this->cacheContext()->usesGenerations()) { + if ($this->getCacheContextAdapter()->usesGenerations()) { return $model; } @@ -1061,7 +1069,7 @@ public function updateCompound($ids, array $attributes): void // no-op instead of updating a row that no longer carries the key. // (QueryStrategy::update() returns void, so a no-op write still // broadcasts; documented as a known limitation.) - $conditions = $this->cacheContext()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); + $conditions = $this->getCacheContextAdapter()->isTableIdentity($ids) ? $identity : Arr::merge($ids, $identity); $this->serviceProvider->queryStrategy->update($this->table, $conditions, $attributes); // The DB write is committed: the invalidation below is best-effort @@ -1075,7 +1083,7 @@ public function updateCompound($ids, array $attributes): void $this->deleteRowEntry($canonicalIdentity, $generation); } - if (!$this->cacheContext()->isTableIdentity($ids)) { + if (!$this->getCacheContextAdapter()->isTableIdentity($ids)) { // Drop the alias too: the update may have moved the row's // business key or identity out from under it. $this->deleteAliasEntry($ids, $generation); @@ -1109,8 +1117,8 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): // WHERE, and cache-key stringification is cache vocabulary that // should not leak into driver-typed comparisons. (The cache delete // canonicalizes separately.) - if ($this->cacheContext()->isTableIdentity($ids)) { - return $this->cacheContext()->rawIdentity($ids); + if ($this->getCacheContextAdapter()->isTableIdentity($ids)) { + return $this->getCacheContextAdapter()->toRawIdentity($ids); } $aliased = $this->resolveAliasedIdentity($ids, $generation); @@ -1131,7 +1139,7 @@ protected function resolveTableIdentity(array $ids, ?string $generation = null): try { [$row] = $this->queryRowAndModel($ids); - return $this->cacheContext()->rawIdentity($row); + return $this->getCacheContextAdapter()->toRawIdentity($row); } catch (RecordNotFoundException $e) { return null; } diff --git a/tests/Doubles/HidingModelAdapter.php b/tests/Doubles/OpaqueModelAdapter.php similarity index 92% rename from tests/Doubles/HidingModelAdapter.php rename to tests/Doubles/OpaqueModelAdapter.php index 198fec2..fc0fbef 100644 --- a/tests/Doubles/HidingModelAdapter.php +++ b/tests/Doubles/OpaqueModelAdapter.php @@ -11,7 +11,7 @@ * * @implements ModelAdapter */ -class HidingModelAdapter implements ModelAdapter +class OpaqueModelAdapter implements ModelAdapter { /** * @param array $array diff --git a/tests/Doubles/ScriptedQueryStrategy.php b/tests/Doubles/ScriptedQueryStrategy.php index 39a2732..c0a28b4 100644 --- a/tests/Doubles/ScriptedQueryStrategy.php +++ b/tests/Doubles/ScriptedQueryStrategy.php @@ -22,6 +22,9 @@ class ScriptedQueryStrategy implements QueryStrategy public array $updates = []; /** @var array> */ public array $deletes = []; + /** @var int|null 1-indexed delete call that should throw. */ + public ?int $throwOnDeleteCall = null; + private int $deleteCalls = 0; /** * @param array> $result @@ -54,10 +57,6 @@ public function insert(Table $table, array $data): array return ['id' => 1]; } - /** @var int|null 1-indexed delete call that should throw. */ - public ?int $throwOnDeleteCall = null; - private int $deleteCalls = 0; - /** * @param array $ids */ diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index cedaba0..dc9f966 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -5,7 +5,7 @@ use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Tests\Doubles\FieldHidingModelAdapter; -use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; +use PHPNomad\Database\Tests\Doubles\OpaqueModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; use PHPNomad\Database\Tests\TestCase; @@ -44,10 +44,10 @@ public function testRowContextIsTypeStableAcrossIntAndStringIdentities(): void // produces two distinct cache contexts and invalidation misses one. $adapter = $this->makeAdapter(['id']); - $this->assertNotNull($adapter->rowContext(['id' => 123], null)); + $this->assertNotNull($adapter->toRowContext(['id' => 123], null)); $this->assertSame( - $adapter->rowContext(['id' => 123], null), - $adapter->rowContext(['id' => '123'], null) + $adapter->toRowContext(['id' => 123], null), + $adapter->toRowContext(['id' => '123'], null) ); } @@ -57,7 +57,7 @@ public function testRowIdentityReordersToTableOrderAndStringifies(): void $this->assertSame( ['orgId' => '1', 'id' => '42'], - $adapter->rowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) + $adapter->toRowIdentity(['id' => 42, 'name' => 'extra', 'orgId' => 1]) ); } @@ -67,7 +67,7 @@ public function testRowIdentityIsNullWhenAFieldIsMissing(): void // owns the logging. $adapter = $this->makeAdapter(['orgId', 'id']); - $this->assertNull($adapter->rowIdentity(['id' => 42])); + $this->assertNull($adapter->toRowIdentity(['id' => 42])); } public function testRawIdentityKeepsOriginalValueTypes(): void @@ -78,7 +78,7 @@ public function testRawIdentityKeepsOriginalValueTypes(): void $this->assertSame( ['orgId' => 1, 'id' => 42], - $adapter->rawIdentity(['id' => 42, 'orgId' => 1, 'name' => 'extra']) + $adapter->toRawIdentity(['id' => 42, 'orgId' => 1, 'name' => 'extra']) ); } @@ -154,7 +154,7 @@ public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void { // Tri-state: the adapter reports "unverifiable"; the datastore // handler owns the policy for what that means per generation mode. - $adapter = $this->makeAdapter(['id'], false, new HidingModelAdapter()); + $adapter = $this->makeAdapter(['id'], false, new OpaqueModelAdapter()); $this->assertNull($adapter->matchesLookup(new IdentityRowModel(['id' => 7, 'keyHash' => 'abc']), ['keyHash' => 'abc'])); } @@ -163,7 +163,7 @@ public function testGenerationKeyedContextsCarryTheSnapshot(): void { $this->assertSame( ['type' => IdentityRowModel::class, 'gen' => 'token-1'], - $this->makeAdapter(['id'], true)->tableContext('token-1') + $this->makeAdapter(['id'], true)->toTableContext('token-1') ); } @@ -172,14 +172,14 @@ public function testGenerationKeyedTablesRefuseUnkeyedContexts(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('generation snapshot is required'); - $this->makeAdapter(['id'], true)->tableContext(null); + $this->makeAdapter(['id'], true)->toTableContext(null); } public function testGenerationDisabledTablesNeverCarryTokens(): void { $this->assertSame( ['type' => IdentityRowModel::class], - $this->makeAdapter(['id'], false)->tableContext('token-1') + $this->makeAdapter(['id'], false)->toTableContext('token-1') ); } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index c1dc409..b431050 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -3,19 +3,19 @@ namespace PHPNomad\Database\Tests\Unit\Traits; use PHPNomad\Cache\Services\CacheableService; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\ArrayCacheStrategy; -use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Tests\Doubles\FieldHidingModelAdapter; use PHPNomad\Database\Tests\Doubles\FlakyCacheStrategy; -use PHPNomad\Database\Tests\Doubles\HidingModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; use PHPNomad\Database\Tests\Doubles\NoopQueryBuilder; use PHPNomad\Database\Tests\Doubles\NullEventStrategy; +use PHPNomad\Database\Tests\Doubles\OpaqueModelAdapter; use PHPNomad\Database\Tests\Doubles\RecordingEventStrategy; use PHPNomad\Database\Tests\Doubles\ScriptedQueryStrategy; use PHPNomad\Database\Tests\Doubles\SerializingCachePolicy; @@ -135,7 +135,7 @@ private function makeHandler( private function currentToken(): ?string { try { - $token = $this->cacheableService->get($this->contextAdapter->generationContext()); + $token = $this->cacheableService->get($this->contextAdapter->toGenerationContext()); } catch (\Throwable $e) { return null; } @@ -151,7 +151,7 @@ private function currentToken(): ?string */ private function probeRowContext(array $row): array { - $context = $this->contextAdapter->rowContext($row, $this->currentToken()); + $context = $this->contextAdapter->toRowContext($row, $this->currentToken()); \assert($context !== null); return $context; @@ -165,7 +165,7 @@ private function probeRowContext(array $row): array */ private function probeAliasContext(array $ids): array { - return $this->contextAdapter->aliasContext($ids, $this->currentToken()); + return $this->contextAdapter->toAliasContext($ids, $this->currentToken()); } /** @@ -635,7 +635,7 @@ public function testUnverifiableAliasLookupFallsBackToTheDatabaseWithoutGenerati ->method('warning') ->with($this->stringContains('could not be verified')); - $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new HidingModelAdapter()); + $handler = $this->makeHandler(['id'], 'test_records', false, $logger, null, new OpaqueModelAdapter()); $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->findByCompound(['keyHash' => 'abc']); @@ -968,7 +968,7 @@ public function testUnverifiableAliasLookupIsTrustedWithGenerations(): void { // With generations on, the bump covers rotation — an unverifiable // alias (adapter exposes nothing) is trusted and served query-free. - $handler = $this->makeHandler(['id'], 'test_records', true, null, null, new HidingModelAdapter()); + $handler = $this->makeHandler(['id'], 'test_records', true, null, null, new OpaqueModelAdapter()); $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); $handler->findByCompound(['keyHash' => 'abc']); @@ -998,9 +998,34 @@ public function testMalformedAliasValueIsNeverDereferencedAsAnIdentity(): void $this->assertSame('7', $model->get('id')); } -} + /** + * @dataProvider generationModes + */ + public function testPoisonedCanonicalEntryIsEvictedAndRepaired(bool $useGenerations): void + { + // A canonical slot holding something that is not a model must never + // be served — and it must not stay poisoned: the read repairs the + // slot so subsequent reads are cache hits again. + $handler = $this->makeHandler(['id'], 'test_records', $useGenerations); + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'seed']]); + $handler->findByCompound(['id' => '1']); + + $this->cacheableService->set($this->probeRowContext(['id' => '1']), 'not-a-model'); + + $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'active']]); + $model = $handler->findByCompound(['id' => '1']); + + $this->assertSame('active', $model->get('status')); + // No queued result remains, so a second read can only succeed if the + // slot was re-warmed — a query here throws by construction. + $repaired = $handler->findByCompound(['id' => '1']); + + $this->assertSame('active', $repaired->get('status')); + } + +} class CanonicalHandler { @@ -1009,8 +1034,8 @@ class CanonicalHandler private bool $useGenerations; /** - * @param class-string<\PHPNomad\Datastore\Interfaces\DataModel> $model - * @param ModelAdapter<\PHPNomad\Datastore\Interfaces\DataModel> $modelAdapter + * @param class-string $model + * @param ModelAdapter $modelAdapter */ public function __construct( DatabaseServiceProvider $serviceProvider, diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 585a7e5..60a366d 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -3,10 +3,10 @@ namespace PHPNomad\Database\Tests\Unit\Traits; use PHPNomad\Cache\Services\CacheableService; +use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Factories\Column; use PHPNomad\Database\Interfaces\QueryStrategy; use PHPNomad\Database\Interfaces\Table; -use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Providers\DatabaseServiceProvider; use PHPNomad\Database\Services\TableSchemaService; use PHPNomad\Database\Tests\Doubles\NoopClauseBuilder; @@ -255,7 +255,7 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo // The deleted cache context must match what the read path wrote, // which used the int identity from the hydrated row. $contextAdapter = new RowCacheContextAdapter($table, TestModel::class, $modelAdapter, false); - $expected = $contextAdapter->rowContext(['id' => 42], null); + $expected = $contextAdapter->toRowContext(['id' => 42], null); $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); $this->assertSame(['type' => TestModel::class], $deletedKeys[1]); From 711f0aa6976dff7109bc9edefee36548525b3108 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 09:44:07 -0400 Subject: [PATCH 22/26] refactor(cache): give the adapter full ownership of context and token formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 audit batch: - withGeneration() renamed toGenerationKeyedContext() and demoted to protected — it is a pure conversion, not a wither, and had no external callers. - The adapter can now produce the ephemeral token format it recognizes (toEphemeralGeneration); the trait mints randomness, the adapter owns the shape. Round-trip test pins the contract. Co-Authored-By: Claude Fable 5 --- lib/Adapters/RowCacheContextAdapter.php | 18 ++++++++++++++---- lib/Traits/WithDatastoreHandlerMethods.php | 2 +- .../Adapters/RowCacheContextAdapterTest.php | 7 +++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php index 11d89b2..1b4bce4 100644 --- a/lib/Adapters/RowCacheContextAdapter.php +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -163,7 +163,7 @@ public function toRowContext(array $row, ?string $generation = null): ?array */ public function toIdentityContext(array $identity, ?string $generation = null): array { - return $this->withGeneration(['type' => $this->model, 'identities' => $this->stringifyScalars($identity)], $generation); + return $this->toGenerationKeyedContext(['type' => $this->model, 'identities' => $this->stringifyScalars($identity)], $generation); } /** @@ -182,7 +182,7 @@ public function toAliasContext(array $ids, ?string $generation = null): array ksort($normalized); - return $this->withGeneration(['type' => $this->model, 'alias' => $normalized], $generation); + return $this->toGenerationKeyedContext(['type' => $this->model, 'alias' => $normalized], $generation); } /** @@ -195,7 +195,7 @@ public function toAliasContext(array $ids, ?string $generation = null): array */ public function toTableContext(?string $generation = null): array { - return $this->withGeneration(['type' => $this->model], $generation); + return $this->toGenerationKeyedContext(['type' => $this->model], $generation); } /** @@ -223,7 +223,7 @@ public function toGenerationContext(): array * * @see https://developer.wordpress.org/reference/functions/wp_cache_set_last_changed/ the pattern's origin */ - public function withGeneration(array $context, ?string $generation): array + protected function toGenerationKeyedContext(array $context, ?string $generation): array { if (!$this->useGenerations) { return $context; @@ -243,6 +243,16 @@ public function withGeneration(array $context, ?string $generation): array return $context; } + /** + * Marks a freshly minted token as ephemeral — the shape + * isEphemeralGeneration() recognizes. The adapter owns the format; the + * handler owns the minting. + */ + public function toEphemeralGeneration(string $token): string + { + return self::EPHEMERAL_GENERATION_PREFIX . $token; + } + /** * True when the snapshot was minted during a token-read outage and no * cache entry keyed under it can ever be served. diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index 00b663d..cb58ac3 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -500,7 +500,7 @@ protected function currentGeneration(): string ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] ); - return RowCacheContextAdapter::EPHEMERAL_GENERATION_PREFIX . $this->mintGenerationToken(); + return $this->getCacheContextAdapter()->toEphemeralGeneration($this->mintGenerationToken()); } if (is_string($token) && $this->getCacheContextAdapter()->isValidGeneration($token)) { diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index dc9f966..2806775 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -205,6 +205,13 @@ public function testEphemeralTokensAreRecognizedByPrefix(?string $token, bool $e $this->assertSame($expected, $this->makeAdapter(['id'], true)->isEphemeralGeneration($token)); } + public function testEphemeralMarkingRoundTrips(): void + { + $adapter = $this->makeAdapter(['id'], true); + + $this->assertTrue($adapter->isEphemeralGeneration($adapter->toEphemeralGeneration('abc123'))); + } + /** * @return array */ From d7b0697ac430679af935c788334938c9a68eea49 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 09:56:09 -0400 Subject: [PATCH 23/26] test(cache): pin the ephemeral no-op guarantee and alias-slot repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 audit batch (mutation-verified): - New outage test proves the direct-store paths (alias writes, list-read batch write-backs) write NOTHING under an ephemeral token — removing either no-op guard, or minting an unmarked outage token, now fails the suite. Previously both mutations passed green. - Malformed-alias coverage is a poison provider (string garbage, wrong-field array, identity subset) and asserts the slot is repaired, not just bypassed. - Blip test asserts byte-for-byte store equality; where() pins model content; the double expectExceptionMessage() (PHPUnit keeps only the last) replaced with explicit fragment assertions. Co-Authored-By: Claude Fable 5 --- tests/Unit/Traits/CanonicalRowCacheTest.php | 87 +++++++++++++++---- .../WithDatastoreHandlerMethodsTest.php | 14 +-- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index b431050..398cdc5 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -197,6 +197,8 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'name', 'operator' => '=', 'value' => 'first']]]]); $this->assertCount(1, $models); + $this->assertInstanceOf(IdentityRowModel::class, $models[0]); + $this->assertSame('first', $models[0]->get('name')); // Cached under the TABLE identity (orgId + id, stringified, table order)… $this->assertTrue( @@ -840,18 +842,46 @@ public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void // …and after recovery the original entries are untouched and served. $flaky->failReads = false; - foreach ($storeBefore as $key => $value) { - $this->assertArrayHasKey($key, $this->cacheStrategy->store, 'A read blip clobbered a healthy cache entry.'); - } - - // And nothing NEW was written: stores no-op under ephemeral tokens, - // so a blip cannot fill the cache with unreachable entries. - $this->assertCount(count($storeBefore), $this->cacheStrategy->store, 'An ephemeral-token read wrote unreachable cache entries.'); + // Byte-for-byte: nothing clobbered, nothing NEW written — stores + // no-op under ephemeral tokens, so a blip can neither corrupt healthy + // entries nor fill the cache with unreachable ones. + $this->assertSame($storeBefore, $this->cacheStrategy->store, 'A read blip changed the cache.'); $served = $handler->findByCompound(['id' => '7']); $this->assertSame('active', $served->get('status')); } + public function testAReadOutageNeverFillsTheCacheThroughDirectStorePaths(): void + { + // Ephemeral tokens must gate the DIRECT store paths (alias writes and + // the list-read batch write-back), not just read-throughs: during a + // read outage WRITES still work, so without the guard every lookup + // would land entries under a token that is never persisted — + // unbounded, unreachable cache garbage. + $flaky = $this->useFlakyCache(); + + $handler = $this->makeHandler(['id'], 'test_records', true); + + // Healthy read establishes a token and a canonical row entry. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $handler->findByCompound(['id' => '7']); + $storeBefore = $this->cacheStrategy->store; + + $flaky->failReads = true; + + // Business-key lookup — would store an alias and a row directly. + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $this->assertSame('active', $handler->findByCompound(['keyHash' => 'abc'])->get('status')); + + // List read — getModels() would batch-write rows back. + $this->queryStrategy->queueQueryResult([['id' => '7']]); + $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $models = $handler->where([['type' => 'AND', 'clauses' => [['column' => 'status', 'operator' => '=', 'value' => 'active']]]]); + + $this->assertCount(1, $models); + $this->assertSame($storeBefore, $this->cacheStrategy->store, 'A read outage wrote cache entries under an ephemeral token.'); + } + public function testDuplicateOnADifferentRowSharingTheModelIdentityIsStillCaught(): void { // Model identity (id only) is a SUBSET of the table identity @@ -980,22 +1010,49 @@ public function testUnverifiableAliasLookupIsTrustedWithGenerations(): void $this->assertSame($before, $this->queryStrategy->queryCount, 'An unverifiable alias was re-resolved despite generation coverage.'); } - public function testMalformedAliasValueIsNeverDereferencedAsAnIdentity(): void + /** + * An alias slot can hold garbage from a corrupted write or an older key + * vocabulary: string garbage, arrays with the wrong fields, or identity + * SUBSETS (old formats). None may ever be dereferenced as an identity. + * + * @return array, 1: mixed, 2: array, 3: array}> + */ + public static function malformedAliasPoisons(): array { - // Old-format or corrupted alias entries (anything that is not a - // valid table identity) must fall through to the database. - $handler = $this->makeHandler(['id'], 'test_records', true); + return [ + 'string garbage' => [['id'], 'not-an-identity', ['id' => '1', 'status' => 'seed'], ['id' => '7', 'keyHash' => 'abc', 'status' => 'active']], + 'wrong-field array' => [['id'], ['status' => 'active'], ['id' => '1', 'status' => 'seed'], ['id' => '7', 'keyHash' => 'abc', 'status' => 'active']], + 'identity subset (old format)' => [['id', 'tenantId'], ['id' => '7'], ['id' => '1', 'tenantId' => '1', 'status' => 'seed'], ['id' => '7', 'tenantId' => '2', 'keyHash' => 'abc', 'status' => 'active']], + ]; + } + + /** + * @dataProvider malformedAliasPoisons + * @param array $identityFields + * @param mixed $poison + * @param array $seedRow + * @param array $freshRow + */ + public function testMalformedAliasValueIsNeverDereferencedAsAnIdentity(array $identityFields, $poison, array $seedRow, array $freshRow): void + { + $handler = $this->makeHandler($identityFields, 'test_records', true); // Establish a token so the poisoned slot matches what reads probe. - $this->queryStrategy->queueQueryResult([['id' => '1', 'status' => 'seed']]); - $handler->findByCompound(['id' => '1']); + $this->queryStrategy->queueQueryResult([$seedRow]); + $handler->findByCompound(array_intersect_key($seedRow, array_flip($identityFields))); - $this->cacheableService->set($this->probeAliasContext(['keyHash' => 'abc']), 'not-an-identity'); + $this->cacheableService->set($this->probeAliasContext(['keyHash' => 'abc']), $poison); - $this->queryStrategy->queueQueryResult([['id' => '7', 'keyHash' => 'abc', 'status' => 'active']]); + $this->queryStrategy->queueQueryResult([$freshRow]); $model = $handler->findByCompound(['keyHash' => 'abc']); $this->assertSame('7', $model->get('id')); + + // And the slot is REPAIRED, not just bypassed: with nothing queued, + // a second lookup can only be served by the healed alias + row. + $repaired = $handler->findByCompound(['keyHash' => 'abc']); + + $this->assertSame('7', $repaired->get('id')); } /** diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 60a366d..6191f7d 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -300,11 +300,15 @@ public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing( $modelAdapter ); - $this->expectException(RecordNotFoundException::class); - $this->expectExceptionMessage('Record not found in table "test_records"'); - $this->expectExceptionMessage('"id":123'); - - $handler->findByIdentity(['id' => 123]); + try { + $handler->findByIdentity(['id' => 123]); + $this->fail('Expected RecordNotFoundException was not thrown.'); + } catch (RecordNotFoundException $e) { + // expectExceptionMessage() keeps only its LAST invocation, so + // both fragments are pinned explicitly. + $this->assertStringContainsString('Record not found in table "test_records"', $e->getMessage()); + $this->assertStringContainsString('"id":123', $e->getMessage()); + } } } From 4e1020e25cc7bc6c71576dea35eee4885e1b085b Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 10:03:15 -0400 Subject: [PATCH 24/26] chore(cache): coherence sweep after the audit rounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toRowIdentity() delegates to toRawIdentity() — the projection loop lived twice after two rounds of accretion. - Duplicate-filter test comment described the superseded model-identity mechanism; it now states the table-identity-first policy. - Cosmetics: dataProvider moved next to its consumer, import order, missing blank line. Co-Authored-By: Claude Fable 5 --- lib/Adapters/RowCacheContextAdapter.php | 12 ++----- .../Adapters/RowCacheContextAdapterTest.php | 2 +- tests/Unit/Traits/CanonicalRowCacheTest.php | 36 ++++++++++--------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php index 1b4bce4..5805287 100644 --- a/lib/Adapters/RowCacheContextAdapter.php +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -88,17 +88,9 @@ public function isTableIdentity(array $ids): bool */ public function toRowIdentity(array $row): ?array { - $identity = []; - - foreach ($this->table->getFieldsForIdentity() as $field) { - if (!array_key_exists($field, $row)) { - return null; - } - - $identity[$field] = $row[$field]; - } + $identity = $this->toRawIdentity($row); - return $this->stringifyScalars($identity); + return $identity === null ? null : $this->stringifyScalars($identity); } /** diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index 2806775..f2d23c2 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -5,9 +5,9 @@ use PHPNomad\Database\Adapters\RowCacheContextAdapter; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Tests\Doubles\FieldHidingModelAdapter; -use PHPNomad\Database\Tests\Doubles\OpaqueModelAdapter; use PHPNomad\Database\Tests\Doubles\IdentityRowModel; use PHPNomad\Database\Tests\Doubles\IdentityRowModelAdapter; +use PHPNomad\Database\Tests\Doubles\OpaqueModelAdapter; use PHPNomad\Database\Tests\TestCase; use PHPNomad\Datastore\Interfaces\DataModel; use PHPNomad\Datastore\Interfaces\ModelAdapter; diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index 398cdc5..d6317cc 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -183,6 +183,19 @@ public static function generationModes(): array ]; } + /** + * One cached row plus the generation-token entry the ON mode keeps. + * + * @return array + */ + public static function generationModesWithRowAndTokenEntryCounts(): array + { + return [ + 'generations on (production default)' => [true, 2], + 'generations off (opt-out)' => [false, 1], + ]; + } + /** * @dataProvider generationModesWithRowAndTokenEntryCounts */ @@ -435,19 +448,6 @@ public static function generationModesWithTokenOnlyEntryCounts(): array ]; } - /** - * One cached row plus the generation-token entry the ON mode keeps. - * - * @return array - */ - public static function generationModesWithRowAndTokenEntryCounts(): array - { - return [ - 'generations on (production default)' => [true, 2], - 'generations off (opt-out)' => [false, 1], - ]; - } - /** * @dataProvider generationModesWithTokenOnlyEntryCounts */ @@ -544,6 +544,7 @@ public function testDeleteWhereWithNoMatchesLeavesCacheUntouched(): void $this->assertSame($storeBefore, $this->cacheStrategy->store); $this->assertSame([], $this->queryStrategy->deletes); } + private function useFlakyCache(): FlakyCacheStrategy { $flaky = new FlakyCacheStrategy(); @@ -749,10 +750,11 @@ public function testListReadStaysBatchedWhenCacheWritesFail(): void public function testBusinessKeyUpdateResendingOwnUniqueValuesIsNotADuplicate(): void { - // Self-matches are filtered by the pre-read model's identity: a - // business-key caller re-sending the record's own unique values must - // not trip DuplicateEntryException just because their lookup key - // never equals a model identity. + // Self-matches are filtered by the pre-read record's TABLE identity + // (model identity only as a fallback): a business-key caller + // re-sending the record's own unique values must not trip + // DuplicateEntryException just because their lookup key never + // equals an identity. $handler = $this->makeHandler(['id'], 'test_api_keys', true, null, null, null, [['keyHash']]); // Pre-read resolves the record by business key… From e6d32676b5fb50fe85b6eb42d30113135e76ecd9 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 10:14:19 -0400 Subject: [PATCH 25/26] fix(cache): discriminate contexts by table name; polish the last audit nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-7 (fresh-eyes) batch: - Every cache context now carries the table name alongside the model class: nothing enforces a 1:1 model-to-table mapping, and two tables reusing one model must never cross-serve rows whose identities coincide. New adapter test pins it. (Key shape change — the PR already declares a one-time cold cache.) - deleteWhere() snapshots the generation before its first query, as the snapshot invariant documents. - An entry evicted between the cache service's exists() and get() is a normal LRU miss, not a failing backend — no more warning noise. - A poisoned set-level (estimatedCount) slot is bypassed with a direct database answer instead of being garbage-cast to int. Co-Authored-By: Claude Fable 5 --- lib/Adapters/RowCacheContextAdapter.php | 20 ++++++++++++++---- lib/Traits/WithDatastoreHandlerMethods.php | 21 ++++++++++++++++--- .../Adapters/RowCacheContextAdapterTest.php | 21 +++++++++++++++++-- tests/Unit/Traits/CanonicalRowCacheTest.php | 2 +- .../WithDatastoreHandlerMethodsTest.php | 5 +++-- 5 files changed, 57 insertions(+), 12 deletions(-) diff --git a/lib/Adapters/RowCacheContextAdapter.php b/lib/Adapters/RowCacheContextAdapter.php index 5805287..d31df86 100644 --- a/lib/Adapters/RowCacheContextAdapter.php +++ b/lib/Adapters/RowCacheContextAdapter.php @@ -155,7 +155,7 @@ public function toRowContext(array $row, ?string $generation = null): ?array */ public function toIdentityContext(array $identity, ?string $generation = null): array { - return $this->toGenerationKeyedContext(['type' => $this->model, 'identities' => $this->stringifyScalars($identity)], $generation); + return $this->toGenerationKeyedContext($this->baseContext() + ['identities' => $this->stringifyScalars($identity)], $generation); } /** @@ -174,7 +174,7 @@ public function toAliasContext(array $ids, ?string $generation = null): array ksort($normalized); - return $this->toGenerationKeyedContext(['type' => $this->model, 'alias' => $normalized], $generation); + return $this->toGenerationKeyedContext($this->baseContext() + ['alias' => $normalized], $generation); } /** @@ -187,7 +187,7 @@ public function toAliasContext(array $ids, ?string $generation = null): array */ public function toTableContext(?string $generation = null): array { - return $this->toGenerationKeyedContext(['type' => $this->model], $generation); + return $this->toGenerationKeyedContext($this->baseContext(), $generation); } /** @@ -198,7 +198,19 @@ public function toTableContext(?string $generation = null): array */ public function toGenerationContext(): array { - return ['type' => $this->model, 'generation' => true]; + return $this->baseContext() + ['generation' => true]; + } + + /** + * The discriminators every context carries. Model class AND table name: + * nothing enforces a 1:1 model-to-table mapping, and two tables sharing + * a model class must never cross-serve rows with coinciding identities. + * + * @return array + */ + protected function baseContext(): array + { + return ['type' => $this->model, 'table' => $this->table->getName()]; } /** diff --git a/lib/Traits/WithDatastoreHandlerMethods.php b/lib/Traits/WithDatastoreHandlerMethods.php index cb58ac3..209c2ca 100644 --- a/lib/Traits/WithDatastoreHandlerMethods.php +++ b/lib/Traits/WithDatastoreHandlerMethods.php @@ -44,9 +44,18 @@ trait WithDatastoreHandlerMethods */ public function getEstimatedCount(): int { - return (int) $this->readTableValueThrough(function () { + $count = $this->readTableValueThrough(function () { return $this->serviceProvider->queryStrategy->estimatedCount($this->table); }); + + if (is_numeric($count)) { + return (int) $count; + } + + // Poisoned set-level slot (row and alias slots evict-and-repair; + // this one is bypassed until the next invalidation) — serve the + // database's answer rather than a garbage cast. + return $this->serviceProvider->queryStrategy->estimatedCount($this->table); } /** @@ -251,13 +260,13 @@ protected function applyPhpDefaults(array $attributes): array */ public function deleteWhere(array $conditions): void { + $generation = $this->snapshotGeneration(); + try { $identityRows = $this->findIds([['type' => 'AND', 'clauses' => $conditions]]); } catch (RecordNotFoundException $e) { return; } - - $generation = $this->snapshotGeneration(); $deleted = false; $broadcastQueue = []; @@ -636,6 +645,12 @@ protected function guardedReadThrough(array $context, callable $fallback) throw $e; } + // An entry evicted between the service's exists() and get() is a + // normal miss under LRU pressure, not a failing backend — no log. + if ($e instanceof CachedItemNotFoundException) { + return $fallback(); + } + $this->serviceProvider->loggerStrategy->warning( 'Cache read failed — loading directly from the fallback.', ['table' => $this->table->getName(), 'exceptionClass' => get_class($e), 'exceptionMessage' => $e->getMessage()] diff --git a/tests/Unit/Adapters/RowCacheContextAdapterTest.php b/tests/Unit/Adapters/RowCacheContextAdapterTest.php index f2d23c2..5330f05 100644 --- a/tests/Unit/Adapters/RowCacheContextAdapterTest.php +++ b/tests/Unit/Adapters/RowCacheContextAdapterTest.php @@ -162,11 +162,28 @@ public function testMatchesLookupIsNullWhenNoFieldIsVerifiable(): void public function testGenerationKeyedContextsCarryTheSnapshot(): void { $this->assertSame( - ['type' => IdentityRowModel::class, 'gen' => 'token-1'], + ['type' => IdentityRowModel::class, 'table' => 'test_records', 'gen' => 'token-1'], $this->makeAdapter(['id'], true)->toTableContext('token-1') ); } + public function testContextsNeverCollideAcrossTablesSharingAModel(): void + { + // Nothing enforces a 1:1 model-to-table mapping: two tables reusing + // one model class must never cross-serve rows whose identities + // coincide, so every context carries the table name too. + $other = $this->createMock(Table::class); + $other->method('getName')->willReturn('other_records'); + $other->method('getFieldsForIdentity')->willReturn(['id']); + + $otherAdapter = new RowCacheContextAdapter($other, IdentityRowModel::class, new IdentityRowModelAdapter(), true); + + $this->assertNotSame( + $this->makeAdapter(['id'], true)->toRowContext(['id' => '7'], 'token-1'), + $otherAdapter->toRowContext(['id' => '7'], 'token-1') + ); + } + public function testGenerationKeyedTablesRefuseUnkeyedContexts(): void { $this->expectException(\InvalidArgumentException::class); @@ -178,7 +195,7 @@ public function testGenerationKeyedTablesRefuseUnkeyedContexts(): void public function testGenerationDisabledTablesNeverCarryTokens(): void { $this->assertSame( - ['type' => IdentityRowModel::class], + ['type' => IdentityRowModel::class, 'table' => 'test_records'], $this->makeAdapter(['id'], false)->toTableContext('token-1') ); } diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index d6317cc..b55dcdf 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -223,7 +223,7 @@ public function testWhereCachesRowsUnderTableIdentityNotModelIdentity(bool $useG // exact entry count below is the general guard — one row entry plus // the generation token when generations are on. $this->assertFalse( - $this->cacheableService->exists(['type' => IdentityRowModel::class, 'identities' => ['id' => '42']]), + $this->cacheableService->exists(['type' => IdentityRowModel::class, 'table' => 'test_records', 'identities' => ['id' => '42']]), 'Row leaked a cache entry keyed by the MODEL identity.' ); $this->assertCount($expectedEntries, $this->cacheStrategy->store, 'Unexpected cache entries beyond the row (and generation token).'); diff --git a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php index 6191f7d..798ca6e 100644 --- a/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php +++ b/tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php @@ -51,9 +51,10 @@ public function testCreateHydratesFromAttributesWithoutPostInsertRead(): void $cacheableService->expects($this->never())->method('set'); $cacheableService->expects($this->once()) ->method('delete') - ->with(['type' => TestModel::class]); + ->with(['type' => TestModel::class, 'table' => 'test_records']); $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); $table->method('getFieldsForIdentity')->willReturn(['id']); $table->method('getColumns')->willReturn([]); @@ -258,7 +259,7 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo $expected = $contextAdapter->toRowContext(['id' => 42], null); $this->assertCount(2, $deletedKeys); $this->assertSame($expected, $deletedKeys[0]); - $this->assertSame(['type' => TestModel::class], $deletedKeys[1]); + $this->assertSame(['type' => TestModel::class, 'table' => 'test_records'], $deletedKeys[1]); } public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing(): void From 40de4f52bb516977c4da5a88920841cc6f6e73a3 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 12 Jul 2026 11:20:56 -0400 Subject: [PATCH 26/26] test(cache): pin the poisoned estimated-count bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-8 verification confirmed everything else clean; this was the one mutation that still passed green — reverting the is_numeric guard to a bare (int) cast went unnoticed. Now a poisoned set-level slot serving a garbage-cast 0 instead of the database's answer fails the suite. Co-Authored-By: Claude Fable 5 --- tests/Unit/Traits/CanonicalRowCacheTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Unit/Traits/CanonicalRowCacheTest.php b/tests/Unit/Traits/CanonicalRowCacheTest.php index b55dcdf..c67fc6f 100644 --- a/tests/Unit/Traits/CanonicalRowCacheTest.php +++ b/tests/Unit/Traits/CanonicalRowCacheTest.php @@ -822,6 +822,21 @@ public function testEstimatedCountSurvivesACacheThatFailsOnReads(): void $this->assertSame(9, $handler->getEstimatedCount()); } + public function testPoisonedEstimatedCountSlotIsBypassedNotServed(): void + { + // The set-level slot is not evict-and-repaired like row/alias slots, + // but a poisoned value must never be garbage-cast and served — the + // database answers instead. + $handler = $this->makeHandler(['id'], 'test_records', true); + + $this->queryStrategy->estimatedCountValue = 9; + $this->assertSame(9, $handler->getEstimatedCount()); + + $this->cacheableService->set($this->contextAdapter->toTableContext($this->currentToken()), 'garbage'); + + $this->assertSame(9, $handler->getEstimatedCount()); + } + public function testGenerationReadBlipDoesNotClobberAHealthyToken(): void { // A read FAILURE mints an ephemeral token without persisting: when