diff --git a/src/xPDO/Om/mysql/xPDOQuery.php b/src/xPDO/Om/mysql/xPDOQuery.php index 3898823..0e94408 100644 --- a/src/xPDO/Om/mysql/xPDOQuery.php +++ b/src/xPDO/Om/mysql/xPDOQuery.php @@ -33,6 +33,10 @@ public function construct() { $this->select('*'); } foreach ($this->query['columns'] as $alias => $column) { + if ($column instanceof \xPDO\Om\xPDOExpression) { + $columns[]= $column->getExpression(); + continue; + } $ignorealias = is_int($alias); $escape = !preg_match('/\bAS\b/i', $column) && !preg_match('/\./', $column) && !preg_match('/\(/', $column); if ($escape) { @@ -78,7 +82,9 @@ public function construct() { foreach ($this->query['set'] as $setKey => $setVal) { $value = $setVal['value']; $type = $setVal['type']; - if ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { + if ($value instanceof \xPDO\Om\xPDOExpression) { + $value = $value->getExpression(); + } elseif ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { $value = $this->xpdo->quote($value, $type); } elseif ($value === null) { $value = 'NULL'; diff --git a/src/xPDO/Om/pgsql/xPDOQuery.php b/src/xPDO/Om/pgsql/xPDOQuery.php index 095eeec..475d95c 100644 --- a/src/xPDO/Om/pgsql/xPDOQuery.php +++ b/src/xPDO/Om/pgsql/xPDOQuery.php @@ -24,6 +24,10 @@ public function construct() { $this->select('*'); } foreach ($this->query['columns'] as $alias => $column) { + if ($column instanceof \xPDO\Om\xPDOExpression) { + $columns[]= $column->getExpression(); + continue; + } $ignorealias = is_int($alias); $escape = !preg_match('/\bAS\b/i', $column) && !preg_match('/\./', $column) && !preg_match('/\(/', $column); if ($escape) { @@ -69,7 +73,9 @@ public function construct() { foreach ($this->query['set'] as $setKey => $setVal) { $value = $setVal['value']; $type = $setVal['type']; - if ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { + if ($value instanceof \xPDO\Om\xPDOExpression) { + $value = $value->getExpression(); + } elseif ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { $value = $this->xpdo->quote($value, $type); } elseif ($value === null) { $value = 'NULL'; diff --git a/src/xPDO/Om/sqlite/xPDOQuery.php b/src/xPDO/Om/sqlite/xPDOQuery.php index f236440..288b46d 100644 --- a/src/xPDO/Om/sqlite/xPDOQuery.php +++ b/src/xPDO/Om/sqlite/xPDOQuery.php @@ -33,6 +33,10 @@ public function construct() { $this->select('*'); } foreach ($this->query['columns'] as $alias => $column) { + if ($column instanceof \xPDO\Om\xPDOExpression) { + $columns[]= $column->getExpression(); + continue; + } $ignorealias = is_int($alias); $escape = !preg_match('/\bAS\b/i', $column) && !preg_match('/\./', $column) && !preg_match('/\(/', $column); if ($escape) { @@ -78,7 +82,9 @@ public function construct() { foreach ($this->query['set'] as $setKey => $setVal) { $value = $setVal['value']; $type = $setVal['type']; - if ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { + if ($value instanceof \xPDO\Om\xPDOExpression) { + $value = $value->getExpression(); + } elseif ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { $value = $this->xpdo->quote($value, $type); } elseif ($value === null) { $value = 'NULL'; diff --git a/src/xPDO/Om/sqlsrv/xPDOQuery.php b/src/xPDO/Om/sqlsrv/xPDOQuery.php index d191ea9..1e4af24 100644 --- a/src/xPDO/Om/sqlsrv/xPDOQuery.php +++ b/src/xPDO/Om/sqlsrv/xPDOQuery.php @@ -10,6 +10,7 @@ namespace xPDO\Om\sqlsrv; +use xPDO\Om\xPDOExpression; use xPDO\Om\xPDOQueryCondition; use xPDO\xPDO; @@ -57,6 +58,9 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) if (is_array($val)) { $result[]= $this->parseConditions($val, $conjunction); continue; + } elseif ($val instanceof xPDOExpression) { + $result[]= new xPDOQueryCondition(array('sql' => $val->getExpression(), 'binding' => null, 'conjunction' => $conjunction)); + continue; } elseif ($this->isConditionalClause($val)) { $result[]= new xPDOQueryCondition(array('sql' => $val, 'binding' => null, 'conjunction' => $conjunction)); continue; @@ -64,7 +68,7 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing condition with key {$key}: " . print_r($val, true)); continue; } - } elseif (is_scalar($val) || is_array($val) || $val === null) { + } elseif (is_scalar($val) || is_array($val) || $val === null || $val instanceof xPDOExpression) { $alias= $command == 'SELECT' ? $this->_class : trim($this->xpdo->getTableName($this->_class, false), $this->xpdo->_escapeCharOpen . $this->xpdo->_escapeCharClose); $operator= '='; $conj = $conjunction; @@ -83,62 +87,77 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) $alias= trim($key_parts[0], " {$this->xpdo->_escapeCharOpen}{$this->xpdo->_escapeCharClose}"); $key= $key_parts[1]; } - if ($val === null) { - $type= \PDO::PARAM_NULL; - if (!in_array($operator, array('IS', 'IS NOT'))) { - $operator= $operator === '!=' ? 'IS NOT' : 'IS'; + if (!empty($key)) { + if ($val instanceof xPDOExpression) { + $type= null; + $sql = $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' ' . $val->getExpression(); + $result[]= new xPDOQueryCondition(array('sql' => $sql, 'binding' => null, 'conjunction' => $conj)); + continue; } - } - elseif (isset($fieldMeta[$key]) && !in_array($fieldMeta[$key]['phptype'], $this->_quotable)) { - $type= \PDO::PARAM_INT; - } - else { - $type= \PDO::PARAM_STR; - } - if (in_array(strtoupper($operator), array('IN', 'NOT IN')) && is_array($val)) { - $vals = array(); - foreach ($val as $v) { - switch ($type) { - case \PDO::PARAM_INT: - $vals[] = (int) $v; - break; - case \PDO::PARAM_STR: - $vals[] = $this->xpdo->quote($v); - break; - default: - $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing {$operator} condition with key {$key}: " . print_r($v, true)); - break; + elseif ($val === null) { + $type= \PDO::PARAM_NULL; + if (!in_array($operator, array('IS', 'IS NOT'))) { + $operator= $operator === '!=' ? 'IS NOT' : 'IS'; } } - if (!empty($vals)) { - $val = "(" . implode(',', $vals) . ")"; - $sql = "{$this->xpdo->escape($alias)}.{$this->xpdo->escape($key)} {$operator} {$val}"; - $result[]= new xPDOQueryCondition(array('sql' => $sql, 'binding' => null, 'conjunction' => $conj)); - continue; + elseif (isset($fieldMeta[$key]) && !in_array($fieldMeta[$key]['phptype'], $this->_quotable)) { + $type= \PDO::PARAM_INT; + } + else { + $type= \PDO::PARAM_STR; + } + if (in_array(strtoupper($operator), array('IN', 'NOT IN')) && is_array($val)) { + $vals = array(); + foreach ($val as $v) { + switch ($type) { + case \PDO::PARAM_INT: + $vals[] = (int) $v; + break; + case \PDO::PARAM_STR: + $vals[] = $this->xpdo->quote($v); + break; + default: + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing {$operator} condition with key {$key}: " . print_r($v, true)); + break; + } + } + if (!empty($vals)) { + $val = "(" . implode(',', $vals) . ")"; + $sql = "{$this->xpdo->escape($alias)}.{$this->xpdo->escape($key)} {$operator} {$val}"; + $result[]= new xPDOQueryCondition(array('sql' => $sql, 'binding' => null, 'conjunction' => $conj)); + continue; + } else { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing {$operator} condition with key {$key}: " . print_r($val, true)); + continue; + } + } + $field= array (); + if ($type === \PDO::PARAM_NULL) { + $field['sql']= $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' NULL'; + $field['binding']= null; + $field['conjunction']= $conj; } else { - $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing {$operator} condition with key {$key}: " . print_r($val, true)); - continue; + $field['sql']= $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' ?'; + $field['binding']= array ( + 'value' => $val, + 'type' => $type, + 'length' => 0 + ); + $field['conjunction']= $conj; } + $result[]= new xPDOQueryCondition($field); } - $field= array (); - if ($type === \PDO::PARAM_NULL) { - $field['sql']= $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' NULL'; - $field['binding']= null; - $field['conjunction']= $conj; - } else { - $field['sql']= $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' ?'; - $field['binding']= array ( - 'value' => $val, - 'type' => $type, - 'length' => 0 - ); - $field['conjunction']= $conj; - } - $result[]= new xPDOQueryCondition($field); } } } } + elseif ($conditions instanceof xPDOExpression) { + $result= new xPDOQueryCondition(array( + 'sql' => $conditions->getExpression() + ,'binding' => null + ,'conjunction' => $conjunction + )); + } elseif ($this->isConditionalClause($conditions)) { $result= new xPDOQueryCondition(array( 'sql' => $conditions @@ -201,6 +220,10 @@ public function construct() { $this->select('*'); } foreach ($this->query['columns'] as $alias => $column) { + if ($column instanceof \xPDO\Om\xPDOExpression) { + $columns[]= $column->getExpression(); + continue; + } $ignorealias = is_int($alias); $escape = !preg_match('/\bAS\b/i', $column) && !preg_match('/\./', $column) && !preg_match('/\(/', $column); if ($escape) { @@ -249,7 +272,9 @@ public function construct() { foreach ($this->query['set'] as $setKey => $setVal) { $value = $setVal['value']; $type = $setVal['type']; - if ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { + if ($value instanceof \xPDO\Om\xPDOExpression) { + $value = $value->getExpression(); + } elseif ($value !== null && in_array($type, array(\PDO::PARAM_INT, \PDO::PARAM_STR))) { $value = $this->xpdo->quote($value, $type); } elseif ($value === null) { $value = 'NULL'; diff --git a/src/xPDO/Om/xPDOExpression.php b/src/xPDO/Om/xPDOExpression.php new file mode 100644 index 0000000..12b1c0d --- /dev/null +++ b/src/xPDO/Om/xPDOExpression.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Om; + +/** + * A value object wrapping a raw SQL expression for use in xPDOQuery. + * + * Pass an instance of this class wherever xPDOQuery accepts a column value + * or a SELECT column entry when you need a verbatim SQL fragment — for example, + * a function call (NOW(), CURRENT_TIMESTAMP), an arithmetic expression + * (counter + 1), or an aggregate with alias (COUNT(*) AS total). Valid contexts + * include SET, SELECT, WHERE, HAVING, GROUP BY, and ORDER BY clauses. + * + * xPDOExpression is intentionally restricted to the query layer. Do NOT pass + * it to xPDOObject::set() or xPDOObject::save(); PDO parameterised bindings + * will treat it as a string literal there. + * + * SECURITY CONTRACT: The expression string is embedded verbatim into the + * generated SQL without any quoting, escaping, or parameterisation. The caller + * is solely responsible for ensuring the expression is safe. User-supplied + * input must NEVER be passed directly to this class or to the xPDO::expression() + * factory. Violating this contract will result in SQL injection vulnerabilities. + * + * @package xPDO\Om + */ +final class xPDOExpression +{ + private string $expression; + + /** + * @param string $expression A trusted, developer-controlled SQL fragment. + * MUST NOT contain unsanitised user input. + * @psalm-taint-sink sql $expression + */ + public function __construct(string $expression) + { + $this->expression = $expression; + } + + public function getExpression(): string + { + return $this->expression; + } +} diff --git a/src/xPDO/Om/xPDOQuery.php b/src/xPDO/Om/xPDOQuery.php index c4e9890..f7791d5 100644 --- a/src/xPDO/Om/xPDOQuery.php +++ b/src/xPDO/Om/xPDOQuery.php @@ -258,13 +258,23 @@ public function set(array $values) { } } if (array_key_exists($key, $fieldMeta)) { - if ($value === null) { + if ($value instanceof xPDOExpression) { + // Raw SQL expression: leave $type as null to signal verbatim inline. + $type= null; + } + elseif ($value === null) { $type= \PDO::PARAM_NULL; } elseif (!in_array($fieldMeta[$key]['phptype'], $this->_quotable)) { $type= \PDO::PARAM_INT; } - elseif (strpos($value, '(') === false && !$this->isConditionalClause($value)) { + else { + // Plain string value: always quote via PDO::PARAM_STR. + // isConditionalClause() is intentionally NOT called here; it + // belongs only in parseConditions()/where() where SQL clauses + // are expected. In a SET value context a plain string is always + // data, never a SQL fragment, unless the caller explicitly wraps + // it in an xPDOExpression. $type= \PDO::PARAM_STR; } $this->query['set'][$key]= array('value' => $value, 'type' => $type); @@ -423,7 +433,8 @@ public function orCondition($conditions, $binding= null, $group= 0) { /** * Add an ORDER BY clause to the query. * - * @param string $column Column identifier to sort by. + * @param string|xPDOExpression $column Column identifier to sort by, or an + * xPDOExpression whose raw SQL is inlined verbatim (bypasses injection check). * @param string $direction The direction to sort by, ASC or DESC. * @return xPDOQuery Returns the instance. */ @@ -433,7 +444,9 @@ public function sortby($column, $direction= 'ASC') { $direction = ''; } - if (!static::isValidClause($column)) { + if ($column instanceof xPDOExpression) { + $this->query['sortby'][] = array('column' => $column->getExpression(), 'direction' => $direction); + } elseif (!static::isValidClause($column)) { $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, 'SQL injection attempt detected in sortby column; clause rejected'); } elseif (!empty($column)) { $this->query['sortby'][] = array('column' => $column, 'direction' => $direction); @@ -449,7 +462,11 @@ public function sortby($column, $direction= 'ASC') { * @return xPDOQuery Returns the instance. */ public function groupby($column, $direction= '') { - $this->query['groupby'][]= array ('column' => $column, 'direction' => $direction); + if ($column instanceof xPDOExpression) { + $this->query['groupby'][] = array('column' => $column->getExpression(), 'direction' => $direction); + } else { + $this->query['groupby'][]= array ('column' => $column, 'direction' => $direction); + } return $this; } @@ -715,6 +732,9 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) if (is_array($val)) { $result[]= $this->parseConditions($val, $conjunction); continue; + } elseif ($val instanceof xPDOExpression) { + $result[]= new xPDOQueryCondition(array('sql' => $val->getExpression(), 'binding' => null, 'conjunction' => $conjunction)); + continue; } elseif ($this->isConditionalClause($val)) { $result[]= new xPDOQueryCondition(array('sql' => $val, 'binding' => null, 'conjunction' => $conjunction)); continue; @@ -722,7 +742,7 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, "Error parsing condition with key {$key}: " . print_r($val, true)); continue; } - } elseif (is_scalar($val) || is_array($val) || $val === null) { + } elseif (is_scalar($val) || is_array($val) || $val === null || $val instanceof xPDOExpression) { $alias= $command == 'SELECT' ? $this->_alias : $this->xpdo->getTableName($this->_class, false); $alias= trim($alias, $this->xpdo->_escapeCharOpen . $this->xpdo->_escapeCharClose); $operator= '='; @@ -750,7 +770,13 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) } } if (!empty($key)) { - if ($val === null) { + if ($val instanceof xPDOExpression) { + $type= null; + $sql = $this->xpdo->escape($alias) . '.' . $this->xpdo->escape($key) . ' ' . $operator . ' ' . $val->getExpression(); + $result[]= new xPDOQueryCondition(array('sql' => $sql, 'binding' => null, 'conjunction' => $conj)); + continue; + } + elseif ($val === null) { $type= \PDO::PARAM_NULL; if (!in_array($operator, array('IS', 'IS NOT'))) { $operator= $operator === '!=' ? 'IS NOT' : 'IS'; @@ -805,6 +831,13 @@ public function parseConditions($conditions, $conjunction = xPDOQuery::SQL_AND) } } } + elseif ($conditions instanceof xPDOExpression) { + $result= new xPDOQueryCondition(array( + 'sql' => $conditions->getExpression() + ,'binding' => null + ,'conjunction' => $conjunction + )); + } elseif ($this->isConditionalClause($conditions)) { $result= new xPDOQueryCondition(array( 'sql' => $conditions diff --git a/src/xPDO/xPDO.php b/src/xPDO/xPDO.php index 52814be..a2c0dd7 100644 --- a/src/xPDO/xPDO.php +++ b/src/xPDO/xPDO.php @@ -2683,6 +2683,32 @@ public function newQuery($class, $criteria= null, $cacheFlag= true) { return $query; } + /** + * Create an xPDOExpression value object wrapping a raw SQL fragment. + * + * This is the preferred public API for constructing xPDOExpression instances. + * Use this factory method (rather than instantiating xPDOExpression directly) + * when you need to pass a verbatim SQL expression (e.g. NOW(), counter + 1, + * COUNT(*) AS total) to xPDOQuery without the value being quoted or escaped. + * + * This is a query-layer feature only. Do not pass xPDOExpression instances + * to xPDOObject::set() or xPDOObject::save(). + * + * SECURITY WARNING: The expression string is embedded verbatim into generated + * SQL without any escaping or parameterisation. User-supplied input must NEVER + * be passed directly to this method. Only developer-controlled, trusted strings + * are safe to use here. + * + * @param string $expr A trusted, developer-controlled SQL expression string. + * MUST NOT contain unsanitised user input. + * @return \xPDO\Om\xPDOExpression + * @psalm-taint-sink sql $expr + */ + public function expression(string $expr): \xPDO\Om\xPDOExpression + { + return new \xPDO\Om\xPDOExpression($expr); + } + /** * Splits a string on a specified character, ignoring escaped content. * diff --git a/test/complete.phpunit.xml b/test/complete.phpunit.xml index 87acd1e..09c5929 100644 --- a/test/complete.phpunit.xml +++ b/test/complete.phpunit.xml @@ -30,6 +30,9 @@ ./xPDO/Test/Om/xPDOQuerySortByTest.php ./xPDO/Test/Om/xPDOGeneratorTest.php ./xPDO/Test/Om/xPDOManagerCreateObjectContainerTest.php + ./xPDO/Test/Om/xPDOExpressionTest.php + ./xPDO/Test/Om/xPDOQuerySetTest.php + ./xPDO/Test/Om/xPDOQuerySelectTest.php ./xPDO/Test/Cache/xPDOCacheManagerTest.php ./xPDO/Test/Cache/xPDOCacheDbTest.php ./xPDO/Test/Compression/xPDOZipTest.php diff --git a/test/mysql.phpunit.xml b/test/mysql.phpunit.xml index 87acd1e..09c5929 100644 --- a/test/mysql.phpunit.xml +++ b/test/mysql.phpunit.xml @@ -30,6 +30,9 @@ ./xPDO/Test/Om/xPDOQuerySortByTest.php ./xPDO/Test/Om/xPDOGeneratorTest.php ./xPDO/Test/Om/xPDOManagerCreateObjectContainerTest.php + ./xPDO/Test/Om/xPDOExpressionTest.php + ./xPDO/Test/Om/xPDOQuerySetTest.php + ./xPDO/Test/Om/xPDOQuerySelectTest.php ./xPDO/Test/Cache/xPDOCacheManagerTest.php ./xPDO/Test/Cache/xPDOCacheDbTest.php ./xPDO/Test/Compression/xPDOZipTest.php diff --git a/test/pgsql.phpunit.xml b/test/pgsql.phpunit.xml index 31070e1..b7536fd 100644 --- a/test/pgsql.phpunit.xml +++ b/test/pgsql.phpunit.xml @@ -30,6 +30,9 @@ ./xPDO/Test/Om/xPDOQuerySortByTest.php ./xPDO/Test/Om/xPDOGeneratorTest.php ./xPDO/Test/Om/xPDOManagerCreateObjectContainerTest.php + ./xPDO/Test/Om/xPDOExpressionTest.php + ./xPDO/Test/Om/xPDOQuerySetTest.php + ./xPDO/Test/Om/xPDOQuerySelectTest.php ./xPDO/Test/Cache/xPDOCacheManagerTest.php ./xPDO/Test/Cache/xPDOCacheDbTest.php ./xPDO/Test/Compression/xPDOZipTest.php diff --git a/test/sqlite.phpunit.xml b/test/sqlite.phpunit.xml index fb433ab..1938d58 100644 --- a/test/sqlite.phpunit.xml +++ b/test/sqlite.phpunit.xml @@ -30,6 +30,11 @@ ./xPDO/Test/Om/xPDOQuerySortByTest.php ./xPDO/Test/Om/xPDOGeneratorTest.php ./xPDO/Test/Om/xPDOManagerCreateObjectContainerTest.php + ./xPDO/Test/Om/xPDOExpressionTest.php + ./xPDO/Test/Om/xPDOQuerySetTest.php + ./xPDO/Test/Om/xPDOQuerySelectTest.php + ./xPDO/Test/Om/xPDOQueryWhereTest.php + ./xPDO/Test/Om/xPDOQueryJoinTest.php ./xPDO/Test/Cache/xPDOCacheManagerTest.php ./xPDO/Test/Cache/xPDOCacheDbTest.php ./xPDO/Test/Compression/xPDOZipTest.php diff --git a/test/xPDO/Test/Om/xPDOExpressionTest.php b/test/xPDO/Test/Om/xPDOExpressionTest.php new file mode 100644 index 0000000..0306153 --- /dev/null +++ b/test/xPDO/Test/Om/xPDOExpressionTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Test\Om; + +use xPDO\Om\xPDOExpression; +use xPDO\TestCase; + +/** + * Tests for the xPDOExpression value object and the xPDO::expression() factory method. + * + * @package xPDO\Test\Om + */ +class xPDOExpressionTest extends TestCase +{ + /** + * Verify that xPDO::expression() returns an xPDOExpression instance. + */ + public function testExpressionFactoryReturnsObject() + { + $expr = $this->xpdo->expression('NOW()'); + $this->assertInstanceOf(xPDOExpression::class, $expr, + 'xPDO::expression() must return an instance of xPDOExpression'); + } + + /** + * Verify that xPDOExpression::getExpression() returns the raw string passed + * to the constructor without modification. + */ + public function testExpressionGetExpressionReturnsString() + { + $raw = 'counter + 1'; + $expr = new xPDOExpression($raw); + $this->assertSame($raw, $expr->getExpression(), + 'xPDOExpression::getExpression() must return the exact string supplied to the constructor'); + } +} diff --git a/test/xPDO/Test/Om/xPDOQueryJoinTest.php b/test/xPDO/Test/Om/xPDOQueryJoinTest.php new file mode 100644 index 0000000..de04da0 --- /dev/null +++ b/test/xPDO/Test/Om/xPDOQueryJoinTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Test\Om; + +use xPDO\Om\xPDOExpression; +use xPDO\TestCase; +use xPDO\xPDO; + +/** + * Tests for xPDOExpression support in xPDOQuery join() conditions. + * + * The join() $conditions argument flows through condition() -> parseConditions(), + * which already handles xPDOExpression in all code paths. These tests confirm + * that passing an xPDOExpression as the join condition results in the expression + * being inlined verbatim in the JOIN ON clause — not quoted, not dropped. + * + * @package xPDO\Test\Om + */ +class xPDOQueryJoinTest extends TestCase +{ + /** + * @before + */ + public function setUpFixtures() + { + parent::setUpFixtures(); + try { + $this->xpdo->getManager(); + $this->xpdo->manager->createObjectContainer('xPDO\\Test\\Sample\\Person'); + $this->xpdo->manager->createObjectContainer('xPDO\\Test\\Sample\\BloodType'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + } + + /** + * @after + */ + public function tearDownFixtures() + { + try { + $this->xpdo->manager->removeObjectContainer('xPDO\\Test\\Sample\\BloodType'); + $this->xpdo->manager->removeObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + parent::tearDownFixtures(); + } + + /** + * An xPDOExpression passed as the $conditions argument to leftJoin() must be + * inlined verbatim in the JOIN ON clause — not quoted, not dropped. + * + * The join() method delegates $conditions to condition() -> parseConditions(), + * which handles bare xPDOExpression objects via the `instanceof xPDOExpression` + * branch. This test confirms the full path from leftJoin() through to the + * generated SQL. + */ + public function testJoinConditionExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->leftJoin( + 'xPDO\\Test\\Sample\\BloodType', + 'BloodType', + new xPDOExpression('BloodType.type = Person.blood_type') + ); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('JOIN', $sql, + 'A leftJoin() call must produce a JOIN clause. SQL was: ' . $sql); + + $this->assertStringContainsString('BloodType.type = Person.blood_type', $sql, + 'An xPDOExpression in leftJoin() conditions must be inlined verbatim in the ON clause. SQL was: ' . $sql); + } + + /** + * An xPDOExpression passed as an associative-key condition to leftJoin() must + * be inlined verbatim in the JOIN ON clause without quoting the expression value. + * + * This exercises the associative array code path in parseConditions() where + * the value is an xPDOExpression. + */ + public function testJoinConditionArrayExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->leftJoin( + 'xPDO\\Test\\Sample\\BloodType', + 'BloodType', + ['BloodType.type' => new xPDOExpression('Person.blood_type')] + ); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('JOIN', $sql, + 'A leftJoin() call must produce a JOIN clause. SQL was: ' . $sql); + + $this->assertStringContainsString('Person.blood_type', $sql, + 'An xPDOExpression value in an associative join condition must be inlined verbatim. SQL was: ' . $sql); + + // Must not be quoted as a string literal + $this->assertStringNotContainsString("'Person.blood_type'", $sql, + 'An xPDOExpression in a join condition must NOT be quoted. SQL was: ' . $sql); + } +} diff --git a/test/xPDO/Test/Om/xPDOQuerySelectTest.php b/test/xPDO/Test/Om/xPDOQuerySelectTest.php new file mode 100644 index 0000000..cf19b96 --- /dev/null +++ b/test/xPDO/Test/Om/xPDOQuerySelectTest.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Test\Om; + +use xPDO\Om\xPDOExpression; +use xPDO\TestCase; +use xPDO\xPDO; + +/** + * Tests for xPDOExpression passthrough in the SELECT column list. + * + * These tests construct SELECT queries and inspect the generated SQL to verify: + * - Plain column names are wrapped in the driver's identifier quoting + * - xPDOExpression values are passed through verbatim, bypassing the quoting regex + * + * @package xPDO\Test\Om + */ +class xPDOQuerySelectTest extends TestCase +{ + /** + * @before + */ + public function setUpFixtures() + { + parent::setUpFixtures(); + try { + $this->xpdo->getManager(); + $this->xpdo->manager->createObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + } + + /** + * @after + */ + public function tearDownFixtures() + { + try { + $this->xpdo->manager->removeObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + parent::tearDownFixtures(); + } + + /** + * A plain column name passed to select() must be wrapped in identifier + * quoting characters in the resulting SQL. + */ + public function testSelectPlainColumnIsQuoted() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->select(['first_name']); + $query->construct(); + + $sql = $query->toSQL(); + $escapeOpen = $this->xpdo->_escapeCharOpen; + + $this->assertStringContainsString($escapeOpen . 'first_name' . $this->xpdo->_escapeCharClose, $sql, + 'A plain column name must be quoted as an identifier in SELECT. SQL was: ' . $sql); + } + + /** + * An xPDOExpression passed to select() must appear verbatim in the SELECT + * column list without any identifier quoting applied. + */ + public function testSelectExpressionIsNotQuoted() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->select([new xPDOExpression('COUNT(*)')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('COUNT(*)', $sql, + 'An xPDOExpression must appear verbatim in SELECT. SQL was: ' . $sql); + + // Must not be wrapped in identifier quotes + $escapeOpen = $this->xpdo->_escapeCharOpen; + $escapeClose = $this->xpdo->_escapeCharClose; + $this->assertStringNotContainsString($escapeOpen . 'COUNT(*)', $sql, + 'An xPDOExpression must NOT be identifier-quoted in SELECT. SQL was: ' . $sql); + } + + /** + * An xPDOExpression containing an alias (e.g. COUNT(*) AS total) must pass + * through to the SELECT column list verbatim. + */ + public function testSelectExpressionWithAlias() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->select([new xPDOExpression('COUNT(*) AS total')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('COUNT(*) AS total', $sql, + 'An xPDOExpression with AS alias must appear verbatim in SELECT. SQL was: ' . $sql); + } + + /** + * A mix of plain column names and xPDOExpression values must both be + * represented correctly in the SELECT column list. + */ + public function testSelectMixedColumnsAndExpressions() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->select([ + 'first_name', + new xPDOExpression('COUNT(*) AS cnt'), + ]); + $query->construct(); + + $sql = $query->toSQL(); + $escapeOpen = $this->xpdo->_escapeCharOpen; + $escapeClose = $this->xpdo->_escapeCharClose; + + $this->assertStringContainsString($escapeOpen . 'first_name' . $escapeClose, $sql, + 'Plain column must be identifier-quoted in mixed SELECT. SQL was: ' . $sql); + + $this->assertStringContainsString('COUNT(*) AS cnt', $sql, + 'xPDOExpression must appear verbatim in mixed SELECT. SQL was: ' . $sql); + } +} diff --git a/test/xPDO/Test/Om/xPDOQuerySetTest.php b/test/xPDO/Test/Om/xPDOQuerySetTest.php new file mode 100644 index 0000000..a2bd794 --- /dev/null +++ b/test/xPDO/Test/Om/xPDOQuerySetTest.php @@ -0,0 +1,135 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Test\Om; + +use xPDO\Om\xPDOExpression; +use xPDO\TestCase; +use xPDO\xPDO; + +/** + * Tests for xPDOQuery::set() string quoting and xPDOExpression passthrough. + * + * These tests construct UPDATE queries and inspect the generated SQL to verify: + * - Plain PHP strings with SQL keywords are always quoted (PARAM_STR path) + * - xPDOExpression values are inlined verbatim without quoting + * + * @package xPDO\Test\Om + */ +class xPDOQuerySetTest extends TestCase +{ + /** + * @before + */ + public function setUpFixtures() + { + parent::setUpFixtures(); + try { + $this->xpdo->getManager(); + $this->xpdo->manager->createObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + } + + /** + * @after + */ + public function tearDownFixtures() + { + try { + $this->xpdo->manager->removeObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + parent::tearDownFixtures(); + } + + /** + * A plain string value containing the SQL keyword IN must be quoted in the + * SET clause, not left as bare SQL. Before the fix, isConditionalClause() + * detected " IN " inside the value string and left $type = null, causing + * the value to be inlined unquoted. + */ + public function testSetPlainStringWithSqlKeywordIsQuoted() + { + $suspiciousValue = 'something IN list'; + + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->command('UPDATE'); + $query->set(['first_name' => $suspiciousValue]); + $query->construct(); + + $sql = $query->toSQL(); + + // The value must appear quoted in the SQL, not as bare SQL keyword soup + $this->assertStringContainsString("'something IN list'", $sql, + 'A plain string containing SQL keyword IN must be quoted in the SET clause. SQL was: ' . $sql); + } + + /** + * A plain string value containing the SQL keyword LIKE must be quoted in + * the SET clause, not treated as a conditional expression. + */ + public function testSetPlainStringWithLikeKeywordIsQuoted() + { + $suspiciousValue = 'something LIKE pattern'; + + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->command('UPDATE'); + $query->set(['first_name' => $suspiciousValue]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString("'something LIKE pattern'", $sql, + 'A plain string containing SQL keyword LIKE must be quoted in the SET clause. SQL was: ' . $sql); + } + + /** + * An xPDOExpression wrapping a SQL expression must be inlined verbatim in + * the SET clause without quoting. + */ + public function testSetExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->command('UPDATE'); + $query->set(['security_level' => new xPDOExpression('security_level + 1')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('security_level + 1', $sql, + 'An xPDOExpression must be inlined verbatim in the SET clause. SQL was: ' . $sql); + + // It must NOT be quoted + $this->assertStringNotContainsString("'security_level + 1'", $sql, + 'An xPDOExpression must NOT be quoted in the SET clause. SQL was: ' . $sql); + } + + /** + * An xPDOExpression wrapping NOW() must appear verbatim in the SET clause. + */ + public function testSetExpressionWithNowFunction() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->command('UPDATE'); + $query->set(['dob' => new xPDOExpression('NOW()')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('NOW()', $sql, + 'An xPDOExpression wrapping NOW() must appear verbatim in SET clause. SQL was: ' . $sql); + + $this->assertStringNotContainsString("'NOW()'", $sql, + 'An xPDOExpression wrapping NOW() must NOT be quoted. SQL was: ' . $sql); + } +} diff --git a/test/xPDO/Test/Om/xPDOQuerySortByTest.php b/test/xPDO/Test/Om/xPDOQuerySortByTest.php index 9046fe9..d2f82b7 100644 --- a/test/xPDO/Test/Om/xPDOQuerySortByTest.php +++ b/test/xPDO/Test/Om/xPDOQuerySortByTest.php @@ -10,6 +10,7 @@ namespace xPDO\Test\Om; +use xPDO\Om\xPDOExpression; use xPDO\Om\xPDOObject; use xPDO\TestCase; use xPDO\xPDO; @@ -172,4 +173,51 @@ public function providerSortByWithLimit() { array('name','DESC',4,0,'item-39'), ); } + + /** + * An xPDOExpression passed to sortby() must be inlined verbatim in the + * ORDER BY clause — not identifier-quoted, not dropped, no TypeError. + * + * Before the fix, sortby() passes $column directly to isValidClause() which + * calls rtrim() on it; PHP 8.1 emits a TypeError when rtrim() receives an + * object. Even if that were suppressed the object would be stored as-is and + * the driver construct() would fail to cast it to a string. + */ + public function testSortByExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Item'); + $query->sortby(new xPDOExpression('FIELD(status, 1, 2)'), 'ASC'); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('FIELD(status, 1, 2) ASC', $sql, + 'An xPDOExpression in sortby() must be inlined verbatim in ORDER BY. SQL was: ' . $sql); + } + + /** + * An xPDOExpression passed to groupby() must be inlined verbatim in the + * GROUP BY clause — not identifier-quoted, not dropped, no TypeError. + * + * Before the fix, groupby() stores $column directly in the query array. + * When the driver construct() runs, it does `$sql .= $groupby['column']` + * which performs implicit string concatenation on the xPDOExpression object. + * xPDOExpression does not implement __toString(), so PHP 8.1 emits a fatal + * TypeError: "Object of class xPDO\Om\xPDOExpression could not be converted + * to string". + * + * After the fix, groupby() must call getExpression() before storing, so the + * driver construct() receives the raw SQL string and inlines it verbatim. + */ + public function testGroupByExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Item'); + $query->groupby(new xPDOExpression('DATE(created_at)')); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('DATE(created_at)', $sql, + 'An xPDOExpression in groupby() must be inlined verbatim in GROUP BY. SQL was: ' . $sql); + } } diff --git a/test/xPDO/Test/Om/xPDOQueryWhereTest.php b/test/xPDO/Test/Om/xPDOQueryWhereTest.php new file mode 100644 index 0000000..49bdc0a --- /dev/null +++ b/test/xPDO/Test/Om/xPDOQueryWhereTest.php @@ -0,0 +1,308 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace xPDO\Test\Om; + +use xPDO\Om\xPDOExpression; +use xPDO\TestCase; +use xPDO\xPDO; + +/** + * Tests for xPDOExpression support in xPDOQuery WHERE conditions (parseConditions). + * + * These tests construct SELECT and UPDATE queries with xPDOExpression values in + * where() calls and inspect the generated SQL to verify: + * - xPDOExpression values are inlined verbatim (not quoted, not dropped) + * - A WHERE clause is always present when where() is called with an expression + * - Mixed scalar and expression conditions both appear correctly + * + * @package xPDO\Test\Om + */ +class xPDOQueryWhereTest extends TestCase +{ + /** + * @before + */ + public function setUpFixtures() + { + parent::setUpFixtures(); + try { + $this->xpdo->getManager(); + $this->xpdo->manager->createObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + } + + /** + * @after + */ + public function tearDownFixtures() + { + try { + $this->xpdo->manager->removeObjectContainer('xPDO\\Test\\Sample\\Person'); + } catch (\Exception $e) { + $this->xpdo->log(xPDO::LOG_LEVEL_ERROR, $e->getMessage(), '', __METHOD__, __FILE__, __LINE__); + } + parent::tearDownFixtures(); + } + + /** + * An xPDOExpression value in where() must be inlined verbatim in the WHERE + * clause — not quoted as a string literal. + * + * Before the fix, the condition was silently dropped because xPDOExpression + * fails is_scalar(), is_array(), and $val === null checks. + */ + public function testWhereExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where(['first_name' => new xPDOExpression("'active'")]); + $query->construct(); + + $sql = $query->toSQL(); + + // Column name is escaped by the driver; the expression value must be inlined verbatim + $this->assertStringContainsString("= 'active'", $sql, + 'An xPDOExpression in where() must be inlined verbatim. SQL was: ' . $sql); + + // Must NOT be doubly-quoted as a string literal wrapping the expression + $this->assertStringNotContainsString("= \"'active'\"", $sql, + 'An xPDOExpression in where() must NOT be re-quoted. SQL was: ' . $sql); + } + + /** + * After calling where() with an xPDOExpression value, the generated SQL must + * contain a WHERE clause. Before the fix, the condition was silently dropped, + * resulting in a full-table query with no WHERE clause at all. + */ + public function testWhereExpressionIsNotSilentlyDropped() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where(['security_level' => new xPDOExpression('0')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('WHERE', $sql, + 'A where() call with an xPDOExpression must produce a WHERE clause. SQL was: ' . $sql); + } + + /** + * An xPDOExpression wrapping a SQL function call (NOW()) must appear verbatim + * in the WHERE clause without quoting. + */ + public function testWhereExpressionWithRawSqlFragment() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where(['dob' => new xPDOExpression('NOW()')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('NOW()', $sql, + 'An xPDOExpression wrapping NOW() must appear verbatim in the WHERE clause. SQL was: ' . $sql); + + $this->assertStringNotContainsString("'NOW()'", $sql, + 'An xPDOExpression wrapping NOW() must NOT be quoted in the WHERE clause. SQL was: ' . $sql); + } + + /** + * A where() call mixing a plain scalar value and an xPDOExpression value must + * produce both conditions correctly in the WHERE clause. + */ + public function testWhereConditionWithMixedScalarAndExpression() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where([ + 'first_name' => 'John', + 'security_level' => new xPDOExpression('security_level + 1'), + ]); + $query->construct(); + + $sql = $query->toSQL(); + + // The scalar value must appear as a bound parameter placeholder + $this->assertStringContainsString('first_name', $sql, + 'The scalar condition must appear in the WHERE clause. SQL was: ' . $sql); + + // The expression must be inlined verbatim + $this->assertStringContainsString('security_level + 1', $sql, + 'The xPDOExpression condition must be inlined verbatim in the WHERE clause. SQL was: ' . $sql); + + // The expression must NOT be quoted + $this->assertStringNotContainsString("'security_level + 1'", $sql, + 'The xPDOExpression condition must NOT be quoted. SQL was: ' . $sql); + } + + /** + * An operator in the key (e.g. 'field:!=') must be respected when the value is + * an xPDOExpression. Before the fix, the expression branch was reached only + * after the scalar/array/null gate, meaning operators with expression values + * were never extracted — the condition was silently dropped. + * + * This also exercises the operator-extraction path inside the xPDOExpression + * branch, which was not covered by prior tests. + */ + public function testWhereExpressionOperatorSyntax() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where(['first_name:!=' => new xPDOExpression('other_field')]); + $query->construct(); + + $sql = $query->toSQL(); + + // The != operator must be present in the generated SQL + $this->assertStringContainsString('!=', $sql, + 'The != operator from key syntax must appear in the WHERE clause. SQL was: ' . $sql); + + // The expression value must be inlined verbatim + $this->assertStringContainsString('other_field', $sql, + 'The xPDOExpression value must be inlined verbatim. SQL was: ' . $sql); + + // Must not be quoted + $this->assertStringNotContainsString("'other_field'", $sql, + 'The xPDOExpression value must NOT be quoted. SQL was: ' . $sql); + } + + /** + * An xPDOExpression passed as a positional (integer-keyed) where() entry must + * be inlined verbatim in the WHERE clause. + * + * Before this fix, the is_int($key) branch only handled arrays and raw strings + * that pass isConditionalClause(). An xPDOExpression object is neither, so it + * fell through to the error log and was silently dropped. + * + * `$query->where([new xPDOExpression("1 = 1")])` must produce a WHERE clause + * containing `1 = 1`. + */ + public function testWherePositionalExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where([new xPDOExpression('1 = 1')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('WHERE', $sql, + 'A positional xPDOExpression must produce a WHERE clause. SQL was: ' . $sql); + + $this->assertStringContainsString('1 = 1', $sql, + 'A positional xPDOExpression must be inlined verbatim. SQL was: ' . $sql); + } + + /** + * A positional xPDOExpression combined with a second condition via AND must + * produce both conditions correctly in the WHERE clause. + * + * This exercises the conjunction forwarding path: the expression is injected + * with the outer $conjunction so AND/OR logic is preserved. + */ + public function testWherePositionalExpressionWithConjunction() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where([new xPDOExpression('1 = 1')]); + $query->andCondition(['first_name' => 'John']); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('1 = 1', $sql, + 'The positional xPDOExpression must appear in the WHERE clause. SQL was: ' . $sql); + + $this->assertStringContainsString('first_name', $sql, + 'The AND scalar condition must also appear in the WHERE clause. SQL was: ' . $sql); + } + + /** + * A bare xPDOExpression (not wrapped in an array) passed directly to where() + * must be inlined verbatim in the generated WHERE clause. + * + * Before this fix, a bare xPDOExpression object passed the is_array() check + * (false), failed isConditionalClause() (which only accepts strings), failed + * the PK type checks, and fell into the final else branch which set + * $result->sql to the object itself. In PHP 8 that causes a TypeError when + * buildConditionalClause() attempts string concatenation because xPDOExpression + * has no __toString(). The condition was effectively broken / unusable. + * + * `$query->where(new xPDOExpression("1 = 1"))` must produce a WHERE clause + * containing the literal `1 = 1`. + */ + public function testWhereDirectExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->where(new xPDOExpression('1 = 1')); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('WHERE', $sql, + 'A bare xPDOExpression passed directly to where() must produce a WHERE clause. SQL was: ' . $sql); + + $this->assertStringContainsString('1 = 1', $sql, + 'A bare xPDOExpression passed directly to where() must be inlined verbatim. SQL was: ' . $sql); + } + + /** + * An xPDOExpression value passed to having() must be inlined verbatim in the + * HAVING clause and must not be silently dropped. + * + * The having() method delegates to where() with the same parseConditions() + * path; this test confirms the fix covers HAVING as well as WHERE. + */ + public function testHavingExpressionIsInlinedVerbatim() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + $query->select(['COUNT(*) AS cnt', 'security_level']); + $query->groupby('security_level'); + $query->having(['security_level:>=' => new xPDOExpression('5')]); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('HAVING', $sql, + 'A having() call with an xPDOExpression must produce a HAVING clause. SQL was: ' . $sql); + + $this->assertStringContainsString('5', $sql, + 'The xPDOExpression value must be inlined verbatim in the HAVING clause. SQL was: ' . $sql); + + // Must not be quoted as a string literal + $this->assertStringNotContainsString("'5'", $sql, + 'The xPDOExpression value in HAVING must NOT be quoted. SQL was: ' . $sql); + } + + /** + * An xPDOExpression passed to orCondition() must be inlined verbatim in the + * WHERE clause using an OR conjunction. + * + * orCondition() delegates to where() with SQL_OR conjunction. This test + * confirms the OR conjunction is preserved and the expression value is + * inlined verbatim — not quoted, not dropped. + */ + public function testOrConditionExpressionPreservesOrConjunction() + { + $query = $this->xpdo->newQuery('xPDO\\Test\\Sample\\Person'); + // Establish a baseline WHERE condition first so the OR has something to attach to + $query->where(['security_level' => 1]); + $query->orCondition(new xPDOExpression("status = 'archived'")); + $query->construct(); + + $sql = $query->toSQL(); + + $this->assertStringContainsString('WHERE', $sql, + 'A where() + orCondition() call must produce a WHERE clause. SQL was: ' . $sql); + + $this->assertStringContainsString('OR', $sql, + 'orCondition() must produce an OR conjunction in the WHERE clause. SQL was: ' . $sql); + + $this->assertStringContainsString("status = 'archived'", $sql, + 'The xPDOExpression in orCondition() must be inlined verbatim. SQL was: ' . $sql); + } +}