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