diff --git a/composer.json b/composer.json
index 7840b86..327842b 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,9 @@
"prefer-stable": true,
"minimum-stability": "dev",
"scripts": {
- "php-cs-fixer": "php-cs-fixer fix"
+ "php-cs-fixer": "php-cs-fixer fix",
+ "test:integration": "phpunit -c phpunit-integration.xml --colors=never",
+ "test:integration:required": "phpunit -c phpunit-integration.xml --colors=never --fail-on-skipped"
},
"repositories": [
{
@@ -34,6 +36,7 @@
}
],
"require-dev": {
+ "phpnomad/di-container": "^1.1",
"phpnomad/tests": "^0.3.0"
},
"require": {
diff --git a/composer.lock b/composer.lock
index b4298c8..27d7ffa 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": "a4b0d4cf35976d81c00484ec7206a14a",
+ "content-hash": "f4c2534653e351a307308b15f6fd502f",
"packages": [
{
"name": "phpnomad/asset",
@@ -317,16 +317,16 @@
},
{
"name": "phpnomad/di",
- "version": "2.0.0",
+ "version": "2.0.1",
"source": {
"type": "git",
"url": "https://github.com/phpnomad/di.git",
- "reference": "71cf432571f405e96c30dab4cfbccc22462bcc67"
+ "reference": "2f343769c816c0574a9ed4b39f4bba0335a0de63"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpnomad/di/zipball/71cf432571f405e96c30dab4cfbccc22462bcc67",
- "reference": "71cf432571f405e96c30dab4cfbccc22462bcc67",
+ "url": "https://api.github.com/repos/phpnomad/di/zipball/2f343769c816c0574a9ed4b39f4bba0335a0de63",
+ "reference": "2f343769c816c0574a9ed4b39f4bba0335a0de63",
"shasum": ""
},
"require-dev": {
@@ -351,9 +351,9 @@
"homepage": "https://github.com/phpnomad/core",
"support": {
"issues": "https://github.com/phpnomad/di/issues",
- "source": "https://github.com/phpnomad/di/tree/2.0.0"
+ "source": "https://github.com/phpnomad/di/tree/2.0.1"
},
- "time": "2026-03-31T15:22:46+00:00"
+ "time": "2026-06-12T10:56:38+00:00"
},
{
"name": "phpnomad/email",
@@ -2275,6 +2275,50 @@
},
"time": "2022-02-21T01:04:05+00:00"
},
+ {
+ "name": "phpnomad/di-container",
+ "version": "1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phpnomad/di-container.git",
+ "reference": "42be402b3a5e747c6e55b5aa9c042e787251ba28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phpnomad/di-container/zipball/42be402b3a5e747c6e55b5aa9c042e787251ba28",
+ "reference": "42be402b3a5e747c6e55b5aa9c042e787251ba28",
+ "shasum": ""
+ },
+ "require": {
+ "phpnomad/di": "^2.0",
+ "phpnomad/utils": "^1.0"
+ },
+ "require-dev": {
+ "phpnomad/tests": "^0.1.0 || ^0.3.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PHPNomad\\Di\\Container\\": "lib/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Alex Standiford",
+ "email": "alex@standiford.us"
+ }
+ ],
+ "description": "Lightweight dependency injection container for PHPNomad",
+ "support": {
+ "issues": "https://github.com/phpnomad/di-container/issues",
+ "source": "https://github.com/phpnomad/di-container/tree/1.1.0"
+ },
+ "time": "2026-08-23T22:17:11+00:00"
+ },
{
"name": "phpnomad/tests",
"version": "0.3.0",
@@ -6084,6 +6128,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
+ "php": ">=8.0",
"ext-json": "*"
},
"platform-dev": {},
diff --git a/lib/Database/ClauseBuilder.php b/lib/Database/ClauseBuilder.php
index 4d895e3..7de8027 100644
--- a/lib/Database/ClauseBuilder.php
+++ b/lib/Database/ClauseBuilder.php
@@ -2,110 +2,108 @@
namespace PHPNomad\Integrations\WordPress\Database;
+use PHPNomad\Database\Exceptions\QueryBuilderException;
use PHPNomad\Database\Interfaces\ClauseBuilder as ClauseBuilderInterface;
use PHPNomad\Database\Traits\WithPrependedFields;
use PHPNomad\Integrations\WordPress\Traits\CanGetDataFormats;
-use PHPNomad\Utils\Helpers\Arr;
class ClauseBuilder implements ClauseBuilderInterface
{
- use CanGetDataFormats, WithPrependedFields;
+ use CanGetDataFormats;
+ use WithPrependedFields;
protected array $clauses = [];
protected array $preparedValues = [];
- protected array $validOperators = ["=", "<", ">", "<=", ">=", "<>", "!=",
- "LIKE", "NOT LIKE", "IN", "NOT IN", "BETWEEN",
- "NOT BETWEEN", "IS NULL", "IS NOT NULL"];
+ protected array $validOperators = [
+ '=', '<', '>', '<=', '>=', '<>', '!=', 'LIKE', 'NOT LIKE',
+ 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN', 'IS NULL', 'IS NOT NULL',
+ ];
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function where($field, string $operator, ...$values)
{
$this->addCondition($field, $operator, $values);
return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function andWhere($field, string $operator, ...$values)
{
$this->addCondition($field, $operator, $values, 'AND');
return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function orWhere($field, string $operator, ...$values)
{
$this->addCondition($field, $operator, $values, 'OR');
return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function group(string $logic, ClauseBuilderInterface ...$clauses)
{
- $group = ['logic' => $logic, 'clauses' => $clauses];
- $this->clauses[] = $group;
-
+ $this->appendGroup($logic, $clauses);
return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function andGroup(string $logic, ClauseBuilderInterface ...$clauses)
{
- if (!empty($this->clauses)) {
+ $logic = $this->validateGroup($logic, $clauses);
+
+ if ($this->clauses !== []) {
$this->clauses[] = 'AND';
}
- return $this->group($logic, ...$clauses);
+ $this->clauses[] = ['logic' => $logic, 'clauses' => $clauses];
+ return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function orGroup(string $logic, ClauseBuilderInterface ...$clauses)
{
- if (!empty($this->clauses)) {
+ $logic = $this->validateGroup($logic, $clauses);
+
+ if ($this->clauses !== []) {
$this->clauses[] = 'OR';
}
- return $this->group($logic, ...$clauses);
+ $this->clauses[] = ['logic' => $logic, 'clauses' => $clauses];
+ return $this;
}
/**
- * Gets the field string, filtering invalid fields.
+ * Gets the field string, rejecting invalid fields.
*
- * @param $field
+ * @param string|string[] $field
* @return string|null
+ * @throws QueryBuilderException
*/
protected function getFieldString($field): ?string
{
- $result = null;
+ if (!is_array($field)) {
+ if (!is_string($field) || !$this->tableHasField($field)) {
+ throw new QueryBuilderException('Unknown field: ' . (string) $field);
+ }
- if (!is_array($field) && $this->tableHasField($field)) {
- $result = $this->prependField($field);
+ return $this->prependField($field);
}
- if (is_array($field)) {
- $fieldStr = Arr::process($field)
- ->filter(fn($field) => $this->tableHasField($field))
- ->map(fn($field) => $this->prependField($field))
- ->setSeparator(', ')
- ->toString();
+ if ($field === []) {
+ throw new QueryBuilderException('A condition field list cannot be empty.');
+ }
- if (!empty($fieldStr)) {
- $result = "($fieldStr)";
+ $fields = [];
+ foreach ($field as $member) {
+ if (!is_string($member) || !$this->tableHasField($member)) {
+ throw new QueryBuilderException('Unknown field: ' . (string) $member);
}
+
+ $fields[] = $this->prependField($member);
}
- return $result;
+ return '(' . implode(', ', $fields) . ')';
}
/**
@@ -114,131 +112,205 @@ protected function getFieldString($field): ?string
* @param string|string[] $field The field, or fields to be compared.
* @param string $operator The operator to be used in the comparison.
* @param array $values The values to be compared against.
- * @param ?string $logic (optional) The logic operator to be prepended to the condition.
+ * @param ?string $logic The logic operator to be prepended to the condition.
* @return $this
+ * @throws QueryBuilderException
*/
protected function addCondition($field, string $operator, array $values, ?string $logic = null): self
{
- $operator = strtoupper($operator);
+ if ($logic !== null) {
+ $logic = strtoupper($logic);
+ if (!in_array($logic, ['AND', 'OR'], true)) {
+ throw new QueryBuilderException("Unknown condition logic: {$logic}");
+ }
+ }
- if (!in_array($operator, $this->validOperators)) {
- return $this;
+ $operator = strtoupper($operator);
+ if (!in_array($operator, $this->validOperators, true)) {
+ throw new QueryBuilderException("Unknown operator: {$operator}");
}
+ $fieldString = $this->getFieldString($field);
+ $values = $this->normalizeValues($field, $operator, $values);
$placeholder = $this->generatePlaceholder($field, $values, $operator);
+ $condition = "{$fieldString} {$operator}" . ($placeholder === '' ? '' : " {$placeholder}");
- $fieldStr = $this->getFieldString($field);
-
- if (!empty($this->clauses) && $logic && in_array(strtoupper($logic), ['AND', 'OR'])) {
- $this->clauses[] = strtoupper($logic);
+ $preparedValues = [];
+ foreach ($values as $value) {
+ if (is_array($value)) {
+ foreach ($value as $tupleValue) {
+ if ($tupleValue !== null) {
+ $preparedValues[] = $tupleValue;
+ }
+ }
+ } elseif ($value !== null) {
+ $preparedValues[] = $value;
+ }
}
- $this->clauses[] = $fieldStr;
- $this->clauses[] = $operator;
- $this->clauses[] = $placeholder;
-
- foreach (Arr::whereNotNull($values) as $value) {
- if (is_array($value)) {
- $this->preparedValues = Arr::merge($this->preparedValues, array_values($value));
- } else {
- $this->preparedValues[] = $value;
+ if ($preparedValues !== []) {
+ global $wpdb;
+ $condition = $wpdb->prepare($condition, ...$preparedValues);
+ if (!is_string($condition) || $condition === '') {
+ throw new QueryBuilderException('WordPress could not prepare a condition.');
}
}
+ if ($this->clauses !== [] && $logic !== null) {
+ $this->clauses[] = $logic;
+ }
+
+ $this->clauses[] = $condition;
return $this;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function build(): string
{
- global $wpdb;
$queryParts = [];
- $allValues = $this->preparedValues; // Initially prepared values
- $subQueryReplacements = [];
- $query = "";
- $marker = 0;
foreach ($this->clauses as $clause) {
if (is_string($clause)) {
- // Directly append logical operators or raw SQL parts
$queryParts[] = $clause;
- } elseif (is_array($clause) && isset($clause['logic'], $clause['clauses'])) {
- // Process group of clauses
- $groupParts = [];
- foreach ($clause['clauses'] as $groupClause) {
- if ($groupClause instanceof ClauseBuilderInterface) {
- $marker++;
- $uniqueMarker = '__NOMADIC_SUBQUERY__' . $marker;
- $builtClause = $groupClause->build();
- $subQueryReplacements[$uniqueMarker] = $builtClause;
- $groupParts[] = $uniqueMarker;
- }
- }
- if (!empty($groupParts)) {
- $queryParts[] = '(' . implode(" {$clause['logic']} ", $groupParts) . ')';
- }
- } elseif ($clause instanceof ClauseBuilderInterface) {
- $marker++;
- $uniqueMarker = '__NOMADIC_SUBQUERY__' . $marker;
- $builtClause = $clause->build();
- $subQueryReplacements[$uniqueMarker] = $builtClause;
- $queryParts[] = $uniqueMarker;
+ continue;
}
- }
- if (!empty($queryParts)) {
- $query = implode(' ', $queryParts);
+ $groupParts = [];
+ foreach ($clause['clauses'] as $groupClause) {
+ $builtClause = $groupClause->build();
+ if ($builtClause === '') {
+ throw new QueryBuilderException('A grouped condition cannot be empty.');
+ }
- // Prepare the query with initial values if available
- if (!empty($allValues)) {
- $query = $wpdb->prepare($query, ...$allValues);
+ $groupParts[] = $builtClause;
}
- // Replace subquery markers with their actual queries
- foreach ($subQueryReplacements as $marker => $subQuery) {
- $query = str_replace($marker, $subQuery, $query);
- }
+ $queryParts[] = '(' . implode(" {$clause['logic']} ", $groupParts) . ')';
}
+ $query = implode(' ', $queryParts);
$this->reset();
return $query;
}
- /**
- * @inheritDoc
- */
+ /** @inheritDoc */
public function reset()
{
$this->clauses = [];
$this->preparedValues = [];
-
return $this;
}
protected function generatePlaceholder($field, array $values, string $operator): string
{
$operator = strtoupper($operator);
+ $placeholderFor = static fn ($value): string => $value === null ? 'NULL' : '%s';
- if($operator === 'IS NULL' || $operator === 'IS NOT NULL'){
- return "";
+ if ($operator === 'IS NULL' || $operator === 'IS NOT NULL') {
+ return '';
}
- if ($operator === 'IN' || $operator === 'NOT IN') {
+ if ($operator === 'BETWEEN' || $operator === 'NOT BETWEEN') {
+ return $placeholderFor($values[0]) . ' AND ' . $placeholderFor($values[1]);
+ }
- // Group fields with multiple $field values (%s,%s),(%s,%s), else just flatten them %s,%s,%s,%s
+ if ($operator === 'IN' || $operator === 'NOT IN') {
if (is_array($field)) {
- $subgroup = "(" . implode(', ', array_fill(0, count($field), '%s')) . ")";
- $placeholderGroup = implode(', ', array_fill(0, count($values), $subgroup));
+ $rows = array_map(
+ static fn (array $tuple): string => '('
+ . implode(', ', array_map($placeholderFor, $tuple)) . ')',
+ $values
+ );
+ $placeholderGroup = implode(', ', $rows);
} else {
- $placeholderGroup = implode(', ', array_fill(0, count($values), '%s'));
+ $placeholderGroup = implode(', ', array_map($placeholderFor, $values));
+ }
+
+ return "({$placeholderGroup})";
+ }
+
+ return $placeholderFor($values[0]);
+ }
+
+ /** @param string|string[] $field */
+ private function normalizeValues($field, string $operator, array $values): array
+ {
+ $count = count($values);
+
+ if ($operator === 'IS NULL' || $operator === 'IS NOT NULL') {
+ if ($values === [] || $values === [null]) {
+ return [];
+ }
+
+ throw new QueryBuilderException("Operator {$operator} accepts no values or one null value.");
+ }
+
+ if ($operator === 'BETWEEN' || $operator === 'NOT BETWEEN') {
+ if ($count !== 2 || is_array($values[0]) || is_array($values[1])) {
+ throw new QueryBuilderException("Operator {$operator} expects exactly two scalar or null values.");
+ }
+
+ return $values;
+ }
+
+ if ($operator === 'IN' || $operator === 'NOT IN') {
+ if ($count === 0) {
+ throw new QueryBuilderException("Operator {$operator} expects at least one value.");
+ }
+
+ if (is_array($field)) {
+ foreach ($values as $tuple) {
+ if (!is_array($tuple) || count($tuple) !== count($field)) {
+ throw new QueryBuilderException("Operator {$operator} tuple width must match the field list.");
+ }
+ }
+
+ return array_map('array_values', $values);
}
- return "($placeholderGroup)";
+ if ($count === 1 && is_array($values[0])) {
+ $nested = array_values($values[0]);
+ return $nested === [] ? [null] : $nested;
+ }
+
+ foreach ($values as $value) {
+ if (is_array($value)) {
+ throw new QueryBuilderException("Operator {$operator} received an invalid nested value.");
+ }
+ }
+
+ return $values;
+ }
+
+ if ($count !== 1 || is_array($values[0])) {
+ throw new QueryBuilderException("Operator {$operator} expects exactly one scalar or null value.");
+ }
+
+ return $values;
+ }
+
+ /** @param ClauseBuilderInterface[] $clauses */
+ private function appendGroup(string $logic, array $clauses): void
+ {
+ $this->clauses[] = [
+ 'logic' => $this->validateGroup($logic, $clauses),
+ 'clauses' => $clauses,
+ ];
+ }
+
+ /** @param ClauseBuilderInterface[] $clauses */
+ private function validateGroup(string $logic, array $clauses): string
+ {
+ $logic = strtoupper($logic);
+ if (!in_array($logic, ['AND', 'OR'], true)) {
+ throw new QueryBuilderException("Unknown group logic: {$logic}");
+ }
+
+ if ($clauses === []) {
+ throw new QueryBuilderException('A condition group cannot be empty.');
}
- return '%s';
+ return $logic;
}
-}
\ No newline at end of file
+}
diff --git a/lib/Strategies/QueryStrategy.php b/lib/Strategies/QueryStrategy.php
index d1d5f95..6d30dcc 100644
--- a/lib/Strategies/QueryStrategy.php
+++ b/lib/Strategies/QueryStrategy.php
@@ -2,6 +2,7 @@
namespace PHPNomad\Integrations\WordPress\Strategies;
+use PHPNomad\Database\Exceptions\QueryBuilderException;
use PHPNomad\Database\Interfaces\QueryBuilder;
use PHPNomad\Database\Interfaces\QueryStrategy as CoreQueryStrategy;
use PHPNomad\Database\Interfaces\Table;
@@ -33,7 +34,11 @@ public function delete(Table $table, array $ids): void
/** @inheritDoc */
public function update(Table $table, array $where, array $data): void
{
- $this->wpdbUpdate($table, $data, $where);
+ try {
+ $this->wpdbUpdate($table, $data, $where);
+ } catch (QueryBuilderException $e) {
+ throw new DatastoreErrorException('Update failed. Invalid query.', 500, $e);
+ }
}
/** @inheritDoc */
diff --git a/lib/Traits/CanQueryWordPressDatabase.php b/lib/Traits/CanQueryWordPressDatabase.php
index c42a864..54a110f 100644
--- a/lib/Traits/CanQueryWordPressDatabase.php
+++ b/lib/Traits/CanQueryWordPressDatabase.php
@@ -34,6 +34,11 @@ protected function wpdbGetResults(QueryBuilder $queryBuilder): array
throw new DatastoreErrorException('Get results failed: invalid query.', 500, $e);
}
+ if (!empty($wpdb->last_error)) {
+ $this->logDatabaseError('Get results failed', $wpdb->last_error);
+ throw new DatastoreErrorException('Get results failed.');
+ }
+
if (is_null($result)) {
$this->logDatabaseError('Get results failed', $wpdb->last_error);
throw new DatastoreErrorException('Get results failed.');
@@ -119,8 +124,9 @@ protected function wpdbInsert(Table $table, array $data): array
* @param Table $table
* @param array $data
* @param array $where
- * @return int
+ * @return void
* @throws DatastoreErrorException
+ * @throws QueryBuilderException
*/
protected function wpdbUpdate(Table $table, array $data, array $where): void
{
@@ -170,7 +176,7 @@ protected function wpdbUpdate(Table $table, array $data, array $where): void
/**
* Deletes a record from the database.
- * @param string $table
+ * @param Table $table
* @param array $where
* @return void
* @throws DatastoreErrorException
diff --git a/phpunit-integration.xml b/phpunit-integration.xml
new file mode 100644
index 0000000..dd873c7
--- /dev/null
+++ b/phpunit-integration.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ ./tests/Integration/Database
+
+
+
diff --git a/tests/Integration/Database/RealWpdbQueryContractTest.php b/tests/Integration/Database/RealWpdbQueryContractTest.php
new file mode 100644
index 0000000..5b93124
--- /dev/null
+++ b/tests/Integration/Database/RealWpdbQueryContractTest.php
@@ -0,0 +1,780 @@
+suppress_errors(true);
+ $GLOBALS['wpdb'] = self::$wpdb;
+
+ if (!self::$wpdb->ready) {
+ throw new \RuntimeException('wpdb could not connect: ' . self::$wpdb->last_error);
+ }
+
+ self::$strategy = new QueryStrategy();
+ self::$predicateTable = new ContractTable(
+ self::PREDICATE_TABLE,
+ 'predicates',
+ [
+ new Column('id', 'INT', null, 'PRIMARY KEY'),
+ new Column('score', 'INT'),
+ new Column('label', 'VARCHAR', [128]),
+ ],
+ ['id']
+ );
+ self::$compoundTable = new ContractTable(
+ self::COMPOUND_TABLE,
+ 'compound_records',
+ [
+ new Column('leftId', 'INT'),
+ new Column('rightId', 'INT'),
+ new Column('label', 'VARCHAR', [128]),
+ new Column('value', 'VARCHAR', [128]),
+ ],
+ ['leftId', 'rightId']
+ );
+
+ self::rawQuery(
+ 'CREATE TEMPORARY TABLE ' . self::PREDICATE_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(
+ "CREATE TEMPORARY TABLE {$tableName} ("
+ . 'leftId INT NOT NULL, rightId INT NOT NULL, label VARCHAR(128) NOT NULL, '
+ . 'value VARCHAR(128) NOT NULL, PRIMARY KEY (leftId, rightId)) ENGINE=InnoDB'
+ );
+ }
+ }
+
+ protected function setUp(): void
+ {
+ self::rawQuery('DELETE FROM ' . self::PREDICATE_TABLE);
+ self::rawQuery(
+ "INSERT INTO " . self::PREDICATE_TABLE . " (id, score, label) VALUES "
+ . "(1,30,'first'),(50,70,'target'),(60,NULL,'null')"
+ );
+ self::preparedQuery(
+ 'INSERT INTO ' . self::PREDICATE_TABLE . ' (id, score, label) VALUES (%d,%d,%s)',
+ [70, 90, self::literalMarkerValue()]
+ );
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ if (!isset(self::$wpdb)) {
+ return;
+ }
+
+ foreach ([self::PREDICATE_TABLE, self::COMPOUND_TABLE, self::COMPOUND_CONTROL_TABLE] as $tableName) {
+ self::rawQuery("DROP TEMPORARY TABLE IF EXISTS {$tableName}");
+ }
+
+ unset($GLOBALS['wpdb']);
+ }
+
+ public function testPackageBootstrapResolvesAndExecutesCoreQueryStrategy(): void
+ {
+
+ $container = new Container();
+ (new Bootstrapper($container, new WordPressInitializer()))->load();
+ $strategy = $container->get(CoreQueryStrategy::class);
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+
+ self::assertInstanceOf(QueryStrategy::class, $strategy);
+ self::assertSame(
+ self::rawSelect('SELECT * FROM ' . self::PREDICATE_TABLE . ' WHERE id=50 ORDER BY id'),
+ $strategy->query(self::selectQuery($clause))
+ );
+ }
+
+ /**
+ * @dataProvider validPredicateProvider
+ * @param list $values
+ */
+ public function testValidPredicatesMatchLiteralSqlControl(
+ string $entryMethod,
+ string $operator,
+ array $values,
+ string $literal
+ ): void {
+
+ $clause = (new ClauseBuilder())->useTable(self::$predicateTable);
+ $prefix = '';
+
+ if ($entryMethod === 'andWhere') {
+ $clause->where('id', '>', 0);
+ $prefix = 'id > 0 AND ';
+ } elseif ($entryMethod === 'orWhere') {
+ $clause->where('id', '=', -1);
+ $prefix = 'id = -1 OR ';
+ }
+
+ $clause->{$entryMethod}('score', $operator, ...$values)
+ ->orWhere('id', '=', 50);
+
+ $actual = self::$strategy->query(self::selectQuery($clause));
+ $control = self::rawSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE
+ . ' WHERE ' . $prefix . $literal . ' OR id = 50 ORDER BY id'
+ );
+
+ self::assertSame($control, $actual);
+ }
+
+ public static function validPredicateProvider(): iterable
+ {
+ yield 'where scalar null' => ['where', '=', [null], 'score = NULL'];
+ yield 'andWhere scalar' => ['andWhere', '>=', [30], 'score >= 30'];
+ yield 'orWhere scalar' => ['orWhere', '<', [40], 'score < 40'];
+ yield 'where less than or equal' => ['where', '<=', [30], 'score <= 30'];
+ yield 'andWhere alternate not equal' => ['andWhere', '<>', [30], 'score <> 30'];
+ yield 'orWhere not equal' => ['orWhere', '!=', [30], 'score != 30'];
+ yield 'where not like' => ['where', 'NOT LIKE', [30], 'score NOT LIKE 30'];
+ yield 'where between with null lower bound' => ['where', 'BETWEEN', [null, 40], 'score BETWEEN NULL AND 40'];
+ yield 'andWhere between' => ['andWhere', 'BETWEEN', [20, 40], 'score BETWEEN 20 AND 40'];
+ yield 'orWhere not between' => ['orWhere', 'NOT BETWEEN', [20, 40], 'score NOT BETWEEN 20 AND 40'];
+ yield 'where variadic list with null' => ['where', 'IN', [null, 30], 'score IN (NULL, 30)'];
+ yield 'andWhere nested list with null' => ['andWhere', 'IN', [[null, 30]], 'score IN (NULL, 30)'];
+ yield 'orWhere nested empty list' => ['orWhere', 'IN', [[]], 'score IN (NULL)'];
+ yield 'where not in list with null' => ['where', 'NOT IN', [30, null], 'score NOT IN (30, NULL)'];
+ yield 'andWhere is null without value' => ['andWhere', 'IS NULL', [], 'score IS NULL'];
+ yield 'orWhere is null with null value' => ['orWhere', 'IS NULL', [null], 'score IS NULL'];
+ yield 'where is not null' => ['where', 'IS NOT NULL', [], 'score IS NOT NULL'];
+ yield 'orWhere like' => ['orWhere', 'LIKE', [30], 'score LIKE 30'];
+ }
+
+ /** @dataProvider tupleListProvider */
+ public function testTupleListMatchesLiteralSqlControl(string $operator): void
+ {
+
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where(
+ ['id', 'score'],
+ $operator,
+ ['id' => 1, 'score' => 30],
+ ['id' => 50, 'score' => 70]
+ );
+
+ $actual = self::$strategy->query(self::selectQuery($clause));
+ $control = self::rawSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE
+ . " WHERE (id, score) {$operator} ((1,30),(50,70)) ORDER BY id"
+ );
+
+ self::assertSame($control, $actual);
+ }
+
+ public static function tupleListProvider(): iterable
+ {
+ yield 'IN' => ['IN'];
+ yield 'NOT IN' => ['NOT IN'];
+ }
+
+ /** @dataProvider groupEntryProvider */
+ public function testGroupsKeepParentAndChildValuePositions(string $groupMethod): void
+ {
+
+ $firstChild = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', 'first')
+ ->andWhere('score', '=', 30);
+ $secondChild = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+ $clause = (new ClauseBuilder())->useTable(self::$predicateTable);
+
+ if ($groupMethod === 'group') {
+ $clause->group('OR', $firstChild, $secondChild)->andWhere('id', '>', 0);
+ $literal = "((label = 'first' AND score = 30) OR id = 50) AND id > 0";
+ } elseif ($groupMethod === 'andGroup') {
+ $clause->where('id', '>', 0)->andGroup('OR', $firstChild, $secondChild)->orWhere('id', '=', 60);
+ $literal = "id > 0 AND ((label = 'first' AND score = 30) OR id = 50) OR id = 60";
+ } else {
+ $clause->where('id', '=', -1)->orGroup('OR', $firstChild, $secondChild)->andWhere('id', '<', 60);
+ $literal = "id = -1 OR ((label = 'first' AND score = 30) OR id = 50) AND id < 60";
+ }
+
+ $actual = self::$strategy->query(self::selectQuery($clause));
+ $control = self::rawSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE . ' WHERE ' . $literal . ' ORDER BY id'
+ );
+
+ self::assertSame($control, $actual);
+ }
+
+ public static function groupEntryProvider(): iterable
+ {
+ yield 'group' => ['group'];
+ yield 'andGroup' => ['andGroup'];
+ yield 'orGroup' => ['orGroup'];
+ }
+
+ public function testLiteralPlaceholderAndSubqueryMarkerTextStaysDataAcrossNestedGroups(): void
+ {
+
+ $literal = self::literalMarkerValue();
+ $grandchild = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+ $child = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', $literal)
+ ->orGroup('AND', $grandchild);
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', $literal)
+ ->orGroup('AND', $child);
+
+ $actual = self::$strategy->query(self::selectQuery($clause));
+ $control = self::preparedSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE
+ . ' WHERE label=%s OR (label=%s OR (id=%d)) ORDER BY id',
+ [$literal, $literal, 50]
+ );
+
+ self::assertSame($control, $actual);
+ }
+
+ public function testFloatPredicatePreservesFullStringPrecision(): void
+ {
+ self::preparedQuery(
+ 'INSERT INTO ' . self::PREDICATE_TABLE . ' (id, score, label) VALUES (%d,%d,%s)',
+ [80, 80, '0.123456789']
+ );
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', 0.123456789);
+
+ self::assertSame(
+ self::preparedSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE . ' WHERE label=%s ORDER BY id',
+ ['0.123456789']
+ ),
+ self::$strategy->query(self::selectQuery($clause))
+ );
+ }
+
+ public function testIntegerPredicateKeepsVarcharComparisonAsQuotedString(): void
+ {
+ self::preparedQuery(
+ 'INSERT INTO ' . self::PREDICATE_TABLE . ' (id, score, label) VALUES '
+ . '(%d,%d,%s),(%d,%d,%s)',
+ [80, 80, '0', 81, 81, 'not-a-number']
+ );
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', 0);
+
+ self::assertSame(
+ self::preparedSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE . ' WHERE label=%s ORDER BY id',
+ ['0']
+ ),
+ self::$strategy->query(self::selectQuery($clause))
+ );
+ }
+
+ public function testPlaceholderOverrideChangesTheExecutedPredicate(): void
+ {
+ self::preparedQuery(
+ 'INSERT INTO ' . self::PREDICATE_TABLE . ' (id, score, label) VALUES '
+ . '(%d,%d,%s),(%d,%d,%s)',
+ [80, 80, 'CaseToken', 81, 81, 'casetoken']
+ );
+ $clause = (new class () extends ClauseBuilder {
+ protected function generatePlaceholder($field, array $values, string $operator): string
+ {
+ return $operator === '='
+ ? '%s COLLATE utf8mb4_bin'
+ : parent::generatePlaceholder($field, $values, $operator);
+ }
+ })
+ ->useTable(self::$predicateTable)
+ ->where('label', '=', 'CaseToken');
+
+ self::assertSame(
+ self::preparedSelect(
+ 'SELECT * FROM ' . self::PREDICATE_TABLE
+ . ' WHERE label=%s COLLATE utf8mb4_bin ORDER BY id',
+ ['CaseToken']
+ ),
+ self::$strategy->query(self::selectQuery($clause))
+ );
+ }
+
+ /**
+ * @dataProvider invalidConditionProvider
+ * @param string|list $field
+ * @param list $values
+ */
+ public function testInvalidConditionsRejectBeforeChangingBuilderState(
+ string $entryMethod,
+ $field,
+ string $operator,
+ array $values
+ ): void {
+
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+ $lastQuery = self::$wpdb->last_query;
+ $caught = null;
+
+ try {
+ $clause->{$entryMethod}($field, $operator, ...$values);
+ } catch (Throwable $failure) {
+ $caught = $failure;
+ }
+
+ self::assertSame($lastQuery, self::$wpdb->last_query);
+
+ $actual = self::$strategy->query(
+ self::selectQuery($clause->andWhere('score', '=', 70))
+ );
+
+ self::assertInstanceOf(QueryBuilderException::class, $caught);
+ self::assertSame(
+ [['id' => '50', 'score' => '70', 'label' => 'target']],
+ $actual
+ );
+ }
+
+ public static function invalidConditionProvider(): iterable
+ {
+ yield 'where zero-argument IN' => ['where', 'score', 'IN', []];
+ yield 'andWhere zero-argument NOT IN' => ['andWhere', 'score', 'NOT IN', []];
+ yield 'orWhere short BETWEEN' => ['orWhere', 'score', 'BETWEEN', [20]];
+ yield 'where long NOT BETWEEN' => ['where', 'score', 'NOT BETWEEN', [20, 40, 60]];
+ yield 'andWhere missing scalar value' => ['andWhere', 'score', '=', []];
+ yield 'orWhere extra scalar value' => ['orWhere', 'score', '=', [30, 70]];
+ yield 'where invalid null arity' => ['where', 'score', 'IS NULL', [null, null]];
+ yield 'andWhere unknown operator' => ['andWhere', 'score', 'CONTAINS', [30]];
+ yield 'orWhere unknown scalar field' => ['orWhere', 'missing', '=', [30]];
+ yield 'where tuple with unknown field' => ['where', ['id', 'missing'], 'IN', [[1, 30]]];
+ yield 'andWhere empty field tuple' => ['andWhere', [], 'IN', [[1, 30]]];
+ yield 'orWhere short tuple row' => ['orWhere', ['id', 'score'], 'IN', [[1]]];
+ yield 'where long tuple row' => ['where', ['id', 'score'], 'IN', [[1, 30, 999]]];
+ yield 'andWhere scalar tuple row' => ['andWhere', ['id', 'score'], 'IN', [1]];
+ yield 'orWhere mixed tuple rows' => ['orWhere', ['id', 'score'], 'IN', [[1, 30], 50]];
+ }
+
+ /** @dataProvider groupEntryProvider */
+ public function testInvalidGroupLogicRejectsBeforeChangingBuilderState(string $groupMethod): void
+ {
+
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+ $child = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('score', '=', 70);
+ $caught = null;
+
+ try {
+ $clause->{$groupMethod}('XOR', $child);
+ } catch (Throwable $failure) {
+ $caught = $failure;
+ }
+
+ $actual = self::$strategy->query(
+ self::selectQuery($clause->andWhere('label', '=', 'target'))
+ );
+
+ self::assertInstanceOf(QueryBuilderException::class, $caught);
+ self::assertSame(
+ [['id' => '50', 'score' => '70', 'label' => 'target']],
+ $actual
+ );
+ }
+
+ /** @dataProvider groupEntryProvider */
+ public function testEmptyGroupRejectsBeforeChangingBuilderState(string $groupMethod): void
+ {
+
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50);
+ $caught = null;
+
+ try {
+ $clause->{$groupMethod}('AND');
+ } catch (Throwable $failure) {
+ $caught = $failure;
+ }
+
+ $actual = self::$strategy->query(
+ self::selectQuery($clause->andWhere('score', '=', 70))
+ );
+
+ self::assertInstanceOf(QueryBuilderException::class, $caught);
+ self::assertSame(
+ [['id' => '50', 'score' => '70', 'label' => 'target']],
+ $actual
+ );
+ }
+
+ public function testMalformedNestedGroupFailsBeforeWpdbExecutesSql(): void
+ {
+
+ $lastQuery = self::$wpdb->last_query;
+ $clause = null;
+ $assemblyFailure = self::captureFailure(static function () use (&$clause): void {
+ $emptyChild = (new ClauseBuilder())->useTable(self::$predicateTable);
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', 50)
+ ->andGroup('AND', $emptyChild);
+ });
+
+ if ($assemblyFailure !== null) {
+ self::assertInstanceOf(QueryBuilderException::class, $assemblyFailure);
+ self::assertSame($lastQuery, self::$wpdb->last_query);
+ return;
+ }
+
+ $strategyFailure = self::captureFailure(
+ static fn () => self::$strategy->query(self::selectQuery($clause))
+ );
+
+ self::assertSame(DatastoreErrorException::class, $strategyFailure ? get_class($strategyFailure) : null);
+ self::assertInstanceOf(QueryBuilderException::class, $strategyFailure?->getPrevious());
+ self::assertSame($lastQuery, self::$wpdb->last_query);
+ }
+
+ public function testQueryStrategyWrapsBuilderFailureWithOriginalCause(): void
+ {
+
+ $cause = new QueryBuilderException('Malformed query contract fixture.');
+ $builder = new class ($cause) extends QueryBuilder {
+ public function __construct(private QueryBuilderException $failure)
+ {
+ }
+
+ public function build(): string
+ {
+ throw $this->failure;
+ }
+ };
+ $caught = self::captureFailure(static fn () => self::$strategy->query($builder));
+
+ self::assertSame(DatastoreErrorException::class, $caught ? get_class($caught) : null);
+ self::assertSame($cause, $caught?->getPrevious());
+ }
+
+ public function testQueryStrategyClassifiesWpdbSqlErrorAsDatastoreError(): void
+ {
+
+ $builder = new class extends QueryBuilder {
+ public function build(): string
+ {
+ return 'SELECT * FROM nomad_contract_table_that_does_not_exist';
+ }
+ };
+ $caught = self::captureFailure(static fn () => self::$strategy->query($builder));
+
+ self::assertSame(DatastoreErrorException::class, $caught ? get_class($caught) : null);
+ self::assertNotSame('', self::$wpdb->last_error);
+ }
+
+ public function testQueryStrategyKeepsGenuineEmptyResultAsRecordNotFound(): void
+ {
+
+ $clause = (new ClauseBuilder())
+ ->useTable(self::$predicateTable)
+ ->where('id', '=', -999);
+ $caught = self::captureFailure(
+ static fn () => self::$strategy->query(self::selectQuery($clause))
+ );
+
+ self::assertSame(RecordNotFoundException::class, $caught ? get_class($caught) : null);
+ self::assertSame('', self::$wpdb->last_error);
+ }
+
+ public function testCompoundInsertReturnsAndPersistsWholeIdentity(): void
+ {
+
+ self::resetCompoundTables();
+ $data = ['leftId' => 4, 'rightId' => 40, 'label' => 'inserted', 'value' => 'four'];
+ $identity = self::$strategy->insert(self::$compoundTable, $data);
+ self::preparedQuery(
+ 'INSERT INTO ' . self::COMPOUND_CONTROL_TABLE
+ . ' (leftId, rightId, label, value) VALUES (%d,%d,%s,%s)',
+ array_values($data)
+ );
+
+ self::assertSame(['leftId' => 4, 'rightId' => 40], $identity);
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+ }
+
+ /** @dataProvider compoundWriteProvider */
+ public function testCompoundWritesMatchPreparedSqlControl(string $operation): void
+ {
+
+ self::resetCompoundTables();
+
+ if ($operation === 'update') {
+ self::$strategy->update(
+ self::$compoundTable,
+ ['leftId' => 2, 'rightId' => 20],
+ ['value' => 'compound-updated']
+ );
+ self::preparedQuery(
+ 'UPDATE ' . self::COMPOUND_CONTROL_TABLE . ' SET value=%s WHERE leftId=%d AND rightId=%d',
+ ['compound-updated', 2, 20]
+ );
+ } else {
+ self::$strategy->delete(self::$compoundTable, ['leftId' => 2, 'rightId' => 20]);
+ self::preparedQuery(
+ 'DELETE FROM ' . self::COMPOUND_CONTROL_TABLE . ' WHERE leftId=%d AND rightId=%d',
+ [2, 20]
+ );
+ }
+
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+ }
+
+ public static function compoundWriteProvider(): iterable
+ {
+ yield 'update' => ['update'];
+ yield 'delete' => ['delete'];
+ }
+
+ public function testLiteralPlaceholderAndMarkerStringsStayDataOnInsert(): void
+ {
+
+ self::resetCompoundTables();
+ $literal = self::literalMarkerValue() . ' ?s ?n ?i ?a ?u ?p';
+ $data = ['leftId' => 4, 'rightId' => 40, 'label' => $literal, 'value' => $literal];
+ $identity = self::$strategy->insert(self::$compoundTable, $data);
+ self::preparedQuery(
+ 'INSERT INTO ' . self::COMPOUND_CONTROL_TABLE
+ . ' (leftId, rightId, label, value) VALUES (%d,%d,%s,%s)',
+ array_values($data)
+ );
+
+ self::assertSame(['leftId' => 4, 'rightId' => 40], $identity);
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+ }
+
+ public function testLiteralPlaceholderStringsStayDataAcrossCompoundWrites(): void
+ {
+
+ $literal = 'literal %s %d %f %i %% ?s ?n ?i ?a ?u ?p';
+ self::resetCompoundTables($literal);
+ self::$strategy->update(
+ self::$compoundTable,
+ ['leftId' => 2, 'rightId' => 20],
+ ['value' => $literal]
+ );
+ self::preparedQuery(
+ 'UPDATE ' . self::COMPOUND_CONTROL_TABLE . ' SET value=%s WHERE leftId=%d AND rightId=%d',
+ [$literal, 2, 20]
+ );
+
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+
+ self::$strategy->delete(self::$compoundTable, ['leftId' => 2, 'rightId' => 20]);
+ self::preparedQuery(
+ 'DELETE FROM ' . self::COMPOUND_CONTROL_TABLE . ' WHERE leftId=%d AND rightId=%d',
+ [2, 20]
+ );
+
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+ }
+
+ public function testZeroRowUpdateWrapsExistenceProbeBuilderFailure(): void
+ {
+
+ self::resetCompoundTables();
+ $incompleteMetadata = new ContractTable(
+ self::COMPOUND_TABLE,
+ 'compound_records',
+ [new Column('value', 'VARCHAR', [128])],
+ ['leftId', 'rightId']
+ );
+ $caught = self::captureFailure(static function () use ($incompleteMetadata): void {
+ self::$strategy->update(
+ $incompleteMetadata,
+ ['leftId' => 999, 'rightId' => 999],
+ ['value' => 'missing']
+ );
+ });
+
+ self::assertSame(DatastoreErrorException::class, $caught ? get_class($caught) : null);
+ self::assertInstanceOf(QueryBuilderException::class, $caught?->getPrevious());
+ self::assertSame(self::compoundRows(self::COMPOUND_CONTROL_TABLE), self::compoundRows(self::COMPOUND_TABLE));
+ }
+
+ private static function selectQuery(ClauseBuilder $clause): QueryBuilder
+ {
+ return (new QueryBuilder())
+ ->from(self::$predicateTable)
+ ->select('*')
+ ->where($clause)
+ ->orderBy('id', 'ASC');
+ }
+
+ private static function resetCompoundTables(string $secondValue = 'two'): void
+ {
+ foreach ([self::COMPOUND_TABLE, self::COMPOUND_CONTROL_TABLE] as $tableName) {
+ self::rawQuery("DELETE FROM {$tableName}");
+ self::preparedQuery(
+ "INSERT INTO {$tableName} (leftId, rightId, label, value) VALUES "
+ . '(%d,%d,%s,%s),(%d,%d,%s,%s),(%d,%d,%s,%s),(%d,%d,%s,%s)',
+ [
+ 1, 10, 'keep-one', 'one',
+ 2, 20, 'target', $secondValue,
+ 2, 21, 'sibling', 'sibling',
+ 3, 30, 'keep-three', 'three',
+ ]
+ );
+ }
+ }
+
+ /** @return list> */
+ private static function compoundRows(string $tableName): array
+ {
+ return self::rawSelect(
+ "SELECT leftId, rightId, label, value FROM {$tableName} ORDER BY leftId, rightId"
+ );
+ }
+
+ private static function rawQuery(string $sql): void
+ {
+ $result = self::$wpdb->query($sql);
+
+ if ($result === false || self::$wpdb->last_error !== '') {
+ throw new \RuntimeException('wpdb query failed: ' . self::$wpdb->last_error . ' SQL=' . $sql);
+ }
+ }
+
+ /** @param list $values */
+ private static function preparedQuery(string $sql, array $values): void
+ {
+ $prepared = self::$wpdb->prepare($sql, ...$values);
+
+ if (!is_string($prepared) || $prepared === '') {
+ throw new \RuntimeException('wpdb could not prepare control SQL: ' . $sql);
+ }
+
+ self::rawQuery($prepared);
+ }
+
+ /** @return list> */
+ private static function rawSelect(string $sql): array
+ {
+ $result = self::$wpdb->get_results($sql, ARRAY_A);
+
+ if (!is_array($result) || self::$wpdb->last_error !== '') {
+ throw new \RuntimeException('wpdb control SELECT failed: ' . self::$wpdb->last_error . ' SQL=' . $sql);
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param list $values
+ * @return list>
+ */
+ private static function preparedSelect(string $sql, array $values): array
+ {
+ $prepared = self::$wpdb->prepare($sql, ...$values);
+
+ if (!is_string($prepared) || $prepared === '') {
+ throw new \RuntimeException('wpdb could not prepare control SELECT: ' . $sql);
+ }
+
+ return self::rawSelect($prepared);
+ }
+
+ private static function literalMarkerValue(): string
+ {
+ return "__NOMADIC_SUBQUERY__1 %s %i %% O'Reilly";
+ }
+
+ private static function captureFailure(callable $operation): ?Throwable
+ {
+ try {
+ $operation();
+ } catch (Throwable $failure) {
+ return $failure;
+ }
+
+ return null;
+ }
+
+ private static function environment(string $name, string $default): string
+ {
+ $value = getenv($name);
+
+ return is_string($value) && $value !== '' ? $value : $default;
+ }
+}
diff --git a/tests/Integration/Support/ContractTable.php b/tests/Integration/Support/ContractTable.php
new file mode 100644
index 0000000..138b89a
--- /dev/null
+++ b/tests/Integration/Support/ContractTable.php
@@ -0,0 +1,73 @@
+ $columns
+ * @param non-empty-list $identity
+ */
+ public function __construct(
+ private string $name,
+ private string $alias,
+ private array $columns,
+ private array $identity
+ ) {
+ }
+
+ public function getName(): string
+ {
+ return $this->name;
+ }
+
+ public function getAlias(): string
+ {
+ return $this->alias;
+ }
+
+ public function getTableVersion(): string
+ {
+ return '1';
+ }
+
+ public function getColumns(): array
+ {
+ return $this->columns;
+ }
+
+ public function getIndices(): array
+ {
+ return [];
+ }
+
+ public function getCharset(): ?string
+ {
+ return 'utf8mb4';
+ }
+
+ public function getCollation(): ?string
+ {
+ return 'utf8mb4_unicode_ci';
+ }
+
+ public function getFieldsForIdentity(): array
+ {
+ return $this->identity;
+ }
+
+ public function getUnprefixedName(): string
+ {
+ return $this->name;
+ }
+
+ public function getSingularUnprefixedName(): string
+ {
+ return $this->alias;
+ }
+}
diff --git a/tests/Integration/bootstrap.php b/tests/Integration/bootstrap.php
new file mode 100644
index 0000000..50953ea
--- /dev/null
+++ b/tests/Integration/bootstrap.php
@@ -0,0 +1,66 @@
+ $wordpressRoot . '/wp-includes/load.php',
+ 'plugin' => $wordpressRoot . '/wp-includes/plugin.php',
+ 'functions' => $wordpressRoot . '/wp-includes/functions.php',
+ 'version' => $wordpressRoot . '/wp-includes/version.php',
+ 'wpdb' => $wordpressRoot . '/wp-includes/class-wpdb.php',
+];
+
+foreach ($requiredFiles as $requiredFile) {
+ if (!is_file($requiredFile)) {
+ throw new RuntimeException("Required WordPress runtime file is missing: {$requiredFile}");
+ }
+}
+
+if (!defined('ABSPATH')) {
+ define('ABSPATH', $wordpressRoot . '/');
+}
+
+if (!defined('WPINC')) {
+ define('WPINC', 'wp-includes');
+}
+
+if (!defined('WP_DEBUG')) {
+ define('WP_DEBUG', false);
+}
+
+if (!defined('WP_DEBUG_DISPLAY')) {
+ define('WP_DEBUG_DISPLAY', false);
+}
+
+require_once $requiredFiles['version'];
+
+if (!isset($wp_version) || $wp_version !== $runtimeMetadata['version']) {
+ throw new RuntimeException(sprintf(
+ 'WORDPRESS_ROOT must contain WordPress %s from %s at %s.',
+ $runtimeMetadata['version'],
+ $runtimeMetadata['source'],
+ $runtimeMetadata['commit']
+ ));
+}
+
+require_once $requiredFiles['load'];
+require_once $requiredFiles['plugin'];
+require_once $requiredFiles['functions'];
+wp_load_translations_early();
+require_once $requiredFiles['wpdb'];
diff --git a/tests/Integration/wordpress-runtime.json b/tests/Integration/wordpress-runtime.json
new file mode 100644
index 0000000..2b37dcf
--- /dev/null
+++ b/tests/Integration/wordpress-runtime.json
@@ -0,0 +1,6 @@
+{
+ "version": "6.8.3",
+ "source": "https://github.com/WordPress/WordPress.git",
+ "ref": "6.8.3",
+ "commit": "ba9e7f97f08a7fbb88fbfa35641bcc230500b37a"
+}
diff --git a/tests/Unit/Database/ClauseBuilderTest.php b/tests/Unit/Database/ClauseBuilderTest.php
new file mode 100644
index 0000000..b33cfdd
--- /dev/null
+++ b/tests/Unit/Database/ClauseBuilderTest.php
@@ -0,0 +1,110 @@
+table = $this->createMock(Table::class);
+ $this->table->method('getAlias')->willReturn('records');
+ $this->table->method('getColumns')->willReturn([
+ new Column('id', 'INT'),
+ new Column('score', 'INT'),
+ new Column('label', 'VARCHAR', [128]),
+ ]);
+
+ $GLOBALS['wpdb'] = new class () {
+ public function prepare(string $format, ...$values): string
+ {
+ return vsprintf($format, array_map(
+ static fn ($value): string => "'" . addslashes((string) $value) . "'",
+ $values
+ ));
+ }
+ };
+ }
+
+ protected function tearDown(): void
+ {
+ unset($GLOBALS['wpdb']);
+ parent::tearDown();
+ }
+
+ public function testBuildPreservesNullsAndLiteralMarkerTextAcrossGroups(): void
+ {
+ $literal = "__NOMADIC_SUBQUERY__1 %s %i %% O'Reilly";
+ $child = (new ClauseBuilder())
+ ->useTable($this->table)
+ ->where('score', 'IN', null, 30);
+ $builder = (new ClauseBuilder())
+ ->useTable($this->table)
+ ->where('label', '=', $literal)
+ ->orGroup('AND', $child);
+
+ self::assertSame(
+ "records.label = '__NOMADIC_SUBQUERY__1 %s %i %% O\\'Reilly' OR (records.score IN (NULL, '30'))",
+ $builder->build()
+ );
+ }
+
+ public function testInvalidTupleWidthLeavesExistingClauseIntact(): void
+ {
+ $builder = (new ClauseBuilder())
+ ->useTable($this->table)
+ ->where('id', '=', 50);
+
+ try {
+ $builder->andWhere(['id', 'score'], 'IN', [1]);
+ self::fail('A short tuple row must be rejected.');
+ } catch (QueryBuilderException $exception) {
+ self::assertSame('Operator IN tuple width must match the field list.', $exception->getMessage());
+ }
+
+ self::assertSame("records.id = '50'", $builder->build());
+ }
+
+ public function testProtectedConditionSeamNormalizesValidLogic(): void
+ {
+ $builder = $this->conditionSeamBuilder();
+ $builder->where('id', '=', 50)->addUsingLogic('and');
+
+ self::assertSame("records.id = '50' AND records.score = '30'", $builder->build());
+ }
+
+ public function testProtectedConditionSeamRejectsInvalidLogicWithoutChangingState(): void
+ {
+ $builder = $this->conditionSeamBuilder();
+ $builder->where('id', '=', 50);
+ $caught = null;
+
+ try {
+ $builder->addUsingLogic('XOR');
+ } catch (QueryBuilderException $exception) {
+ $caught = $exception;
+ }
+
+ self::assertInstanceOf(QueryBuilderException::class, $caught);
+ self::assertSame("records.id = '50'", $builder->build());
+ }
+
+ private function conditionSeamBuilder(): ClauseBuilder
+ {
+ return (new class () extends ClauseBuilder {
+ public function addUsingLogic(string $logic): self
+ {
+ return $this->addCondition('score', '=', [30], $logic);
+ }
+ })->useTable($this->table);
+ }
+}
diff --git a/tests/Unit/Strategies/QueryStrategyTest.php b/tests/Unit/Strategies/QueryStrategyTest.php
new file mode 100644
index 0000000..10e8612
--- /dev/null
+++ b/tests/Unit/Strategies/QueryStrategyTest.php
@@ -0,0 +1,34 @@
+cause;
+ }
+ };
+
+ try {
+ $strategy->update($this->createMock(Table::class), ['id' => 1], ['value' => 'changed']);
+ self::fail('The builder failure must be translated at the strategy boundary.');
+ } catch (DatastoreErrorException $exception) {
+ self::assertSame($cause, $exception->getPrevious());
+ }
+ }
+}
diff --git a/tests/Unit/Traits/CanQueryWordPressDatabaseSqlErrorTest.php b/tests/Unit/Traits/CanQueryWordPressDatabaseSqlErrorTest.php
new file mode 100644
index 0000000..9d65f8d
--- /dev/null
+++ b/tests/Unit/Traits/CanQueryWordPressDatabaseSqlErrorTest.php
@@ -0,0 +1,55 @@
+createMock(QueryBuilder::class);
+ $queryBuilder->method('build')->willReturn('SELECT * FROM missing_table');
+
+ $GLOBALS['wpdb'] = new class () {
+ public string $last_error = 'Table does not exist';
+
+ public function get_results(string $query, string $output): array
+ {
+ return [];
+ }
+ };
+
+ $subject = new class () {
+ use CanQueryWordPressDatabase;
+
+ public function getResults(QueryBuilder $queryBuilder): array
+ {
+ return $this->wpdbGetResults($queryBuilder);
+ }
+ };
+
+ $this->expectException(DatastoreErrorException::class);
+ $this->expectExceptionMessage('Get results failed.');
+
+ $subject->getResults($queryBuilder);
+ }
+}