SqlExceptTable.php
PHP
Path: src/Table/Wrappers/SqlExceptTable.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\Types\IndexType;
use mini\Table\Types\Operator;
use Traversable;
/**
* SQL EXCEPT - rows from left that don't exist in right
*
* Uses positional column matching (SQL standard for set operations).
* Predicates push down to left side only (we're filtering left, right is exclusion set).
*
* ```php
* // SELECT * FROM a EXCEPT SELECT * FROM b
* new SqlExceptTable($tableA, $tableB)
* ```
*/
class SqlExceptTable extends AbstractTable
{
public function __construct(
private TableInterface $left,
private TableInterface $right,
) {
// Freeze sides with pagination to prevent filter pushdown from escaping their result sets
if ($left instanceof AbstractTable && ($left->getLimit() !== null || $left->getOffset() > 0)) {
$this->left = $left = BarrierTable::from($left);
}
if ($right instanceof AbstractTable && ($right->getLimit() !== null || $right->getOffset() > 0)) {
$this->right = $right = BarrierTable::from($right);
}
$leftCols = $left->getColumns();
$rightCols = $right->getColumns();
// Validate matching column count (SQL set operation requirement)
if (count($leftCols) !== count($rightCols)) {
throw new \InvalidArgumentException(
'EXCEPT requires same number of columns: ' . count($leftCols) . ' vs ' . count($rightCols)
);
}
// Use left table's column names (SQL standard)
parent::__construct(...array_values($leftCols));
}
protected function materialize(string ...$additionalColumns): Traversable
{
$cols = array_unique([...array_keys($this->getColumns()), ...$additionalColumns]);
$leftCols = array_keys($this->left->getColumns());
$rightCols = array_keys($this->right->getColumns());
// Wrap right side with OptimizingTable if it has no useful index
$probeTable = $this->right;
if (!$this->hasUsefulIndex($this->right, $rightCols) && $probeTable instanceof AbstractTable) {
$probeTable = OptimizingTable::from($probeTable)
->withExpectedHasCalls($this->left->count());
}
$skipped = 0;
$emitted = 0;
$limit = $this->getLimit();
$offset = $this->getOffset();
foreach ($this->left->columns(...$leftCols) as $id => $row) {
// Build member with right's column names for has() check
$member = $this->remapRow($row, $leftCols, $rightCols);
// Exclude if row exists in right side
if ($probeTable->has($member)) {
continue;
}
if ($skipped < $offset) {
$skipped++;
continue;
}
// Output with our (left's) column names
$out = $this->remapRow($row, $leftCols, $cols);
yield $id => $out;
$emitted++;
if ($limit !== null && $emitted >= $limit) {
return;
}
}
}
/**
* Check if a table has useful indexes for membership testing
*/
private function hasUsefulIndex(TableInterface $table, array $columns): bool
{
$tableCols = $table->getColumns();
foreach ($columns as $col) {
if (isset($tableCols[$col]) && $tableCols[$col]->index !== IndexType::None) {
return true;
}
}
return false;
}
/**
* Remap row from source columns to target columns (positional)
*/
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;
}
// -------------------------------------------------------------------------
// Filter methods - push down to LEFT only (we're filtering left rows)
// Right is the exclusion set and should remain unchanged
// -------------------------------------------------------------------------
public function eq(string $column, int|float|string|null $value): TableInterface
{
$c = clone $this;
$c->left = $this->left->eq($column, $value);
return $c;
}
public function lt(string $column, int|float|string $value): TableInterface
{
$c = clone $this;
$c->left = $this->left->lt($column, $value);
return $c;
}
public function lte(string $column, int|float|string $value): TableInterface
{
$c = clone $this;
$c->left = $this->left->lte($column, $value);
return $c;
}
public function gt(string $column, int|float|string $value): TableInterface
{
$c = clone $this;
$c->left = $this->left->gt($column, $value);
return $c;
}
public function gte(string $column, int|float|string $value): TableInterface
{
$c = clone $this;
$c->left = $this->left->gte($column, $value);
return $c;
}
public function in(string $column, SetInterface $values): TableInterface
{
$c = clone $this;
$c->left = $this->left->in($column, $values);
return $c;
}
public function like(string $column, string $pattern): TableInterface
{
$c = clone $this;
$c->left = $this->left->like($column, $pattern);
return $c;
}
// -------------------------------------------------------------------------
// Other methods
// -------------------------------------------------------------------------
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);
}
public function has(object $member): bool
{
// Must exist in left AND NOT in right
$leftCols = array_keys($this->left->getColumns());
$rightCols = array_keys($this->right->getColumns());
// Remap member to right's column names
$rightMember = new \stdClass();
foreach ($rightCols as $i => $rightCol) {
$leftCol = $leftCols[$i];
if (property_exists($member, $leftCol)) {
$rightMember->$rightCol = $member->$leftCol;
}
}
return $this->left->has($member) && !$this->right->has($rightMember);
}
}