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