AliasTable.php

PHP

Path: src/Table/Wrappers/AliasTable.php

<?php

namespace mini\Table\Wrappers;

use mini\Table\ColumnDef;
use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\OrderDef;
use mini\Table\Predicate;
use mini\Table\Utility\TablePropertiesTrait;
use Traversable;

/**
 * Wrapper that applies table/column aliasing to a source table
 *
 * Transforms column names by prefixing with table alias. Used for JOINs
 * and correlated subqueries where tables need qualified column names.
 *
 * ```php
 * $aliased = $users->withAlias('u');
 * // Columns: u.id, u.name
 * // Rows: (object) ['u.id' => 123, 'u.name' => 'Frode']
 *
 * // Filter methods require aliased column names
 * $aliased->eq('u.id', 123);  // Works
 * $aliased->eq('id', 123);    // Throws InvalidArgumentException
 * ```
 */
class AliasTable implements TableInterface
{
    use TablePropertiesTrait;

    private TableInterface $source;

    /** @var array<string,string> Original name → aliased name mapping */
    private array $aliasMap = [];

    /** @var array<string,string> Aliased name → original name mapping */
    private array $reverseMap = [];

    /** @var ?string Table alias for prefixing columns */
    private ?string $alias;

    /** @var array<string,string> Column renames [original => alias] */
    private array $colAliases;

    public function __construct(
        TableInterface $source,
        ?string $tableAlias = null,
        array $columnAliases = [],
    ) {
        $this->source = $source;
        $this->alias = $tableAlias;
        $this->colAliases = $columnAliases;

        // Build alias mappings from source columns
        foreach ($source->getColumns() as $origName => $def) {
            $aliasedName = $this->buildAliasedName($origName);
            $this->aliasMap[$origName] = $aliasedName;
            $this->reverseMap[$aliasedName] = $origName;
        }
    }

    private function buildAliasedName(string $name): string
    {
        // Apply column alias first
        $name = $this->colAliases[$name] ?? $name;

        // Then apply table prefix
        if ($this->alias !== null) {
            $name = $this->alias . '.' . $name;
        }

        return $name;
    }

    /**
     * Get the underlying source table
     */
    public function getSource(): TableInterface
    {
        return $this->source;
    }

    /**
     * Create a copy with a different source table
     */
    public function withSource(TableInterface $source): self
    {
        $copy = clone $this;
        $copy->source = $source;
        return $copy;
    }

    /**
     * Resolve aliased column name to original
     *
     * Only accepts aliased names (e.g., 'users.id'), not original ('id').
     */
    public function resolveToOriginal(string $name): string
    {
        // Exact match on aliased name (e.g., "t1.col")
        if (isset($this->reverseMap[$name])) {
            return $this->reverseMap[$name];
        }

        // Try unqualified match - find aliased name ending with ".{name}"
        // This allows using "col" to match "t1.col"
        $suffix = '.' . $name;
        foreach ($this->reverseMap as $aliased => $original) {
            if (str_ends_with($aliased, $suffix)) {
                return $original;
            }
        }

        throw new \InvalidArgumentException(
            "Column '$name' does not exist in table. " .
            "Available columns: " . implode(', ', array_keys($this->reverseMap))
        );
    }

    public function getIterator(): Traversable
    {
        // Cache alias map locally to avoid repeated property access
        $aliasMap = $this->aliasMap;

        foreach ($this->source as $id => $row) {
            // Convert to array, remap keys, convert back to object
            $arr = (array) $row;
            $aliased = [];
            foreach ($arr as $origName => $value) {
                $aliased[$aliasMap[$origName] ?? $origName] = $value;
            }
            yield $id => (object) $aliased;
        }
    }

    public function count(): int
    {
        return $this->source->count();
    }

    public function getColumns(): array
    {
        $sourceCols = $this->source->getColumns();
        $result = [];

        foreach ($sourceCols as $origName => $def) {
            $aliasedName = $this->aliasMap[$origName]
                ?? throw new \RuntimeException("No alias mapping for column '$origName'");
            $result[$aliasedName] = new ColumnDef($aliasedName, $def->type, $def->index, ...$def->indexWith);
        }

        return $result;
    }

