UnionTable.php

PHP

Path: src/Table/Wrappers/UnionTable.php

<?php

namespace mini\Table\Wrappers;

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

/**
 * Union of two tables (set union / OR operation)
 *
 * Yields rows from both tables, deduplicating by content via SetInterface::has().
 * Filter methods push down to both sides, allowing each to optimize independently.
 *
 * ```php
 * // WHERE status = 'active' OR status = 'pending'
 * $table->eq('status', 'active')->union($table->eq('status', 'pending'))
 * ```
 */
class UnionTable 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 columns
        foreach ($aCols as $name => $_) {
            if (!isset($bCols[$name])) {
                throw new \InvalidArgumentException(
                    'UNION requires matching columns: column "' . $name . '" missing in second table'
                );
            }
        }
        foreach ($bCols as $name => $_) {
            if (!isset($aCols[$name])) {
                throw new \InvalidArgumentException(
                    'UNION requires matching columns: column "' . $name . '" missing in first table'
                );
            }
        }

        // Compute merged column definitions
        $merged = [];
        foreach ($aCols as $name => $defA) {
            $merged[] = $defA->commonWith($bCols[$name]);
        }

        parent::__construct(...$merged);
    }

    // -------------------------------------------------------------------------
    // Iteration and counting
    // -------------------------------------------------------------------------

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

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

        foreach ($a as $id => $row) {
            if ($skipped++ < $offset) {
                continue;
            }

            yield $id => $row;

            if (++$emitted === $limit) {
                return;
            }
        }

        foreach ($b as $id => $row) {
            // Use SetInterface::has() - each table knows how to check membership efficiently
            if ($a->has($row) || $skipped++ < $offset) {
                continue;
            }

            yield $id => $row;

            if (++$emitted === $limit) {
                return;
            }
        }
    }

    // -------------------------------------------------------------------------
    // Filter methods - push down to both sides
    // -------------------------------------------------------------------------

    public function eq(string $column, int|float|string|null $value): TableInterface
    {
        return new self($this->a->eq($column, $value), $this->b->eq($column, $value));
    }

    public function lt(string $column, int|float|string $value): TableInterface
    {
        return new self($this->a->lt($column, $value), $this->b->lt($column, $value));
    }

    public function lte(string $column, int|float|string $value): TableInterface
    {
        return new self($this->a->lte($column, $value), $this->b->lte($column, $value));
    }

    public function gt(string $column, int|float|string $value): TableInterface
    {
        return new self($this->a->gt($column, $value), $this->b->gt($column, $value));
    }

    public function gte(string $column, int|float|string $value): TableInterface
    {
        return new self($this->a->gte($column, $value), $this->b->gte($column, $value));
    }

    public function in(string $column, SetInterface $values): TableInterface
    {
        return new self($this->a->in($column, $values), $this->b->in($column, $values));
    }

    public function like(string $column, string $pattern): TableInterface
    {
        return new self($this->a->like($column, $pattern), $this->b->like($column, $pattern));
    }

    // -------------------------------------------------------------------------
    // Membership and counting
    // -------------------------------------------------------------------------

    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);
    }
}