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
21 changes: 20 additions & 1 deletion lib/Strategies/QueryStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,28 +12,47 @@ class QueryStrategy implements CoreQueryStrategy
{
use CanQueryWordPressDatabase;

/**
* 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;

/** @inheritDoc */
public function query(QueryBuilder $builder): array
{
if ($this->hasWritten) {
return $this->wpdbGetResultsAfterWrite($builder);
}

return $this->wpdbGetResults($builder);
}

/** @inheritDoc */
public function insert(Table $table, array $data): array
{
return $this->wpdbInsert($table, $data);
$ids = $this->wpdbInsert($table, $data);
$this->hasWritten = true;

return $ids;
}

/** @inheritDoc */
public function delete(Table $table, array $ids): void
{
$this->wpdbDelete($table, $ids);
$this->hasWritten = true;
}

/** @inheritDoc */
public function update(Table $table, array $where, array $data): void
{
$this->wpdbUpdate($table, $data, $where);
$this->hasWritten = true;
}

/** @inheritDoc */
Expand Down
90 changes: 71 additions & 19 deletions lib/Traits/CanQueryWordPressDatabase.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use PHPNomad\Integrations\WordPress\Database\ClauseBuilder;
use PHPNomad\Integrations\WordPress\Database\QueryBuilder as WordPressQueryBuilder;
use PHPNomad\Utils\Helpers\Arr;
use Throwable;

trait CanQueryWordPressDatabase
{
Expand Down Expand Up @@ -42,6 +43,43 @@ protected function wpdbGetResults(QueryBuilder $queryBuilder): array
return $result;
}

/**
* Gets a batch of rows using a writer-consistent read path after a write.
*
* @param QueryBuilder $queryBuilder
* @return array<string, mixed>[]|array<int>
* @throws DatastoreErrorException
* @throws RecordNotFoundException
*/
protected function wpdbGetResultsAfterWrite(QueryBuilder $queryBuilder): array
{
global $wpdb;

if (method_exists($wpdb, 'send_reads_to_masters')) {
$wpdb->send_reads_to_masters();

return $this->wpdbGetResults($queryBuilder);
}

if (false === $wpdb->query('START TRANSACTION')) {
throw new DatastoreErrorException('Consistent read failed - could not start transaction: ' . $wpdb->last_error);
}

try {
$result = $this->wpdbGetResults($queryBuilder);

if (false === $wpdb->query('COMMIT')) {
throw new DatastoreErrorException('Consistent read failed - could not commit transaction: ' . $wpdb->last_error);
}

return $result;
} catch (Throwable $e) {
$wpdb->query('ROLLBACK');

throw $e;
}
}

/**
* Gets a single row using wpdb.
* @return array<string, mixed>
Expand Down Expand Up @@ -81,32 +119,46 @@ protected function wpdbInsert(Table $table, array $data): array
{
global $wpdb;

if (empty($data)) {
$inserted = $wpdb->query('INSERT INTO ' . $table->getName() . '() VALUES ();');
} else {
$inserted = $wpdb->insert($table->getName(), $data, $this->getFormats($data));
if (false === $wpdb->query('START TRANSACTION')) {
throw new DatastoreErrorException('Insert failed - could not start transaction: ' . $wpdb->last_error);
}

if (false === $inserted) {
throw new DatastoreErrorException('Insert failed - ' . $wpdb->last_error);
}
try {
if (empty($data)) {
$inserted = $wpdb->query('INSERT INTO ' . $table->getName() . '() VALUES ();');
} else {
$inserted = $wpdb->insert($table->getName(), $data, $this->getFormats($data));
}

if (false === $inserted) {
throw new DatastoreErrorException('Insert failed - ' . $wpdb->last_error);
}

$fields = $table->getFieldsForIdentity();
$ids = Arr::process($fields)
->reduce(function ($acc, $field) use ($data) {
if (isset($data[$field])) {
$acc[$field] = $data[$field];
}
$fields = $table->getFieldsForIdentity();
$ids = Arr::process($fields)
->reduce(function ($acc, $field) use ($data) {
if (isset($data[$field])) {
$acc[$field] = $data[$field];
}

return $acc;
}, [])
->toArray();
return $acc;
}, [])
->toArray();

if (count($ids) !== count($fields)) {
$ids = ['id' => $wpdb->insert_id];
}

if (false === $wpdb->query('COMMIT')) {
throw new DatastoreErrorException('Insert failed - could not commit transaction: ' . $wpdb->last_error);
}

if (count($ids) === count($fields)) {
return $ids;
}
} catch (Throwable $e) {
$wpdb->query('ROLLBACK');

return ['id' => $wpdb->insert_id];
throw $e;
}
}

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

namespace PHPNomad\Integrations\WordPress\Tests\Unit\Strategies;

use PHPNomad\Database\Interfaces\QueryBuilder;
use PHPNomad\Database\Interfaces\Table;
use PHPNomad\Integrations\WordPress\Strategies\QueryStrategy;
use PHPNomad\Integrations\WordPress\Tests\TestCase;

class QueryStrategyTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

if (!defined('ARRAY_A')) {
define('ARRAY_A', 'ARRAY_A');
}
}

protected function tearDown(): void
{
unset($GLOBALS['wpdb']);
parent::tearDown();
}

public function testQueryUsesTransactionBackedReadAfterInsert(): void
{
$GLOBALS['wpdb'] = new class {
public int $insert_id = 123;
public string $last_error = '';
public array $queries = [];
private bool $inTransaction = false;

public function query(string $query): bool
{
$this->queries[] = $query;

if ($query === 'START TRANSACTION') {
$this->inTransaction = true;
}

if ($query === 'COMMIT' || $query === 'ROLLBACK') {
$this->inTransaction = false;
}

return true;
}

public function insert(string $table, array $data, array $formats): int
{
return 1;
}

public function get_results(string $query, string $output): array
{
return $this->inTransaction ? [['id' => 123, 'name' => 'Example']] : [];
}
};

$table = $this->createMock(Table::class);
$table->method('getName')->willReturn('wp_test_records');
$table->method('getFieldsForIdentity')->willReturn(['id']);

$queryBuilder = $this->createMock(QueryBuilder::class);
$queryBuilder->expects($this->once())
->method('build')
->willReturn('SELECT * FROM wp_test_records WHERE id = 123');

$strategy = new QueryStrategy();
$strategy->insert($table, ['name' => 'Example']);

$this->assertSame([['id' => 123, 'name' => 'Example']], $strategy->query($queryBuilder));
$this->assertSame(['START TRANSACTION', 'COMMIT', 'START TRANSACTION', 'COMMIT'], $GLOBALS['wpdb']->queries);
}
}
50 changes: 50 additions & 0 deletions tests/Unit/Traits/CanQueryWordPressDatabaseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,56 @@ public function getResults(QueryBuilder $queryBuilder): array
$subject->getResults($queryBuilder);
}

public function testWpdbInsertResolvesInsertIdInsideTransaction(): void
{
$table = $this->createMock(Table::class);
$table->method('getName')->willReturn('wp_test_records');
$table->method('getFieldsForIdentity')->willReturn(['id']);

$GLOBALS['wpdb'] = new class {
public int $insert_id = 123;
public string $last_error = '';
public array $queries = [];
public bool $insertedInTransaction = false;
private bool $inTransaction = false;

public function query(string $query): bool
{
$this->queries[] = $query;

if ($query === 'START TRANSACTION') {
$this->inTransaction = true;
}

if ($query === 'COMMIT' || $query === 'ROLLBACK') {
$this->inTransaction = false;
}

return true;
}

public function insert(string $table, array $data, array $formats): int
{
$this->insertedInTransaction = $this->inTransaction;

return 1;
}
};

$subject = new class {
use CanQueryWordPressDatabase;

public function insertRecord(Table $table, array $data): array
{
return $this->wpdbInsert($table, $data);
}
};

$this->assertSame(['id' => 123], $subject->insertRecord($table, ['name' => 'Example']));
$this->assertTrue($GLOBALS['wpdb']->insertedInTransaction);
$this->assertSame(['START TRANSACTION', 'COMMIT'], $GLOBALS['wpdb']->queries);
}

public function testWpdbUpdateIncludesTableIdentityAndPayloadWhenRecordIsMissing(): void
{
$table = $this->createMock(Table::class);
Expand Down
Loading