Predicate.php
PHP
Path: src/Table/Predicate.php
<?php
namespace mini\Table;
use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Types\Operator;
/**
* Immutable predicate for filtering conditions
*
* A standalone class representing filter conditions that can be used with or().
* Supports both concrete values and bind parameters.
*
* ```php
* use const mini\p;
*
* // Use the p root instance helper for concise syntax
* $users->or(
* p->eq('status', 'active'),
* p->gte('age', 65)
* );
*
* // Chain multiple conditions (AND)
* p->eq('status', 'active')->lt('age', 30)
*
* // With bind parameters
* p->eqBind('id', ':id')->bind([':id' => 123])
* ```
*/
final class Predicate
{
/**
* @var list<array{column: string, operator: Operator, value: mixed, bound: bool}>
*/
private array $conditions = [];
/** @var bool If true, test() always returns false */
private bool $matchesNothing = false;
/**
* Create an empty predicate (matches everything)
*/
public function __construct() {}
/**
* Create a predicate that matches nothing
*
* Used for SQL `col = NULL` which per SQL standard always evaluates to UNKNOWN,
* meaning no rows should match.
*/
public static function never(): self
{
$p = new self();
$p->matchesNothing = true;
return $p;
}
/**
* Create a predicate builder for a table
*
* @param TableInterface $table The table context (for future type validation)
*/
public static function from(TableInterface $table): self
{
return new self();
}
// -------------------------------------------------------------------------
// Condition methods - return new Predicate with condition added
// -------------------------------------------------------------------------
public function eq(string $column, int|float|string|null $value): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Eq, 'value' => $value, 'bound' => true];
return $new;
}
public function eqBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Eq, 'value' => $param, 'bound' => false];
return $new;
}
public function lt(string $column, int|float|string $value): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Lt, 'value' => $value, 'bound' => true];
return $new;
}
public function ltBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Lt, 'value' => $param, 'bound' => false];
return $new;
}
public function lte(string $column, int|float|string $value): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Lte, 'value' => $value, 'bound' => true];
return $new;
}
public function lteBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Lte, 'value' => $param, 'bound' => false];
return $new;
}
public function gt(string $column, int|float|string $value): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Gt, 'value' => $value, 'bound' => true];
return $new;
}
public function gtBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Gt, 'value' => $param, 'bound' => false];
return $new;
}
public function gte(string $column, int|float|string $value): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Gte, 'value' => $value, 'bound' => true];
return $new;
}
public function gteBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Gte, 'value' => $param, 'bound' => false];
return $new;
}
public function in(string $column, SetInterface $values): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::In, 'value' => $values, 'bound' => true];
return $new;
}
public function like(string $column, string $pattern): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Like, 'value' => $pattern, 'bound' => true];
return $new;
}
public function likeBind(string $column, string $param): self
{
$new = clone $this;
$new->conditions[] = ['column' => $column, 'operator' => Operator::Like, 'value' => $param, 'bound' => false];
return $new;
}
// -------------------------------------------------------------------------
// Bind parameter support
// -------------------------------------------------------------------------
/**
* Check if all parameters are bound
*/
public function isBound(): bool
{
foreach ($this->conditions as $cond) {
if (!$cond['bound']) {
return false;
}
}
return true;
}
/**
* Get list of unbound parameter names
*
* @return list<string>
*/
public function getUnboundParams(): array
{
$params = [];
foreach ($this->conditions as $cond) {
if (!$cond['bound']) {
$params[] = $cond['value'];
}
}
return $params;
}
/**
* Resolve bind parameters with concrete values
*
* @param array<string, mixed> $values Parameter name => value
*/
public function bind(array $values): self
{
$new = clone $this;
foreach ($new->conditions as $i => $cond) {
if (!$cond['bound']) {
$param = $cond['value'];
if (!array_key_exists($param, $values)) {
continue; // Leave unbound for partial binding
}
$new->conditions[$i]['value'] = $values[$param];
$new->conditions[$i]['bound'] = true;
}
}
return $new;
}
// -------------------------------------------------------------------------
// Condition access
// -------------------------------------------------------------------------
/**
* Get all conditions
*
* @return list<array{column: string, operator: Operator, value: mixed, bound: bool}>
*/
public function getConditions(): array
{
return $this->conditions;
}
/**
* Check if predicate has no conditions (matches everything)
*/
public function isEmpty(): bool
{
return empty($this->conditions);
}
/**
* Create a new predicate with column names mapped through a callback
*
* Used by AliasTable to translate aliased column names to original names.
*
* @param callable(string): string $mapper Function that maps column names
*/
public function mapColumns(callable $mapper): self
{
$new = clone $this;
foreach ($new->conditions as $i => $cond) {
$new->conditions[$i]['column'] = $mapper($cond['column']);
}
return $new;
}
// -------------------------------------------------------------------------
// Row testing
// -------------------------------------------------------------------------
/**
* Test if a row matches all conditions
*
* Empty predicate (no conditions) matches everything.
* Predicate::never() always returns false.
*
* @throws \LogicException If there are unbound parameters
*/
public function test(object $row): bool
{
if ($this->matchesNothing) {
return false;
}
foreach ($this->conditions as $cond) {
if (!$cond['bound']) {
throw new \LogicException("Predicate has unbound parameter '{$cond['value']}'");
}
if (!$this->testCondition($row, $cond['column'], $cond['operator'], $cond['value'])) {
return false;
}
}
return true;
}
private function testCondition(object $row, string $column, Operator $operator, mixed $value): bool
{
if (!property_exists($row, $column)) {
return true; // Open world assumption
}
$rowValue = $row->$column;
return match ($operator) {
Operator::Eq => $this->compareEq($rowValue, $value),
Operator::Lt => $rowValue !== null && $rowValue < $value,
Operator::Lte => $rowValue !== null && $rowValue <= $value,
Operator::Gt => $rowValue !== null && $rowValue > $value,
Operator::Gte => $rowValue !== null && $rowValue >= $value,
Operator::In => $this->testIn($rowValue, $column, $value),
Operator::Like => $this->testLike($rowValue, $value),
};
}
private function compareEq(mixed $rowValue, mixed $value): bool
{
if ($value === null) {
return $rowValue === null;
}
// Use == for numeric comparison (5 == 5.0)
if (is_numeric($rowValue) && is_numeric($value)) {
return $rowValue == $value;
}
return $rowValue === $value;
}
private function testIn(mixed $rowValue, string $column, SetInterface $set): bool
{
$member = (object)[$column => $rowValue];
return $set->has($member);
}
private function testLike(mixed $rowValue, string $pattern): bool
{
if ($rowValue === null) {
return false;
}
$regex = '/^' . str_replace(
['%', '_'],
['.*', '.'],
preg_quote($pattern, '/')
) . '$/i';
return preg_match($regex, (string)$rowValue) === 1;
}
}