Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions lib/Traits/WithDatastoreHandlerMethods.php
Original file line number Diff line number Diff line change
Expand Up @@ -456,11 +456,96 @@ public function updateCompound($ids, array $attributes): void
$record = $this->findFromCompound($ids);
$this->maybeThrowForDuplicateUniqueFields($attributes, $ids);

// Resolve the row's table identity (primary columns) BEFORE the
// update, while the row still matches $ids. where()/getModels()
// hydration caches each row under its table identity, so an update
// keyed on a compound business key (e.g. a config's
// type/subtype/configKey) must invalidate that entry too — deleting
// only the caller-provided context leaves the identity-keyed entry
// serving stale reads until TTL.
$identities = $this->resolveTableIdentitiesForUpdate($ids);

$this->serviceProvider->queryStrategy->update($this->table, $ids, $attributes);
$this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($ids));

foreach ($identities as $identity) {
$this->serviceProvider->cacheableService->delete($this->getCacheContextForItem($identity));
}

$this->serviceProvider->eventStrategy->broadcast(new RecordUpdated($record::class, $ids, $attributes));
}

/**
* Resolves the table-identity (primary column) values for rows matching
* the given compound key — the identity arrays the where()/getModels()
* read path keys its cache entries on.
*
* @param array<string, int|string> $ids
* @return array<int, array<string, int|string>>
* @throws DatastoreErrorException
*/
protected function resolveTableIdentitiesForUpdate(array $ids): array
{
$identityFields = $this->table->getFieldsForIdentity();

// Nothing to resolve when the table defines no identity fields, or
// when the caller's key IS the table identity (the standard
// update-by-id path) — the direct context deletion already covers it.
if (empty($identityFields) || array_keys($ids) === $identityFields) {
return [];
}

// Same fields, different ORDER: the cache context is order-sensitive,
// so reorder to table order locally — no query needed.
if (count($ids) === count($identityFields)
&& array_diff(array_keys($ids), $identityFields) === []
) {
$reordered = [];
foreach ($identityFields as $field) {
$reordered[$field] = $ids[$field];
}

return [$reordered];
}

// Genuinely different key: resolve the identity from the database.
// Invalidation is best-effort — a resolution failure must degrade to
// a possibly-stale cache entry, never break the write itself.
try {
$clauseBuilder = (clone $this->serviceProvider->clauseBuilder)->reset()->useTable($this->table);

foreach ($ids as $key => $id) {
$clauseBuilder->andWhere($key, '=', $id);
}

// from() BEFORE select(): the builder prefixes selected columns
// with the CURRENT table's alias, which is stale until from()
// runs — the same call order every read path in this trait uses.
$rows = $this->serviceProvider->queryStrategy->query(
$this->serviceProvider->queryBuilder
->from($this->table)
->select(...$identityFields)
->where($clauseBuilder)
->limit(1)
);
} catch (RecordNotFoundException $e) {
return [];
} catch (\Throwable $e) {
$this->serviceProvider->loggerStrategy->warning(
'Cache-invalidation identity resolution failed; a stale cache entry may persist until TTL: ' . $e->getMessage(),
['table' => $this->table->getName()]
);

return [];
}

// Project to identity fields regardless of what the query strategy
// returned — the cache context must contain identity values only.
$identityFlip = array_flip($identityFields);

return array_map(fn(array $row) => array_intersect_key($row, $identityFlip), $rows);
}

/**
* @param array $attributes
* @param array $fields
Expand Down
59 changes: 59 additions & 0 deletions tests/Unit/Traits/WithDatastoreHandlerMethodsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,65 @@ public function testUpdateCompoundInvalidatesCacheRegardlessOfIdentityType(): vo
$this->assertSame($expected, $deletedKeys[0]);
}

public function testUpdateByCompoundBusinessKeyInvalidatesIdentityKeyedEntry(): void
{
// where()/getModels() hydration caches rows under the TABLE identity.
// An update keyed on a compound business key (e.g. a config's
// type/subtype/configKey) must invalidate that identity-keyed entry
// too, or reads keep serving the pre-update row until TTL.
$loggerStrategy = $this->createMock(LoggerStrategy::class);
$eventStrategy = $this->createMock(EventStrategy::class);
$queryStrategy = $this->createMock(QueryStrategy::class);

$deletedKeys = [];
$cacheableService = $this->createMock(CacheableService::class);
$cacheableService->method('getWithCache')
->willReturnCallback(fn(string $operation, array $context, callable $callback) => $callback());
$cacheableService->expects($this->exactly(2))
->method('delete')
->willReturnCallback(function (array $context) use (&$deletedKeys) {
$deletedKeys[] = $context;
});

// Serves both findFromCompound (full row) and the identity resolution
// (identity columns only) — the identity value is what matters.
$queryStrategy->method('query')->willReturn([['id' => 42, 'configKey' => 'rate', 'value' => 'old']]);

$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);
$modelAdapter->method('toModel')->willReturn(new TestModel(42));

$serviceProvider = new DatabaseServiceProvider(
$loggerStrategy,
$queryStrategy,
new DummyQueryBuilder(),
new DummyClauseBuilder(),
$cacheableService,
$eventStrategy
);

$handler = new DummyDatastoreHandler(
$serviceProvider,
$table,
$tableSchemaService,
TestModel::class,
$modelAdapter
);

$handler->updateCompound(['configKey' => 'rate'], ['value' => 'new']);

$this->assertCount(2, $deletedKeys);
// First deletion: the caller-provided compound context (existing behavior).
$this->assertSame($handler->exposeCacheContext(['configKey' => 'rate']), $deletedKeys[0]);
// Second deletion: the row's identity context — the key hydrated reads cache under.
$this->assertSame($handler->exposeCacheContext(['id' => 42]), $deletedKeys[1]);
}

public function testFindFromCompoundIncludesTableAndIdentityWhenRecordIsMissing(): void
{
$queryStrategy = $this->createMock(QueryStrategy::class);
Expand Down
Loading