DebugTable.php
PHP
Path: src/Table/Wrappers/DebugTable.php
<?php
namespace mini\Table\Wrappers;
use mini\Table\AbstractTable;
use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Types\Operator;
use Traversable;
/**
* Debug wrapper that logs operations reaching the implementation
*
* Wraps any table and logs what predicates, ordering, and pagination
* are in effect when the table is actually materialized or queried.
*
* ```php
* $debug = DebugTable::wrap($table);
* $result = $debug->eq('status', 'active')->gt('age', 18)->limit(10);
* foreach ($result as $row) { ... }
* // Logs: [DebugTable] MATERIALIZE: WHERE status='active' AND age>18 LIMIT 10
* ```
*/
class DebugTable extends AbstractTableWrapper
{
/** @var array<array{column: string, op: Operator, value: mixed}> */
private array $filters = [];
/** @var string|null */
private ?string $orderSpec = null;
/** @var callable|null */
private $logger;
/** @var string */
private string $tableName;
private function __construct(
AbstractTable $source,
?callable $logger = null,
string $tableName = 'table',
) {
parent::__construct($source);
$this->logger = $logger;
$this->tableName = $tableName;
}
/**
* Wrap a table for debugging
*
* @param TableInterface $table Table to wrap
* @param callable|null $logger Custom logger (receives string), defaults to error_log
* @param string $tableName Name to show in logs
*/
public static function wrap(
TableInterface $table,
?callable $logger = null,
string $tableName = 'table',
): self {
if (!$table instanceof AbstractTable) {
throw new \InvalidArgumentException('DebugTable requires AbstractTable source');
}
return new self($table, $logger, $tableName);
}
private function log(string $message): void
{
$line = "[DebugTable:{$this->tableName}] $message";
if ($this->logger) {
($this->logger)($line);
} else {
error_log($line);
}
}
/**
* Build SQL-like description of current state
*/
private function describeState(string $operation): string
{
$parts = [$operation];
// WHERE clause
if ($this->filters) {
$conditions = [];
foreach ($this->filters as $f) {
$conditions[] = $this->describeFilter($f);
}
$parts[] = 'WHERE ' . implode(' AND ', $conditions);
}
// ORDER BY
if ($this->orderSpec) {
$parts[] = 'ORDER BY ' . $this->orderSpec;
}
// LIMIT/OFFSET
$limit = $this->source->getLimit();
$offset = $this->source->getOffset();
if ($limit !== null) {
$parts[] = 'LIMIT ' . $limit;
}
if ($offset > 0) {
$parts[] = 'OFFSET ' . $offset;
}
// Show wrapper chain to identify implementation path
$parts[] = '-- via ' . $this->describeWrapperChain();
return implode(' ', $parts);
}
/**
* Describe the wrapper chain (helps identify optimization barriers)
*/
private function describeWrapperChain(): string
{
$chain = [];
$current = $this->source;
while ($current !== null) {
$class = (new \ReflectionClass($current))->getShortName();
$chain[] = $class;
if ($current instanceof AbstractTableWrapper) {
$current = $current->getSource();
} else {
break;
}
}
return implode(' → ', $chain);
}
private function describeFilter(array $f): string
{
$col = $f['column'];
$op = match ($f['op']) {
Operator::Eq => '=',
Operator::Lt => '<',
Operator::Lte => '<=',
Operator::Gt => '>',
Operator::Gte => '>=',
Operator::In => 'IN',
Operator::Like => 'LIKE',
};
$val = $this->describeValue($f['value']);
if ($f['op'] === Operator::In) {
return "$col IN ($val)";
}
return "$col$op$val";
}
private function describeValue(mixed $value): string
{
if ($value === null) {
return 'NULL';
}
if (is_string($value)) {
return "'" . addslashes($value) . "'";
}
if (is_bool($value)) {
return $value ? 'TRUE' : 'FALSE';
}
if (is_numeric($value)) {
return (string) $value;
}
if ($value instanceof SetInterface) {
return '...set...';
}
if ($value instanceof TableInterface) {
return '...subquery...';
}
return '?';
}
// =========================================================================
// Materialization - log when data is actually accessed
// =========================================================================
protected function materialize(string ...$additionalColumns): Traversable
{
$this->log($this->describeState('MATERIALIZE'));
return parent::materialize(...$additionalColumns);
}
public function count(): int
{
$this->log($this->describeState('COUNT'));
return parent::count();
}
// =========================================================================
// Membership - log has() calls
// =========================================================================
public function has(object $member): bool
{
$props = [];
foreach (get_object_vars($member) as $k => $v) {
$props[] = "$k=" . $this->describeValue($v);
}
$memberDesc = '{' . implode(', ', $props) . '}';
$result = parent::has($member);
$resultStr = $result ? 'true' : 'false';
$this->log("HAS $memberDesc → $resultStr");
return $result;
}
public function load(string|int $rowId): ?object
{
$result = parent::load($rowId);
$resultStr = $result !== null ? 'found' : 'null';
$this->log("LOAD($rowId) → $resultStr");
return $result;
}
// =========================================================================
// Filter methods - track predicates and delegate
// =========================================================================
private function cloneWithFilter(string $column, Operator $op, mixed $value): self
{
$c = clone $this;
$c->filters[] = ['column' => $column, 'op' => $op, 'value' => $value];
return $c;
}
public function eq(string $column, int|float|string|null $value): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Eq, $value);
$c->source = $this->source->eq($column, $value);
return $c;
}
public function lt(string $column, int|float|string $value): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Lt, $value);
$c->source = $this->source->lt($column, $value);
return $c;
}
public function lte(string $column, int|float|string $value): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Lte, $value);
$c->source = $this->source->lte($column, $value);
return $c;
}
public function gt(string $column, int|float|string $value): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Gt, $value);
$c->source = $this->source->gt($column, $value);
return $c;
}
public function gte(string $column, int|float|string $value): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Gte, $value);
$c->source = $this->source->gte($column, $value);
return $c;
}
public function in(string $column, SetInterface $values): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::In, $values);
$c->source = $this->source->in($column, $values);
return $c;
}
public function like(string $column, string $pattern): TableInterface
{
$c = $this->cloneWithFilter($column, Operator::Like, $pattern);
$c->source = $this->source->like($column, $pattern);
return $c;
}
// =========================================================================
// Ordering and pagination
// =========================================================================
public function order(?string $spec): TableInterface
{
$c = clone $this;
$c->orderSpec = $spec;
$c->source = $this->source->order($spec);
return $c;
}
public function limit(?int $n): TableInterface
{
$c = clone $this;
$c->source = $this->source->limit($n);
return $c;
}
public function offset(int $n): TableInterface
{
$c = clone $this;
$c->source = $this->source->offset($n);
return $c;
}
// =========================================================================
// Column projection
// =========================================================================
public function columns(string ...$columns): TableInterface
{
$c = clone $this;
$c->source = $this->source->columns(...$columns);
return $c;
}
}