JSONTable.php

PHP

Path: src/Table/JSONTable.php

<?php

namespace mini\Table;

use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Types\ColumnType;
use mini\Table\Types\Operator;
use mini\Table\Utility\EmptyTable;
use mini\Table\Wrappers\FilteredTable;
use mini\Table\Wrappers\OrTable;
use mini\Table\Wrappers\SortedTable;
use Traversable;

/**
 * Table backed by JSON data
 *
 * Reads an array of objects from a JSON file, string, or PHP array.
 * Column types are inferred from the data.
 *
 * ```php
 * // From file
 * $table = JSONTable::fromFile('data.json');
 *
 * // From string
 * $table = JSONTable::fromString('[{"id":1,"name":"Alice"}]');
 *
 * // From array (already decoded)
 * $table = JSONTable::fromArray([
 *     ['id' => 1, 'name' => 'Alice'],
 *     ['id' => 2, 'name' => 'Bob'],
 * ]);
 *
 * // With JSON pointer to nested array
 * $table = JSONTable::fromFile('response.json', '/data/users');
 * ```
 */
class JSONTable extends AbstractTable
{
    /** @var array<int, object> */
    private array $rows = [];

    private function __construct(ColumnDef ...$columns)
    {
        parent::__construct(...$columns);
    }

    /**
     * Create table from a JSON file
     *
     * @param string $path File path
     * @param string|null $pointer JSON pointer to array (e.g., '/data/items')
     */
    public static function fromFile(string $path, ?string $pointer = null): self
    {
        if (!file_exists($path)) {
            throw new \InvalidArgumentException("JSON file not found: $path");
        }

        $content = file_get_contents($path);
        if ($content === false) {
            throw new \RuntimeException("Failed to read JSON file: $path");
        }

        return self::fromString($content, $pointer);
    }

    /**
     * Create table from a JSON string
     *
     * @param string $content JSON content
     * @param string|null $pointer JSON pointer to array (e.g., '/data/items')
     */
    public static function fromString(string $content, ?string $pointer = null): self
    {
        $data = json_decode($content, false, 512, JSON_THROW_ON_ERROR);

        if ($pointer !== null) {
            $data = self::resolvePointer($data, $pointer);
        }

        if (!is_array($data)) {
            throw new \InvalidArgumentException('JSON must decode to an array of objects');
        }

        return self::fromArray($data);
    }

    /**
     * Create table from a PHP array
     *
     * @param array<array|object> $data Array of rows
     */
    public static function fromArray(array $data): self
    {
        if (empty($data)) {
            throw new \InvalidArgumentException('JSONTable requires non-empty data');
        }

        // Convert to objects and collect all column names
        $rows = [];
        $allColumns = [];

        foreach ($data as $idx => $row) {
            if (is_array($row)) {
                $row = (object) $row;
            }
            if (!is_object($row)) {
                throw new \InvalidArgumentException("Row $idx must be an array or object");
            }

            foreach ($row as $key => $value) {
                if (!isset($allColumns[$key])) {
                    $allColumns[$key] = null; // type TBD
                }
            }

            $rows[$idx] = $row;
        }

        // Infer types from all rows
        foreach ($rows as $row) {
            foreach ($allColumns as $col => $currentType) {
                $value = $row->$col ?? null;

                if ($value === null) {
                    continue;
                }

                $valueType = match (true) {
                    is_int($value) => ColumnType::Int,
                    is_float($value) => ColumnType::Float,
                    is_bool($value) => ColumnType::Int, // SQLite convention
                    is_string($value) => ColumnType::Text,
                    default => ColumnType::Text, // arrays/objects become Text
                };

                // Widen type if needed
                $allColumns[$col] = match ($currentType) {
                    null => $valueType,
                    ColumnType::Int => match ($valueType) {
                        ColumnType::Int => ColumnType::Int,
                        ColumnType::Float => ColumnType::Float,
                        default => ColumnType::Text,
                    },
                    ColumnType::Float => match ($valueType) {
                        ColumnType::Int, ColumnType::Float => ColumnType::Float,
                        default => ColumnType::Text,
                    },
                    default => ColumnType::Text,
                };
            }
        }

        // Stringify non-scalar values and convert bools to int
        foreach ($rows as $row) {
            foreach ($row as $col => $value) {
                if (is_bool($value)) {
                    $row->$col = $value ? 1 : 0;
                } elseif (is_array($value) || is_object($value)) {
                    $row->$col = json_encode($value);
                }
            }
        }

        // Build columns
        $columns = [];
        foreach ($allColumns as $name => $type) {
            $columns[] = new ColumnDef($name, $type ?? ColumnType::Text);
        }

        $table = new self(...$columns);
        $table->rows = $rows;

        return $table;
    }

    /**
     * Resolve a JSON pointer to a nested value
     *
     * @param mixed $data Root data
     * @param string $pointer JSON pointer (e.g., '/data/items')
     * @return mixed Resolved value
     */
    private static function resolvePointer(mixed $data, string $pointer): mixed
    {
        if ($pointer === '' || $pointer === '/') {
            return $data;
        }

        $parts = explode('/', ltrim($pointer, '/'));

        foreach ($parts as $part) {
            // Unescape JSON pointer special chars
            $part = str_replace(['~1', '~0'], ['/', '~'], $part);

            if (is_array($data)) {
                if (!array_key_exists($part, $data)) {
                    throw new \InvalidArgumentException("JSON pointer '$pointer' not found at '$part'");
                }
                $data = $data[$part];
            } elseif (is_object($data)) {
                if (!property_exists($data, $part)) {
                    throw new \InvalidArgumentException("JSON pointer '$pointer' not found at '$part'");
                }
                $data = $data->$part;
            } else {
                throw new \InvalidArgumentException("JSON pointer '$pointer' cannot traverse non-object at '$part'");
            }
        }

        return $data;
    }

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

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

        foreach ($this->rows as $key => $row) {
            if ($skipped < $offset) {
                $skipped++;
                continue;
            }

            $projected = new \stdClass();
            foreach ($cols as $col) {
                $projected->$col = $row->$col ?? null;
            }

            yield $key => $projected;
            $emitted++;

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

    public function count(): int
    {
        $total = count($this->rows);
        $offset = $this->getOffset();
        $limit = $this->getLimit();

        $count = max(0, $total - $offset);
        if ($limit !== null) {
            $count = min($count, $limit);
        }

        return $count;
    }

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

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

    public function or(Predicate $a, Predicate $b, Predicate ...$more): TableInterface
    {
        $predicates = array_values(array_filter(
            [$a, $b, ...$more],
            fn($p) => !$p->isEmpty()
        ));

        if (empty($predicates)) {
            return EmptyTable::from($this);
        }

        return new OrTable($this, ...$predicates);
    }
}