ResultSet.php

PHP

Path: src/Database/ResultSet.php

<?php

namespace mini\Database;

use stdClass;

/**
 * Simple result set wrapper for raw SQL queries
 *
 * Wraps an iterable of rows and provides the ResultSetInterface API.
 * Supports hydration to entity classes or custom closures.
 *
 * Rows can be arrays or stdClass objects. When hydration is not configured,
 * rows are returned as-is (array or stdClass depending on source).
 *
 * @template T of array|object
 * @implements ResultSetInterface<T>
 */
class ResultSet implements ResultSetInterface
{
    /** @var iterable<array|stdClass> */
    private iterable $rows;

    /** @var array<array|stdClass>|null Materialized rows (lazy) */
    private ?array $materialized = null;

    /** @var \Closure|null */
    private ?\Closure $hydrator = null;

    /** @var class-string|null */
    private ?string $entityClass = null;

    /** @var array|false */
    private array|false $constructorArgs = false;

    /**
     * @param iterable<array|stdClass> $rows Raw database rows
     */
    public function __construct(iterable $rows)
    {
        $this->rows = $rows;
    }

    /**
     * @return \Traversable<int, T>
     */
    public function getIterator(): \Traversable
    {
        // Fast path: no hydration configured, yield rows directly
        if ($this->hydrator === null && $this->entityClass === null) {
            yield from $this->rows;
            return;
        }

        // Slow path: apply hydration
        foreach ($this->rows as $row) {
            yield $this->hydrateRow($row);
        }
    }

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

    public function jsonSerialize(): array
    {
        return $this->toArray();
    }

    public function toArray(): array
    {
        $result = [];
        foreach ($this->materialize() as $row) {
            $result[] = $this->hydrateRow($row);
        }
        return $result;
    }

    public function one(): mixed
    {
        foreach ($this->rows as $row) {
            return $this->hydrateRow($row);
        }
        return null;
    }

    public function column(): array
    {
        $result = [];
        foreach ($this->rows as $row) {
            $result[] = $this->getFirstValue($row);
        }
        return $result;
    }

    public function field(): mixed
    {
        foreach ($this->rows as $row) {
            return $this->getFirstValue($row);
        }
        return null;
    }

    /**
     * Get first value from a row (works with both array and stdClass)
     */
    private function getFirstValue(array|stdClass $row): mixed
    {
        if ($row instanceof stdClass) {
            $vars = get_object_vars($row);
            return $vars ? reset($vars) : null;
        }
        return $row ? reset($row) : null;
    }

    public function withEntityClass(string $class, array|false $constructorArgs = false): self
    {
        $clone = clone $this;
        $clone->entityClass = $class;
        $clone->constructorArgs = $constructorArgs;
        $clone->hydrator = null;
        return $clone;
    }

    public function withHydrator(\Closure $hydrator): self
    {
        $clone = clone $this;
        $clone->hydrator = $hydrator;
        $clone->entityClass = null;
        return $clone;
    }

    /**
     * Materialize rows for operations that need the full set
     */
    private function materialize(): array
    {
        if ($this->materialized === null) {
            $this->materialized = $this->rows instanceof \Traversable
                ? iterator_to_array($this->rows)
                : (array) $this->rows;
        }
        return $this->materialized;
    }

    /**
     * Apply hydration to a single row
     *
     * @param array|stdClass $row
     * @return T
     */
    private function hydrateRow(array|stdClass $row): mixed
    {
        if ($this->hydrator !== null) {
            return ($this->hydrator)($row);
        }

        if ($this->entityClass !== null) {
            return $this->hydrateEntity($row);
        }

        return $row;
    }

    /**
     * Convert row to array if needed
     */
    private function rowToArray(array|stdClass $row): array
    {
        return $row instanceof stdClass ? get_object_vars($row) : $row;
    }

    /**
     * Hydrate row into entity class
     */
    private function hydrateEntity(array|stdClass $row): object
    {
        $class = $this->entityClass;
        $refClass = new \ReflectionClass($class);

        if ($this->constructorArgs === false) {
            // Skip constructor, assign properties directly
            $entity = $refClass->newInstanceWithoutConstructor();
        } else {
            // Use constructor with provided args
            $entity = $refClass->newInstanceArgs($this->constructorArgs);
        }

        // Map columns to properties by name
        foreach ($row as $key => $value) {
            if ($refClass->hasProperty($key)) {
                $prop = $refClass->getProperty($key);
                $prop->setValue($entity, $value);
            }
        }

        return $entity;
    }
}