    public function getAllColumns(): array
    {
        $sourceCols = $this->source->getAllColumns();
        $result = [];

        foreach ($sourceCols as $origName => $def) {
            $aliasedName = $this->aliasMap[$origName]
                ?? throw new \RuntimeException("No alias mapping for column '$origName'");
            $result[$aliasedName] = new ColumnDef($aliasedName, $def->type, $def->index, ...$def->indexWith);
        }

        return $result;
    }

    public function has(object $member): bool
    {
        // Convert aliased member to original column names
        $original = new \stdClass();
        foreach ($member as $name => $value) {
            $origName = $this->reverseMap[$name] ?? $name;
            $original->$origName = $value;
        }
        return $this->source->has($original);
    }

    // -------------------------------------------------------------------------
    // Filter methods - resolve column name and delegate
    // -------------------------------------------------------------------------

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

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

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

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

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

    public function in(string $column, SetInterface $values): TableInterface
    {
        $c = clone $this;
        $c->source = $this->source->in($this->resolveToOriginal($column), $values);
        return $c;
    }

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

    public function union(TableInterface $other): TableInterface
    {
        return new UnionTable($this, $other);
    }

    public function or(Predicate $a, Predicate $b, Predicate ...$more): TableInterface
    {
        // Translate aliased column names in predicates to original names
        $translated = array_map(
            fn(Predicate $p) => $p->mapColumns(fn($col) => $this->resolveToOriginal($col)),
            [$a, $b, ...$more]
        );

        $c = clone $this;
        $c->source = $this->source->or($translated[0], $translated[1], ...array_slice($translated, 2));
        return $c;
    }

    public function except(SetInterface $other): TableInterface
    {
        return new ExceptTable($this, $other);
    }

    public function distinct(): TableInterface
    {
        $c = clone $this;
        $c->source = $this->source->distinct();
        return $c;
    }

    public function columns(string ...$columns): TableInterface
    {
        // Resolve to original names and delegate
        $origColumns = array_map([$this, 'resolveToOriginal'], $columns);
        $c = clone $this;
        $c->source = $this->source->columns(...$origColumns);

        // Rebuild alias maps for new column set
        $c->aliasMap = [];
        $c->reverseMap = [];
        foreach ($origColumns as $origName) {
            $aliasedName = $c->buildAliasedName($origName);
            $c->aliasMap[$origName] = $aliasedName;
            $c->reverseMap[$aliasedName] = $origName;
        }

        return $c;
    }

    public function order(?string $spec): TableInterface
    {
        if ($spec === null || $spec === '') {
            $c = clone $this;
            $c->source = $this->source->order(null);
            return $c;
        }

        // Parse and resolve column names in order spec
        $orders = OrderDef::parse($spec);
        $resolved = [];
        foreach ($orders as $order) {
            $origCol = $this->resolveToOriginal($order->column);
            $resolved[] = $origCol . ' ' . ($order->asc ? 'ASC' : 'DESC');
        }

        $c = clone $this;
        $c->source = $this->source->order(implode(', ', $resolved));
        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;
    }

    public function getLimit(): ?int
    {
        return $this->source->getLimit();
    }

    public function getOffset(): int
    {
        return $this->source->getOffset();
    }

    public function exists(): bool
    {
        return $this->source->exists();
    }

    public function load(string|int $rowId): ?object
    {
        $row = $this->source->load($rowId);
        if ($row === null) {
            return null;
        }

        // Remap to aliased names
        $aliased = new \stdClass();
        foreach ($row as $origName => $value) {
            $aliasedName = $this->aliasMap[$origName]
                ?? throw new \RuntimeException("No alias mapping for column '$origName'");
            $aliased->$aliasedName = $value;
        }
        return $aliased;
    }

    /**
     * Create new AliasTable with replaced/merged alias configuration
     *
     * When stacking aliases, the new table alias replaces the old one,
     * but column aliases are merged (new overrides old).
     */
    public function withAlias(?string $tableAlias = null, array $columnAliases = []): TableInterface
    {
        // Merge column aliases: new ones override old
        $merged = array_merge($this->colAliases, $columnAliases);
        return new AliasTable($this->source, $tableAlias, $merged);
    }
}