From 496e35711a4489400cc30746d86c09dbf9defa8e Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 20 Sep 2026 19:08:49 -0400 Subject: [PATCH] Add WordPress query boundary metadata --- composer.json | 6 +- composer.lock | 43 ++++++---- lib/Database/ClauseBuilder.php | 23 ++++- lib/Database/QueryBuilder.php | 47 +++++++++- .../Database/RealWpdbQueryContractTest.php | 85 ++++++++++++++++++- tests/Unit/Database/ClauseBuilderTest.php | 13 +++ tests/Unit/Database/QueryBuilderTest.php | 82 ++++++++++++++++++ 7 files changed, 277 insertions(+), 22 deletions(-) create mode 100644 tests/Unit/Database/QueryBuilderTest.php diff --git a/composer.json b/composer.json index 327842b..e07d25f 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,10 @@ "test:integration:required": "phpunit -c phpunit-integration.xml --colors=never --fail-on-skipped" }, "repositories": [ + { + "type": "git", + "url": "https://github.com/phpnomad/db.git" + }, { "type": "vcs", "url": "https://github.com/woocommerce/action-scheduler" @@ -43,7 +47,7 @@ "php": ">=8.0", "phpnomad/auth": "^1.0", "phpnomad/asset": "^1.0", - "phpnomad/db": "^2.0", + "phpnomad/db": "dev-release/2.2 as 2.2.x-dev", "phpnomad/datastore": "^2.0", "phpnomad/event": "^1.0", "phpnomad/email": "^1.0", diff --git a/composer.lock b/composer.lock index 27d7ffa..8f656ad 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": "f4c2534653e351a307308b15f6fd502f", + "content-hash": "2a88635225488e950fdb4e7ca403fb02", "packages": [ { "name": "phpnomad/asset", @@ -270,17 +270,11 @@ }, { "name": "phpnomad/db", - "version": "2.2.1", + "version": "dev-release/2.2", "source": { "type": "git", "url": "https://github.com/phpnomad/db.git", - "reference": "6e423f78b4bf0c166981fcee4b14d39fa2925f5d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpnomad/db/zipball/6e423f78b4bf0c166981fcee4b14d39fa2925f5d", - "reference": "6e423f78b4bf0c166981fcee4b14d39fa2925f5d", - "shasum": "" + "reference": "17fb84703d260a66de874704ae37cf924b9d8c2b" }, "require": { "phpnomad/cache": "^1.0", @@ -290,6 +284,7 @@ "phpnomad/utils": "^1.0" }, "require-dev": { + "phpnomad/event": "^1.0", "phpnomad/tests": "^0.1.0 || ^0.3.0" }, "type": "library", @@ -298,7 +293,16 @@ "PHPNomad\\Database\\": "lib/" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "PHPNomad\\Database\\Tests\\": "tests/" + } + }, + "scripts": { + "php-cs-fixer": [ + "php-cs-fixer fix" + ] + }, "license": [ "MIT" ], @@ -309,11 +313,7 @@ } ], "homepage": "https://github.com/phpnomad/core", - "support": { - "issues": "https://github.com/phpnomad/db/issues", - "source": "https://github.com/phpnomad/db/tree/2.2.1" - }, - "time": "2026-05-02T01:06:15+00:00" + "time": "2026-09-20T15:42:00+00:00" }, { "name": "phpnomad/di", @@ -6122,9 +6122,18 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], + "aliases": [ + { + "package": "phpnomad/db", + "version": "dev-release/2.2", + "alias": "2.2.x-dev", + "alias_normalized": "2.2.9999999.9999999-dev" + } + ], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "phpnomad/db": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/lib/Database/ClauseBuilder.php b/lib/Database/ClauseBuilder.php index 7de8027..6286ca1 100644 --- a/lib/Database/ClauseBuilder.php +++ b/lib/Database/ClauseBuilder.php @@ -6,6 +6,7 @@ use PHPNomad\Database\Interfaces\ClauseBuilder as ClauseBuilderInterface; use PHPNomad\Database\Traits\WithPrependedFields; use PHPNomad\Integrations\WordPress\Traits\CanGetDataFormats; +use wpdb; class ClauseBuilder implements ClauseBuilderInterface { @@ -19,6 +20,13 @@ class ClauseBuilder implements ClauseBuilderInterface 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'IS NULL', 'IS NOT NULL', ]; + private ?wpdb $database = null; + + public function __construct(?wpdb $database = null) + { + $this->database = $database; + } + /** @inheritDoc */ public function where($field, string $operator, ...$values) { @@ -149,8 +157,7 @@ protected function addCondition($field, string $operator, array $values, ?string } if ($preparedValues !== []) { - global $wpdb; - $condition = $wpdb->prepare($condition, ...$preparedValues); + $condition = $this->wpdb()->prepare($condition, ...$preparedValues); if (!is_string($condition) || $condition === '') { throw new QueryBuilderException('WordPress could not prepare a condition.'); } @@ -313,4 +320,16 @@ private function validateGroup(string $logic, array $clauses): string return $logic; } + + /** @return wpdb */ + private function wpdb() + { + if ($this->database !== null) { + return $this->database; + } + + global $wpdb; + + return $wpdb; + } } diff --git a/lib/Database/QueryBuilder.php b/lib/Database/QueryBuilder.php index b574888..110370d 100644 --- a/lib/Database/QueryBuilder.php +++ b/lib/Database/QueryBuilder.php @@ -4,6 +4,7 @@ use PHPNomad\Database\Exceptions\QueryBuilderException; use PHPNomad\Database\Interfaces\ClauseBuilder; +use PHPNomad\Database\Interfaces\HasQueryTables; use PHPNomad\Database\Interfaces\QueryBuilder as QueryBuilderInterface; use PHPNomad\Database\Interfaces\Table; use PHPNomad\Database\Traits\WithPrependedFields; @@ -11,7 +12,7 @@ use PHPNomad\Utils\Helpers\Arr; use wpdb; -class QueryBuilder implements QueryBuilderInterface +class QueryBuilder implements QueryBuilderInterface, HasQueryTables { use CanGetDataFormats; use WithPrependedFields; @@ -20,6 +21,8 @@ class QueryBuilder implements QueryBuilderInterface protected array $from = []; + protected array $join = []; + protected array $sql = []; private array $preparedValues = []; @@ -41,6 +44,18 @@ class QueryBuilder implements QueryBuilderInterface protected ?ClauseBuilder $clauseBuilder = null; protected array $groupBy = []; + private ?Table $rootTable = null; + + /** @var list */ + private array $joinTables = []; + + private ?wpdb $database = null; + + public function __construct(?wpdb $database = null) + { + $this->database = $database; + } + /** @inheritDoc */ public function select(string $field, string ...$fields) { @@ -64,6 +79,7 @@ public function select(string $field, string ...$fields) public function from(Table $table) { $this->useTable($table); + $this->rootTable = $table; $this->from = ['FROM', $table->getName(), 'AS', $table->getAlias()]; return $this; @@ -99,6 +115,8 @@ public function leftJoin(Table $table, string $column, string $onColumn) $this->join = $join; } + $this->joinTables[] = $table; + return $this; } @@ -123,6 +141,8 @@ public function rightJoin(Table $table, string $column, string $onColumn) $this->join = $join; } + $this->joinTables[] = $table; + return $this; } @@ -274,12 +294,23 @@ public function build(): string return $sql; } + /** @inheritDoc */ + public function getReferencedTables(): array + { + if ($this->rootTable === null) { + return []; + } + + return array_merge([$this->rootTable], $this->joinTables); + } + /** @inheritDoc */ public function reset() { $this->select = []; $this->clauseBuilder = null; $this->from = []; + $this->join = []; $this->sql = []; $this->preparedValues = []; $this->prepare = []; @@ -290,6 +321,8 @@ public function reset() $this->offset = []; $this->orderBy = []; $this->groupBy = []; + $this->rootTable = null; + $this->joinTables = []; return $this; } @@ -303,6 +336,14 @@ public function resetClauses(string $clause, string ...$clauses) if (isset($this->$clauseToReset)) { $this->$clauseToReset = []; } + + if ($clauseToReset === 'from') { + $this->rootTable = null; + } + + if ($clauseToReset === 'join') { + $this->joinTables = []; + } } return $this; @@ -347,6 +388,10 @@ private function maybeAppend(string $key) */ private function wpdb(): wpdb { + if ($this->database !== null) { + return $this->database; + } + global $wpdb; return $wpdb; diff --git a/tests/Integration/Database/RealWpdbQueryContractTest.php b/tests/Integration/Database/RealWpdbQueryContractTest.php index 5b93124..b4ef123 100644 --- a/tests/Integration/Database/RealWpdbQueryContractTest.php +++ b/tests/Integration/Database/RealWpdbQueryContractTest.php @@ -23,6 +23,7 @@ final class RealWpdbQueryContractTest extends TestCase { private const PREDICATE_TABLE = 'nomad_wpdb_contract_predicates'; + private const JOIN_TABLE = 'nomad_wpdb_contract_join_records'; private const COMPOUND_TABLE = 'nomad_wpdb_contract_compound'; private const COMPOUND_CONTROL_TABLE = 'nomad_wpdb_contract_compound_control'; @@ -101,6 +102,10 @@ public static function setUpBeforeClass(): void 'CREATE TEMPORARY TABLE ' . self::PREDICATE_TABLE . ' (id INT PRIMARY KEY, score INT NULL, label VARCHAR(128) NOT NULL) ENGINE=InnoDB' ); + self::rawQuery( + 'CREATE TEMPORARY TABLE ' . self::JOIN_TABLE + . ' (id INT PRIMARY KEY, score INT NULL, label VARCHAR(128) NOT NULL) ENGINE=InnoDB' + ); foreach ([self::COMPOUND_TABLE, self::COMPOUND_CONTROL_TABLE] as $tableName) { self::rawQuery( @@ -122,6 +127,11 @@ protected function setUp(): void 'INSERT INTO ' . self::PREDICATE_TABLE . ' (id, score, label) VALUES (%d,%d,%s)', [70, 90, self::literalMarkerValue()] ); + self::rawQuery('DELETE FROM ' . self::JOIN_TABLE); + self::rawQuery( + "INSERT INTO " . self::JOIN_TABLE . " (id, score, label) VALUES " + . "(500,70,'matching'),(600,999,'unmatched')" + ); } public static function tearDownAfterClass(): void @@ -130,7 +140,9 @@ public static function tearDownAfterClass(): void return; } - foreach ([self::PREDICATE_TABLE, self::COMPOUND_TABLE, self::COMPOUND_CONTROL_TABLE] as $tableName) { + foreach ( + [self::PREDICATE_TABLE, self::JOIN_TABLE, self::COMPOUND_TABLE, self::COMPOUND_CONTROL_TABLE] as $tableName + ) { self::rawQuery("DROP TEMPORARY TABLE IF EXISTS {$tableName}"); } @@ -673,6 +685,77 @@ public function testZeroRowUpdateWrapsExistenceProbeBuilderFailure(): void self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE)); } + public function testInjectedWpdbBuildsJoinedQueriesAndClearsMetadataAcrossReuse(): void + { + $joinedTable = new ContractTable( + self::JOIN_TABLE, + 'matching_predicates', + self::$predicateTable->getColumns(), + ['id'] + ); + $globalWpdb = $GLOBALS['wpdb']; + $GLOBALS['wpdb'] = new class () { + public function prepare(): void + { + throw new \RuntimeException('The injected builders must not use the global wpdb resource.'); + } + }; + + try { + $clause = (new ClauseBuilder(self::$wpdb)) + ->useTable(self::$predicateTable) + ->where('id', '=', 50); + $builder = (new class (self::$wpdb) extends QueryBuilder { + public function preparedLimit(int $limit): self + { + $this->limit = ['LIMIT', ['type' => '%d', 'value' => $limit]]; + + return $this; + } + }) + ->from(self::$predicateTable) + ->select('id') + ->leftJoin($joinedTable, 'score', 'score') + ->where($clause) + ->orderBy('id', 'ASC') + ->preparedLimit(1); + + self::assertSame( + [self::$predicateTable, $joinedTable], + $builder->getReferencedTables() + ); + + $joinedSql = $builder->build(); + self::assertStringContainsString("WHERE predicates.id = '50'", $joinedSql); + self::assertStringContainsString('LIMIT 1', $joinedSql); + self::assertSame([], $builder->getReferencedTables()); + + $plainSql = $builder + ->from(self::$predicateTable) + ->select('*') + ->orderBy('id', 'ASC') + ->build(); + + self::assertStringNotContainsString('JOIN', $plainSql); + self::assertSame([], $builder->getReferencedTables()); + + $builder + ->from(self::$predicateTable) + ->select('*') + ->leftJoin($joinedTable, 'score', 'score') + ->resetClauses('join'); + + self::assertSame([self::$predicateTable], $builder->getReferencedTables()); + $resetSql = $builder->build(); + } finally { + $GLOBALS['wpdb'] = $globalWpdb; + } + + self::assertSame('50', self::rawSelect($joinedSql)[0]['id']); + self::assertCount(4, self::rawSelect($plainSql)); + self::assertCount(4, self::rawSelect($resetSql)); + } + private static function selectQuery(ClauseBuilder $clause): QueryBuilder { return (new QueryBuilder()) diff --git a/tests/Unit/Database/ClauseBuilderTest.php b/tests/Unit/Database/ClauseBuilderTest.php index b33cfdd..cc782e6 100644 --- a/tests/Unit/Database/ClauseBuilderTest.php +++ b/tests/Unit/Database/ClauseBuilderTest.php @@ -98,6 +98,19 @@ public function testProtectedConditionSeamRejectsInvalidLogicWithoutChangingStat self::assertSame("records.id = '50'", $builder->build()); } + public function testSubclassConstructorCanKeepGlobalWpdbFallback(): void + { + $builder = (new class () extends ClauseBuilder { + public function __construct() + { + } + }) + ->useTable($this->table) + ->where('id', '=', 50); + + self::assertSame("records.id = '50'", $builder->build()); + } + private function conditionSeamBuilder(): ClauseBuilder { return (new class () extends ClauseBuilder { diff --git a/tests/Unit/Database/QueryBuilderTest.php b/tests/Unit/Database/QueryBuilderTest.php new file mode 100644 index 0000000..75b4291 --- /dev/null +++ b/tests/Unit/Database/QueryBuilderTest.php @@ -0,0 +1,82 @@ +table('records', 'records'); + $replacement = $this->table('replacement_records', 'replacement'); + $firstJoin = $this->table('first_links', 'first_links'); + $secondJoin = $this->table('second_links', 'second_links'); + $builder = (new QueryBuilder()) + ->from($root) + ->leftJoin($firstJoin, 'id', 'recordId') + ->rightJoin($secondJoin, 'id', 'recordId'); + + self::assertInstanceOf(HasQueryTables::class, $builder); + self::assertSame([$root, $firstJoin, $secondJoin], $builder->getReferencedTables()); + + $builder->resetClauses('join'); + self::assertSame([$root], $builder->getReferencedTables()); + + $builder->leftJoin($firstJoin, 'id', 'recordId'); + $builder->from($replacement); + self::assertSame([$replacement, $firstJoin], $builder->getReferencedTables()); + + $builder->resetClauses('from'); + self::assertSame([], $builder->getReferencedTables()); + + $builder->reset(); + self::assertSame([], $builder->getReferencedTables()); + } + + public function testSuccessfulBuildClearsJoinSqlAndMetadataBeforeReuse(): void + { + $root = $this->table('records', 'records'); + $join = $this->table('links', 'links'); + $builder = (new QueryBuilder()) + ->select('*') + ->from($root) + ->leftJoin($join, 'id', 'recordId'); + + self::assertStringContainsString('LEFT JOIN links AS links', $builder->build()); + self::assertSame([], $builder->getReferencedTables()); + + $sql = $builder + ->select('*') + ->from($root) + ->build(); + + self::assertStringNotContainsString('JOIN', $sql); + self::assertSame([], $builder->getReferencedTables()); + } + + public function testBuildersKeepZeroArgumentConstruction(): void + { + $queryConstructor = (new ReflectionClass(QueryBuilder::class))->getConstructor(); + $clauseConstructor = (new ReflectionClass(ClauseBuilder::class))->getConstructor(); + + self::assertNotNull($queryConstructor); + self::assertNotNull($clauseConstructor); + self::assertSame(0, $queryConstructor->getNumberOfRequiredParameters()); + self::assertSame(0, $clauseConstructor->getNumberOfRequiredParameters()); + } + + private function table(string $name, string $alias): Table + { + $table = $this->createMock(Table::class); + $table->method('getName')->willReturn($name); + $table->method('getAlias')->willReturn($alias); + + return $table; + } +}