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;
+ }
+}