BindableTable.php

PHP

Path: src/Database/BindableTable.php

<?php

namespace mini\Database;

use Countable;
use IteratorAggregate;
use mini\Table\Contracts\MutableTableInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Predicate;
use mini\Table\Utility\EmptyTable;
use Traversable;

/**
 * Internal table wrapper with parameter binding support
 *
 * Used by VirtualDatabase for JOIN execution and correlated subqueries.
 * Provides deferred filter binding for dynamic row-by-row evaluation.
 *
 * @internal This is an implementation detail - use PartialQuery::fromTable() for user code
 */
final class BindableTable implements IteratorAggregate, Countable
{
    private TableInterface $source;

    /** @var MutableTableInterface|null Root table for mutations */
    private ?MutableTableInterface $mutableRoot = null;

    /** @var array<string|int, list<array{string, string}>> param => [[column, operator], ...] */
    private array $binds = [];

    /** @var string[]|null Deferred column projection (applied at iteration) */
    private ?array $deferredColumns = null;

    /** @var Predicate[]|null Deferred OR predicates (applied at iteration) */
    private ?array $deferredPredicates = null;

    private function __construct(TableInterface $source)
    {
        $this->source = $source;
        if ($source instanceof MutableTableInterface) {
            $this->mutableRoot = $source;
        }
    }

    /**
     * Create a BindableTable wrapper from any TableInterface
     */
    public static function from(self|TableInterface $source): self
    {
        if ($source instanceof self) {
            return $source;
        }
        return new self($source);
    }

    // =========================================================================
    // Mutation methods
    // =========================================================================

    /**
     * Check if this table supports mutations (insert/update/delete)
     */
    public function isMutable(): bool
    {
        return $this->mutableRoot !== null;
    }

    /**
     * Insert a new row
     *
     * @throws \RuntimeException if table is not mutable
     */
    public function insert(array $row): int|string
    {
        if ($this->mutableRoot === null) {
            throw new \RuntimeException('Table does not support mutations');
        }
        return $this->mutableRoot->insert($row);
    }

    /**
     * Update rows matching current filters
     *
     * @throws \RuntimeException if table is not mutable or has unbound parameters
     */
    public function update(array $changes): int
    {
        if ($this->mutableRoot === null) {
            throw new \RuntimeException('Table does not support mutations');
        }
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot update: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        return $this->mutableRoot->update($this->getEffectiveSource(), $changes);
    }

    /**
     * Delete rows matching current filters
     *
     * @throws \RuntimeException if table is not mutable or has unbound parameters
     */
    public function delete(): int
    {
        if ($this->mutableRoot === null) {
            throw new \RuntimeException('Table does not support mutations');
        }
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot delete: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        return $this->mutableRoot->delete($this->getEffectiveSource());
    }

    // =========================================================================
    // Bindable predicates
    // =========================================================================

