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
80 changes: 77 additions & 3 deletions lib/Strategies/QueryStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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());
Expand Down Expand Up @@ -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;
}
}

/**
Expand Down Expand Up @@ -119,6 +190,7 @@ public function delete(Table $table, array $ids): void
);

$this->db->query($query);
$this->hasWritten = true;
}

/** @inheritDoc */
Expand Down Expand Up @@ -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;
}


Expand Down
123 changes: 123 additions & 0 deletions tests/Unit/Strategies/QueryStrategyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<?php

namespace PHPNomad\MySql\Integration\Tests\Unit\Strategies;

use PHPNomad\Database\Factories\Column;
use PHPNomad\Database\Interfaces\ClauseBuilder;
use PHPNomad\Database\Interfaces\QueryBuilder;
use PHPNomad\Database\Interfaces\Table;
use PHPNomad\Database\Services\TableSchemaService;
use PHPNomad\MySql\Integration\Interfaces\DatabaseStrategy;
use PHPNomad\MySql\Integration\Strategies\QueryStrategy;
use PHPUnit\Framework\TestCase;

class QueryStrategyTest extends TestCase
{
public function testInsertResolvesLastInsertIdInsideTransaction(): 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');

$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;
}
}
Loading