AbstractTable.php
PHP
Path: src/Table/AbstractTable.php
<?php
namespace mini\Table;
use Closure;
use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Types\IndexType;
use mini\Table\Types\Operator;
use mini\Table\Utility\EmptyTable;
use mini\Table\Utility\TablePropertiesTrait;
use mini\Table\Wrappers\AliasTable;
use mini\Table\Wrappers\DistinctTable;
use mini\Table\Wrappers\ExceptTable;
use mini\Table\Wrappers\UnionTable;
use Traversable;
/**
* Base class for all table implementations
*
* Provides:
* - Centralized order() parsing into OrderDef[]
* - Centralized limit/offset handling
* - String collation function for sorting (locale-aware by default)
* - Default union() and except() returning wrapper types
*/
abstract class AbstractTable implements TableInterface
{
use TablePropertiesTrait;
/** @var array<string, ColumnDef> All columns in the table */
private readonly array $columnDefs;
/** @var string[] Column names available for output (empty = all) */
private array $visibleColumns = [];
/** @var Closure(string, string): int|null Custom compare function, null = use default */
protected ?Closure $compareFn = null;
protected ?int $limit = null;
protected int $offset = 0;
/** @var ColumnDef|false|null Cached primary key column (null=not checked, false=none found) */
private ColumnDef|false|null $primaryKeyColumn = null;
public function __construct(ColumnDef ...$columns)
{
$defs = [];
foreach ($columns as $col) {
$defs[$col->name] = $col;
}
$this->columnDefs = $defs;
}
/**
* Hook for subclasses to customize clone behavior
*/
public function __clone()
{
// Default: nothing to reset
}
/**
* Get column name(s) that the row key represents
*
* If non-empty, the row keys from iteration are the values of these columns.
* This enables optimizations when checking membership on exactly these columns.
*
* @return string[] Column names that form the row key (typically primary key)
*/
public function getRowKeyColumns(): array
{
return [];
}
/**
* Get the string comparison function for sorting
*
* Used by SortedTable for string column comparisons. By default uses
* the application's Collator service (via mini\collator()) when available,
* falling back to binary comparison (<=>) otherwise.
*
* @return \Closure(string, string): int
*/
protected function getCompareFn(): Closure
{
if ($this->compareFn !== null) {
return $this->compareFn;
}
return static fn(string $a, string $b): int => $a === $b ? 0 : (\mini\collator()->compare($a, $b) ?: 0);
}
/**
* Apply ordering to the table
*
* Implementations must choose how to handle ordering:
* - Store locally and apply in materialize() (e.g., SQL-backed tables push to DB)
* - Return a SortedTable wrapper for in-memory sorting
*
* @param string|null $spec Column name(s), optionally suffixed with " ASC" or " DESC"
* Multiple columns: "name ASC, created_at DESC"
* Empty string or null clears ordering
*/
abstract public function order(?string $spec): TableInterface;
public function limit(?int $n): TableInterface
{
if ($this->limit === $n) {
return $this;
}
$c = clone $this;
$c->limit = $n;
return $c;
}
public function offset(int $n): TableInterface
{
if ($this->offset === $n) {
return $this;
}
$c = clone $this;
$c->offset = $n;
return $c;
}
public function getLimit(): ?int
{
return $this->limit;
}
public function getOffset(): int
{
return $this->offset;
}
public function withAlias(?string $tableAlias = null, array $columnAliases = []): TableInterface
{
return new AliasTable($this, $tableAlias, $columnAliases);
}
/**
* Get the current table alias (null if not set)
*/
public function getTableAlias(): ?string
{
return $this->getProperty('alias');
}
public function union(TableInterface $other): TableInterface
{
return new UnionTable($this, $other);
}
public function except(SetInterface $other): TableInterface
{
return new ExceptTable($this, $other);
}
public function distinct(): TableInterface
{
return new DistinctTable($this);
}
/**
* Filter rows matching any of the given predicates (OR semantics)
*
* ```php
* // WHERE status = 'active' OR status = 'pending'
* $users->or(
* Predicate::eq('status', 'active'),
* Predicate::eq('status', 'pending')
* );
*
* // WHERE (age < 18) OR (age >= 65 AND status = 'retired')
* $users->or(
* Predicate::lt('age', 18),
* Predicate::gte('age', 65)->andEq('status', 'retired')
* );
* ```
*/
public function or(Predicate $a, Predicate $b, Predicate ...$more): TableInterface
{
$predicates = [$a, $b, ...$more];
// Filter out empty predicates (they match nothing)
$predicates = array_values(array_filter(
$predicates,
fn($p) => !$p->isEmpty()
));
// No predicates → nothing matches
if (empty($predicates)) {
return EmptyTable::from($this);
}
// Single predicate → apply directly without union overhead
if (count($predicates) === 1) {
return $this->applyPredicate($predicates[0]);
}
// Multiple predicates → union branches
$result = $this->applyPredicate($predicates[0]);
for ($i = 1; $i < count($predicates); $i++) {
$branch = $this->applyPredicate($predicates[$i]);
$result = $result->union($branch);
}
return $result;
}
/**
* Apply a Predicate to this table
*
* Converts Predicate conditions to table filter calls.
*/
private function applyPredicate(Predicate $predicate): TableInterface
{
$result = $this;
foreach ($predicate->getConditions() as $cond) {
$col = $cond['column'];
$op = $cond['operator'];
$val = $cond['value'];
$result = match ($op) {
Operator::Eq => $result->eq($col, $val),
Operator::Lt => $result->lt($col, $val),
Operator::Lte => $result->lte($col, $val),
Operator::Gt => $result->gt($col, $val),
Operator::Gte => $result->gte($col, $val),
Operator::In => $result->in($col, $val),
Operator::Like => $result->like($col, $val),
};
}
return $result;
}
public function exists(): bool
{
return $this->limit(1)->count() > 0;
}
/**
* Check if value(s) exist in the table's projected columns
*
* Uses indexed columns when available to avoid full table scans.
*
* @param object $member Object with properties matching getColumns()
*/
public function has(object $member): bool
{
$cols = $this->getColumns();
$memberProps = array_keys((array) $member);
// Member shape must match table columns exactly
if (count($cols) !== count($memberProps)) {
return false;
}
foreach ($memberProps as $prop) {
if (!isset($cols[$prop])) {
return false;
}
}
// Normalize member to array for faster comparison
$memberValues = [];
foreach ($cols as $col => $def) {
$memberValues[$col] = $member->$col ?? null;
}
// Try to find a unique index we can query directly
$uniqueCol = $this->findUniqueIndexColumn($cols);
if ($uniqueCol !== null) {
$query = $this->eq($uniqueCol, $memberValues[$uniqueCol]);
// Apply remaining column filters
foreach ($memberValues as $col => $val) {
if ($col !== $uniqueCol) {
$query = $query->eq($col, $val);
}
}
return $query->exists();
}
// No unique index - iterate and search
foreach ($this as $row) {
$matches = true;
foreach ($memberValues as $col => $val) {
if (($row->$col ?? null) !== $val) {
$matches = false;
break;
}
}
if ($matches) {
return true;
}
}
return false;
}
/**
* Get the primary key column definition (cached)
*/
protected function getPrimaryKeyColumn(): ?ColumnDef
{
if ($this->primaryKeyColumn === null) {
$this->primaryKeyColumn = false;
foreach ($this->columnDefs as $col) {
if ($col->index === IndexType::Primary) {
$this->primaryKeyColumn = $col;
break;
}
}
}
return $this->primaryKeyColumn ?: null;
}
/**
* Find a column with a unique index (Primary or Unique)
*
* @return string|null Column name if found, null otherwise
*/
private function findUniqueIndexColumn(array $cols): ?string
{
foreach ($cols as $col => $def) {
if ($def->index === IndexType::Primary || $def->index === IndexType::Unique) {
return $col;
}
}
return null;
}
/**
* Materialize function is needed to facilitate AbstractTableWrapper and other logic that
* might require access to columns that aren't selected for output via TableInterface::columns().
*
* @return Traversable<int|string, object>
*/
abstract protected function materialize(string ...$additionalColumns): Traversable;
/**
* Iterate over rows with visible columns only
*
* @return Traversable<int|string, object>
*/
final public function getIterator(): Traversable
{
// Fast path: no column projection needed
if (empty($this->visibleColumns)) {
yield from $this->materialize();
return;
}
// Slow path: project to visible columns only
$visibleCols = $this->getColumns();
foreach ($this->materialize() as $id => $row) {
yield $id => (object) array_intersect_key((array) $row, $visibleCols);
}
}
/**
* Get columns available for output
*
* @return array<string, ColumnDef>
*/
public function getColumns(): array
{
if (empty($this->visibleColumns)) {
return $this->columnDefs;
}
// Preserve the order from visibleColumns, not columnDefs
$result = [];
foreach ($this->visibleColumns as $col) {
if (isset($this->columnDefs[$col])) {
$result[$col] = $this->columnDefs[$col];
}
}
return $result;
}
/**
* Get all column definitions regardless of projection
*
* Used by wrappers that need to filter/sort on columns not in the output.
*
* @return array<string, ColumnDef>
*/
public function getAllColumns(): array
{
return $this->columnDefs;
}
/**
* Narrow to specific columns
*
* @throws \InvalidArgumentException if column doesn't exist
*/
public function columns(string ...$columns): TableInterface
{
$available = $this->getColumns();
foreach ($columns as $col) {
if (!isset($available[$col])) {
throw new \InvalidArgumentException(
"Column '$col' does not exist in table"
);
}
}
$c = clone $this;
$c->visibleColumns = $columns;
return $c;
}
/**
* Load a single row by its row ID
*
* Default implementation iterates to find the row.
* Subclasses should override for O(1) lookups when possible.
*/
public function load(string|int $rowId): ?object
{
foreach ($this as $id => $row) {
if ($id === $rowId) {
return $row;
}
}
return null;
}
}