    /**
     * Add bindable equality predicate
     */
    public function eqBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'eq'];
        return $c;
    }

    /**
     * Add bindable less-than predicate
     */
    public function ltBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'lt'];
        return $c;
    }

    /**
     * Add bindable less-than-or-equal predicate
     */
    public function lteBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'lte'];
        return $c;
    }

    /**
     * Add bindable greater-than predicate
     */
    public function gtBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'gt'];
        return $c;
    }

    /**
     * Add bindable greater-than-or-equal predicate
     */
    public function gteBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'gte'];
        return $c;
    }

    /**
     * Add bindable LIKE predicate
     */
    public function likeBind(string $column, string|int $param): self
    {
        $c = clone $this;
        $c->binds[$param][] = [$column, 'like'];
        return $c;
    }

    /**
     * Bind parameter values
     *
     * @param array<string|int, mixed> $values Parameter values keyed by name or position
     * @throws \InvalidArgumentException If an unknown parameter is provided
     */
    public function bind(array $values): self
    {
        $c = clone $this;

        // Bind to deferred predicates
        if ($c->deferredPredicates !== null) {
            $c->deferredPredicates = array_map(
                fn(Predicate $p) => $p->bind($values),
                $c->deferredPredicates
            );
        }

        // Bind to direct filters
        foreach ($values as $param => $value) {
            if (!isset($c->binds[$param])) {
                throw new \InvalidArgumentException("Unknown parameter: $param");
            }
            foreach ($c->binds[$param] as $bind) {
                // Skip 'or' markers - those are handled via deferredPredicates
                if ($bind[0] === 'or') {
                    continue;
                }
                [$column, $op] = $bind;
                // SQL: comparisons with NULL return UNKNOWN (no rows match)
                // col = NULL, col < NULL, col > NULL etc. all return no rows
                if ($value === null) {
                    $c->source = EmptyTable::from($c->source);
                    continue;
                }
                $c->source = match ($op) {
                    'eq' => $c->source->eq($column, $value),
                    'lt' => $c->source->lt($column, $value),
                    'lte' => $c->source->lte($column, $value),
                    'gt' => $c->source->gt($column, $value),
                    'gte' => $c->source->gte($column, $value),
                    'like' => $c->source->like($column, $value),
                    default => throw new \LogicException("Unknown operator: $op"),
                };
            }
            unset($c->binds[$param]);
        }
        return $c;
    }

    /**
     * Check if all parameters are bound
     */
    public function isBound(): bool
    {
        if (!empty($this->binds)) {
            return false;
        }
        if ($this->deferredPredicates !== null) {
            foreach ($this->deferredPredicates as $p) {
                if (!$p->isBound()) {
                    return false;
                }
            }
        }
        return true;
    }

    /**
     * Get list of unbound parameter names
     */
    public function getUnboundParameters(): array
    {
        $params = array_keys($this->binds);
        if ($this->deferredPredicates !== null) {
            foreach ($this->deferredPredicates as $p) {
                $params = array_merge($params, $p->getUnboundParams());
            }
        }
        return array_unique($params);
    }

    // =========================================================================
    // Filter methods
    // =========================================================================

    public function eq(string $column, int|float|string|null $value): self
    {
        $c = clone $this;
        $c->source = $c->source->eq($column, $value);
        return $c;
    }

    public function lt(string $column, int|float|string $value): self
    {
        $c = clone $this;
        $c->source = $c->source->lt($column, $value);
        return $c;
    }

    public function lte(string $column, int|float|string $value): self
    {
        $c = clone $this;
        $c->source = $c->source->lte($column, $value);
        return $c;
    }

    public function gt(string $column, int|float|string $value): self
    {
        $c = clone $this;
        $c->source = $c->source->gt($column, $value);
        return $c;
    }

    public function gte(string $column, int|float|string $value): self
    {
        $c = clone $this;
        $c->source = $c->source->gte($column, $value);
        return $c;
    }

    public function like(string $column, string $pattern): self
    {
        $c = clone $this;
        $c->source = $c->source->like($column, $pattern);
        return $c;
    }

    public function or(Predicate $a, Predicate $b, Predicate ...$more): self
    {
        $predicates = [$a, $b, ...$more];
        $c = clone $this;

        // Check for unbound params in predicates
        $hasUnbound = false;
        foreach ($predicates as $p) {
            foreach ($p->getUnboundParams() as $param) {
                $c->binds[$param][] = ['or', count($predicates)]; // marker for or predicates
                $hasUnbound = true;
            }
        }

        if ($hasUnbound) {
            // Defer predicates until bound
            $c->deferredPredicates = $predicates;
        } else {
            // All bound, apply immediately
            $c->source = $c->source->or($a, $b, ...$more);
        }

        return $c;
    }

    public function columns(string ...$columns): self
    {
        $c = clone $this;
        if ($this->deferredColumns !== null) {
            $c->deferredColumns = array_values(
                array_intersect($columns, $this->deferredColumns)
            );
        } else {
            $c->deferredColumns = $columns;
        }
        return $c;
    }

    public function order(?string $spec): self
    {
        $c = clone $this;
        $c->source = $c->source->order($spec);
        return $c;
    }

    public function limit(int $n): self
    {
        $c = clone $this;
        $c->source = $c->source->limit($n);
        return $c;
    }

    public function offset(int $n): self
    {
        $c = clone $this;
        $c->source = $c->source->offset($n);
        return $c;
    }

    // =========================================================================
    // Iteration and data access
    // =========================================================================

    public function getIterator(): Traversable
    {
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot iterate: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        yield from $this->getEffectiveSource();
    }

    public function count(): int
    {
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot count: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        return $this->getEffectiveSource()->count();
    }

    public function exists(): bool
    {
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot check exists: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        return $this->getEffectiveSource()->exists();
    }

    public function load(string|int $rowId): ?object
    {
        if (!empty($this->binds)) {
            throw new \RuntimeException(
                "Cannot load: unbound parameters: " . implode(', ', array_keys($this->binds))
            );
        }
        return $this->getEffectiveSource()->load($rowId);
    }

    /**
     * @return array<string, ColumnDef>
     */
    public function getColumns(): array
    {
        if ($this->deferredColumns !== null) {
            $sourceCols = $this->source->getColumns();
            $result = [];
            foreach ($this->deferredColumns as $col) {
                if (isset($sourceCols[$col])) {
                    $result[$col] = $sourceCols[$col];
                }
            }
            return $result;
        }
        return $this->source->getColumns();
    }

    // =========================================================================
    // Internal
    // =========================================================================

    private function getEffectiveSource(): TableInterface
    {
        $source = $this->source;

        // Apply deferred predicates (must be fully bound at this point)
        if ($this->deferredPredicates !== null) {
            $source = $source->or(...$this->deferredPredicates);
        }

        if ($this->deferredColumns !== null) {
            $source = $source->columns(...$this->deferredColumns);
        }

        return $source;
    }
}