diff --git a/lib/Strategies/QueryStrategy.php b/lib/Strategies/QueryStrategy.php index 7f004c0..1b68773 100644 --- a/lib/Strategies/QueryStrategy.php +++ b/lib/Strategies/QueryStrategy.php @@ -12,10 +12,20 @@ use PHPNomad\Datastore\Exceptions\RecordNotFoundException; use PHPNomad\MySql\Integration\Interfaces\DatabaseStrategy; use PHPNomad\Utils\Helpers\Arr; -use PHPNomad\Utils\Helpers\Str; +use Throwable; class QueryStrategy implements CoreQueryStrategy { + /** + * Tracks whether this strategy has written during the current request. + * + * After a write, managed hosts with read replicas may route normal reads to a + * stale replica. This flag lets only post-write reads use the writer-consistent + * path, so normal reads stay cheap while read-after-write hydration can see + * the row that was just inserted or changed. + */ + protected bool $hasWritten = false; + public function __construct( protected DatabaseStrategy $db, protected TableSchemaService $tableSchemaService, @@ -26,6 +36,51 @@ public function __construct( /** @inheritDoc */ public function query(QueryBuilder $builder): array + { + if ($this->hasWritten) { + return $this->queryAfterWrite($builder); + } + + return $this->queryNormally($builder); + } + + /** + * Executes a read through a path that can see preceding writes. + * + * @param QueryBuilder $builder + * @return array + * @throws DatastoreErrorException + * @throws RecordNotFoundException + */ + protected function queryAfterWrite(QueryBuilder $builder): array + { + $this->db->query('START TRANSACTION'); + + try { + $result = $this->queryNormally($builder); + $this->db->query('COMMIT'); + + return $result; + } catch (Throwable $e) { + try { + $this->db->query('ROLLBACK'); + } catch (Throwable $rollbackException) { + // Preserve the original read failure. + } + + throw $e; + } + } + + /** + * Executes a normal read query using the configured database strategy. + * + * @param QueryBuilder $builder + * @return array + * @throws DatastoreErrorException + * @throws RecordNotFoundException + */ + protected function queryNormally(QueryBuilder $builder): array { try { $result = $this->db->query($builder->build()); @@ -60,8 +115,24 @@ public function insert(Table $table, array $data): array ...Arr::values($data) ); - $this->db->query($query); - return $this->resolveInsertIdentity($table, $data); + $this->db->query('START TRANSACTION'); + + try { + $this->db->query($query); + $identity = $this->resolveInsertIdentity($table, $data); + $this->db->query('COMMIT'); + $this->hasWritten = true; + + return $identity; + } catch (Throwable $e) { + try { + $this->db->query('ROLLBACK'); + } catch (Throwable $rollbackException) { + // Preserve the original insert failure. + } + + throw $e; + } } /** @@ -119,6 +190,7 @@ public function delete(Table $table, array $ids): void ); $this->db->query($query); + $this->hasWritten = true; } /** @inheritDoc */ @@ -158,6 +230,8 @@ public function update(Table $table, array $ids, array $data): void if ($result === 0) { throw new RecordNotFoundException('The update failed because no record exists with the specified IDs.'); } + + $this->hasWritten = true; } diff --git a/tests/Unit/Strategies/QueryStrategyTest.php b/tests/Unit/Strategies/QueryStrategyTest.php new file mode 100644 index 0000000..7d8bf37 --- /dev/null +++ b/tests/Unit/Strategies/QueryStrategyTest.php @@ -0,0 +1,123 @@ +createMock(TableSchemaService::class); + $tableSchemaService->expects($this->once()) + ->method('getPrimaryColumnsForTable') + ->willReturn([new Column('id', 'INT', null, 'AUTO_INCREMENT')]); + + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); + + $strategy = new QueryStrategy( + $db, + $tableSchemaService, + $this->createMock(ClauseBuilder::class) + ); + + $identity = $strategy->insert($table, ['name' => 'Example']); + + $this->assertSame(['id' => 123], $identity); + $this->assertSame([ + 'START TRANSACTION', + 'INSERT INTO test_records (name) VALUES ("Example")', + 'SELECT LAST_INSERT_ID()', + 'COMMIT', + ], $db->queries); + $this->assertTrue($db->lastInsertIdQueriedInTransaction); + } + + public function testQueryUsesTransactionBackedReadAfterInsert(): void + { + $db = new InsertTransactionDatabaseStrategyStub(); + + $tableSchemaService = $this->createMock(TableSchemaService::class); + $tableSchemaService->expects($this->once()) + ->method('getPrimaryColumnsForTable') + ->willReturn([new Column('id', 'INT', null, 'AUTO_INCREMENT')]); + + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn('test_records'); + + $queryBuilder = $this->createMock(QueryBuilder::class); + $queryBuilder->expects($this->once()) + ->method('build') + ->willReturn('SELECT * FROM test_records WHERE id = 123'); + + $strategy = new QueryStrategy( + $db, + $tableSchemaService, + $this->createMock(ClauseBuilder::class) + ); + + $strategy->insert($table, ['name' => 'Example']); + + $this->assertSame([['id' => 123, 'name' => 'Example']], $strategy->query($queryBuilder)); + $this->assertSame([ + 'START TRANSACTION', + 'INSERT INTO test_records (name) VALUES ("Example")', + 'SELECT LAST_INSERT_ID()', + 'COMMIT', + 'START TRANSACTION', + 'SELECT * FROM test_records WHERE id = 123', + 'COMMIT', + ], $db->queries); + } +} + +class InsertTransactionDatabaseStrategyStub implements DatabaseStrategy +{ + public array $queries = []; + public bool $lastInsertIdQueriedInTransaction = false; + private bool $inTransaction = false; + + public function parse(string $query, ...$args): string + { + return 'INSERT INTO test_records (name) VALUES ("Example")'; + } + + public function query(string $query) + { + $this->queries[] = $query; + + if ($query === 'START TRANSACTION') { + $this->inTransaction = true; + + return 1; + } + + if ($query === 'COMMIT' || $query === 'ROLLBACK') { + $this->inTransaction = false; + + return 1; + } + + if ($query === 'SELECT LAST_INSERT_ID()') { + $this->lastInsertIdQueriedInTransaction = $this->inTransaction; + + return [['LAST_INSERT_ID()' => 123]]; + } + + if (str_starts_with($query, 'SELECT *')) { + return $this->inTransaction ? [['id' => 123, 'name' => 'Example']] : []; + } + + return 1; + } +}