ConcatTable.php

PHP

Path: src/Table/Wrappers/ConcatTable.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 mini\Table\OrderDef;
use mini\Table\Utility\TablePropertiesTrait;
use Traversable;

/**
 * Concatenation of two tables (UNION ALL semantics)
 *
 * Yields all rows from both tables without deduplication.
 * For SQL UNION (with deduplication), wrap in DistinctTable.
 *
 * ```php
 * // UNION ALL - all rows from both
 * new ConcatTable($tableA, $tableB);
 *
 * // UNION - deduplicated
 * new DistinctTable(new ConcatTable($tableA, $tableB));
 * ```
 */
class ConcatTable extends AbstractTable
{
    public function __construct(
        private TableInterface $a,
        private TableInterface $b,
    ) {
        // Freeze sides with pagination to prevent filter pushdown from escaping their result sets
        if ($a instanceof AbstractTable && ($a->getLimit() !== null || $a->getOffset() > 0)) {
            $this->a = $a = BarrierTable::from($a);
        }
        if ($b instanceof AbstractTable && ($b->getLimit() !== null || $b->getOffset() > 0)) {
            $this->b = $b = BarrierTable::from($b);
        }

        $aCols = $a->getColumns();
        $bCols = $b->getColumns();

        // Validate matching column count (SQL UNION requirement)
        // Skip validation if either side has unknown columns (e.g., PartialQuery)
        if (!empty($aCols) && !empty($bCols) && count($aCols) !== count($bCols)) {
            throw new \InvalidArgumentException(
                'UNION requires same number of columns: ' . count($aCols) . ' vs ' . count($bCols)
            );
        }

        // Use first table's column names if known, otherwise use second's, otherwise empty
        // We don't require column name match, only count match
        $cols = !empty($aCols) ? $aCols : $bCols;
        parent::__construct(...array_values($cols));
    }

    protected function materialize(string ...$additionalColumns): Traversable
    {
        $cols = array_unique([...array_keys($this->getColumns()), ...$additionalColumns]);
        $aCols = array_keys($this->a->getColumns());
        $bCols = array_keys($this->b->getColumns());

        $skipped = 0;
        $emitted = 0;
        $limit = $this->getLimit();
        $offset = $this->getOffset();

        // Yield from first table
        // If column info unavailable, iterate directly (SELECT * semantics)
        $aIterator = empty($aCols) ? $this->a : $this->a->columns(...$aCols);
        foreach ($aIterator as $row) {
            if ($skipped++ < $offset) {
                continue;
            }

            // Remap to our column names if different, or pass through if unknown
            $out = empty($cols) ? $row : $this->remapRow($row, $aCols, $cols);
            yield $out;

            if ($limit !== null && ++$emitted >= $limit) {
                return;
            }
        }

        // Yield from second table
        // If column info unavailable, iterate directly (SELECT * semantics)
        $bIterator = empty($bCols) ? $this->b : $this->b->columns(...$bCols);
        foreach ($bIterator as $row) {
            if ($skipped++ < $offset) {
                continue;
            }

            // Remap to our column names if different, or pass through if unknown
            $out = empty($cols) ? $row : $this->remapRow($row, $bCols, $cols);
            yield $out;

            if ($limit !== null && ++$emitted >= $limit) {
                return;
            }
        }
    }

    /**
     * Remap row from source columns to target columns
     */
    private function remapRow(object $row, array $sourceCols, array $targetCols): object
    {
        $out = new \stdClass();
        foreach ($targetCols as $i => $targetCol) {
            $sourceCol = $sourceCols[$i] ?? $targetCol;
            $out->$targetCol = $row->$sourceCol ?? null;
        }
        return $out;
    }

    public function count(): int
    {
        return iterator_count($this);
    }

    public function order(?string $spec): TableInterface
    {
        $orders = $spec ? OrderDef::parse($spec) : [];
        if (empty($orders)) {
            return $this;
        }
        return new SortedTable($this, ...$orders);
    }

    // -------------------------------------------------------------------------
    // Filter methods - wrap in FilteredTable (filters apply to concatenated result)
    // -------------------------------------------------------------------------

    public function eq(string $column, int|float|string|null $value): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Eq, $value);
    }

    public function lt(string $column, int|float|string $value): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Lt, $value);
    }

    public function lte(string $column, int|float|string $value): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Lte, $value);
    }

    public function gt(string $column, int|float|string $value): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Gt, $value);
    }

    public function gte(string $column, int|float|string $value): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Gte, $value);
    }

    public function in(string $column, SetInterface $values): TableInterface
    {
        return new FilteredTable($this, $column, Operator::In, $values);
    }

    public function like(string $column, string $pattern): TableInterface
    {
        return new FilteredTable($this, $column, Operator::Like, $pattern);
    }
}