InMemoryTable.php

PHP

Path: src/Table/InMemoryTable.php

<?php

namespace mini\Table;

use mini\Table\Contracts\MutableTableInterface;
use mini\Table\Contracts\SetInterface;
use mini\Table\Contracts\TableInterface;
use mini\Table\Types\ColumnType;
use mini\Table\Types\IndexType;
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 mini\Table\Wrappers\ExceptTable;
use mini\Table\Wrappers\UnionTable;
use mini\Util\Math\Decimal;
use SQLite3;
use Traversable;

/**
 * SQLite-backed in-memory table implementation
 *
 * Intended as a ground-truth oracle for testing - all operations are
 * translated to SQL and executed by SQLite, which provides well-defined
 * semantics for filtering, sorting, and set operations.
 *
 * ```php
 * $table = new InMemoryTable(
 *     new ColumnDef('id', ColumnType::Int, IndexType::Primary),
 *     new ColumnDef('name', ColumnType::Text, IndexType::Index),
 *     new ColumnDef('age', ColumnType::Int),
 * );
 *
 * $table->insert(['id' => 1, 'name' => 'Alice', 'age' => 30]);
 * $table->insert(['id' => 2, 'name' => 'Bob', 'age' => 25]);
 *
 * foreach ($table->gt('age', 20)->order('name ASC') as $id => $row) {
 *     echo "$row->name is $row->age\n";
 * }
 * ```
 */
class InMemoryTable extends AbstractTable implements MutableTableInterface
{
    private SQLite3 $db;
    private string $tableName = 'data';

    /** @var array{column: string, op: string, value: mixed, paramName: string}[] */
    private array $where = [];

    /**
     * OR groups - when non-empty, WHERE becomes: (group1) OR (group2) OR ...
     * Each group is an array of conditions that are ANDed together.
     * @var array[][]
     */
    private array $orGroups = [];

    /** @var OrderDef[] */
    private array $orderBy = [];

    /** @var int Counter for unique parameter names */
    private int $paramCounter = 0;

    /**
     * Create a new in-memory table with the given schema
     */
    public function __construct(ColumnDef ...$columns)
    {
        if (empty($columns)) {
            throw new \InvalidArgumentException('InMemoryTable requires at least one column');
        }

        parent::__construct(...$columns);

        $this->db = new SQLite3(':memory:');
        $this->db->enableExceptions(true);
        $this->bufferingDisabled = true;

        $this->createTable($columns);
    }

    /**
     * Share database connection on clone (filters are query-level state)
     */
    public function __clone()
    {
        parent::__clone();
        // Keep same $db reference - clones share the data
        // Keep paramCounter to ensure unique parameter names across filter chain
    }

    /**
     * Quote a SQLite identifier (column/table name).
     */
    private function quoteIdentifier(string $name): string
    {
        return '"' . str_replace('"', '""', $name) . '"';
    }

    /**
     * Create the SQLite table with proper schema
     */
    private function createTable(array $columns): void
    {
        $colDefs = [];
        $indexes = [];
        $primaryKey = null;

        foreach ($columns as $col) {
            // Decimal columns stored as scaled INTEGER for lossless storage
            if ($col->type === ColumnType::Decimal) {
                $sqlType = 'INTEGER';
            } else {
                $sqlType = $col->type->sqlType();
            }
            $def = $this->quoteIdentifier($col->name) . ' ' . $sqlType;

            if ($col->index === IndexType::Primary) {
                if ($primaryKey === null) {
                    // First PRIMARY KEY becomes the actual primary key
                    $def .= ' PRIMARY KEY';
                    $primaryKey = $col->name;
                } else {
                    // Additional PRIMARY KEY columns become UNIQUE indexes
                    // (SQLite only allows one PRIMARY KEY per table)
                    $indexName = $this->quoteIdentifier('idx_' . $col->name);
                    $indexes[] = "CREATE UNIQUE INDEX {$indexName} ON {$this->tableName} ("
                        . $this->quoteIdentifier($col->name) . ')';
                }
            }

            $colDefs[] = $def;

            // Handle non-primary indexes
            if ($col->index === IndexType::Index || $col->index === IndexType::Unique) {
                $indexCols = [$col->name, ...$col->indexWith];
                $indexName = $this->quoteIdentifier('idx_' . implode('_', $indexCols));
                $unique = $col->index === IndexType::Unique ? 'UNIQUE ' : '';
                $indexes[] = "CREATE {$unique}INDEX {$indexName} ON {$this->tableName} ("
                    . implode(', ', array_map(fn($c) => $this->quoteIdentifier($c), $indexCols))
                    . ')';
            }
        }

        $sql = "CREATE TABLE {$this->tableName} (" . implode(', ', $colDefs) . ')';
        $this->db->exec($sql);

        foreach ($indexes as $indexSql) {
            $this->db->exec($indexSql);
        }
    }

    // =========================================================================
    // Mutation methods
    // =========================================================================

    public function insert(array $row): int|string
    {
        $columns = [];
        $placeholders = [];
        $values = [];
        $columnDefs = $this->getColumns();

        foreach ($row as $col => $value) {
            $columns[] = $this->quoteIdentifier($col);
            $placeholders[] = '?';
            // Coerce Decimal columns to proper scale
            $values[] = $this->coerceValue($col, $value, $columnDefs);
        }

        $sql = "INSERT INTO {$this->tableName} (" . implode(', ', $columns) . ') VALUES (' . implode(', ', $placeholders) . ')';

        $stmt = $this->db->prepare($sql);
        foreach ($values as $i => $value) {
            $stmt->bindValue($i + 1, $value);
        }
        $stmt->execute();

        return $this->db->lastInsertRowID();
    }

    /**
     * Coerce a value based on column type
     *
     * For Decimal columns, converts to scaled INTEGER for lossless storage.
     * E.g., 9.99 with scale 2 → 999
     */
    private function coerceValue(string $column, mixed $value, array $columnDefs): mixed
    {
        if ($value === null) {
            return null;
        }

        $colDef = $columnDefs[$column] ?? null;
        if ($colDef === null) {
            return $value;
        }

        // Handle Decimal type: store as scaled integer
        if ($colDef->type === ColumnType::Decimal) {
            $scale = $colDef->getScale();
            if ($value instanceof Decimal) {
                // Get unscaled value at target scale
                return (string) $value->rescale($scale)->unscaledValue();
            }
            if (is_string($value) || is_int($value) || is_float($value)) {
                $decimal = Decimal::of((string) $value, $scale);
                return (string) $decimal->unscaledValue();
            }
        }

        return $value;
    }

    /**
     * Coerce a filter value based on column type
     *
     * For Decimal columns, converts to scaled INTEGER for comparison.
     */
    private function coerceFilterValue(string $column, mixed $value): mixed
    {
        if ($value === null) {
            return null;
        }

        $colDef = $this->getColumns()[$column] ?? null;
        if ($colDef === null) {
            return $value;
        }

        // Handle Decimal type: convert to scaled integer
        if ($colDef->type === ColumnType::Decimal) {
            $scale = $colDef->getScale();
            if ($value instanceof Decimal) {
                return (string) $value->rescale($scale)->unscaledValue();
            }
            if (is_string($value) || is_int($value) || is_float($value)) {
                $decimal = Decimal::of((string) $value, $scale);
                return (string) $decimal->unscaledValue();
            }
        }

        return $value;
    }

    public function update(TableInterface $query, array $changes): int
    {
        $this->validateQuery($query);

        $sets = [];
        $params = [];
        $columnDefs = $this->getColumns();
        $i = 0;

        foreach ($changes as $col => $value) {
            $paramName = ':set_' . ($i++);
            $sets[] = $this->quoteIdentifier($col) . ' = ' . $paramName;
            $params[$paramName] = $this->coerceValue($col, $value, $columnDefs);
        }

        $sql = "UPDATE {$this->tableName} SET " . implode(', ', $sets);

        [$whereSql, $whereParams] = $query->buildWhereClause();
        if ($whereSql) {
            $sql .= ' WHERE ' . $whereSql;
            $params = array_merge($params, $whereParams);
        }

        $stmt = $this->db->prepare($sql);
        foreach ($params as $name => $value) {
            $stmt->bindValue($name, $value);
        }
        $stmt->execute();

        return $this->db->changes();
    }

    public function delete(TableInterface $query): int
    {
        $this->validateQuery($query);

        $sql = "DELETE FROM {$this->tableName}";

        [$whereSql, $whereParams] = $query->buildWhereClause();
        if ($whereSql) {
            $sql .= ' WHERE ' . $whereSql;
        }

        $stmt = $this->db->prepare($sql);
        foreach ($whereParams as $name => $value) {
            $stmt->bindValue($name, $value);
        }
        $stmt->execute();

        return $this->db->changes();
    }

    /**
     * Validate that a query is derived from this table
     */
    private function validateQuery(TableInterface $query): void
    {
        if (!$query instanceof self) {
            throw new \InvalidArgumentException(
                'Query must be an InMemoryTable derived from this table'
            );
        }
        if ($query->db !== $this->db) {
            throw new \InvalidArgumentException(
                'Query must be derived from the same table instance'
            );
        }
    }

    // =========================================================================
    // Filter methods - build WHERE state
    // =========================================================================

    public function eq(string $column, int|float|string|null $value): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);

        if ($value === null) {
            $clone->where[] = ['column' => $column, 'op' => 'IS', 'value' => null, 'paramName' => null];
        } else {
            $clone->where[] = ['column' => $column, 'op' => '=', 'value' => $clone->coerceFilterValue($column, $value), 'paramName' => $paramName];
        }

        return $clone;
    }

    public function lt(string $column, int|float|string $value): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);
        $clone->where[] = ['column' => $column, 'op' => '<', 'value' => $clone->coerceFilterValue($column, $value), 'paramName' => $paramName];
        return $clone;
    }

    public function lte(string $column, int|float|string $value): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);
        $clone->where[] = ['column' => $column, 'op' => '<=', 'value' => $clone->coerceFilterValue($column, $value), 'paramName' => $paramName];
        return $clone;
    }

    public function gt(string $column, int|float|string $value): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);
        $clone->where[] = ['column' => $column, 'op' => '>', 'value' => $clone->coerceFilterValue($column, $value), 'paramName' => $paramName];
        return $clone;
    }

    public function gte(string $column, int|float|string $value): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);
        $clone->where[] = ['column' => $column, 'op' => '>=', 'value' => $clone->coerceFilterValue($column, $value), 'paramName' => $paramName];
        return $clone;
    }

    public function in(string $column, SetInterface $values): TableInterface
    {
        // Materialize the set values
        $members = [];
        foreach ($values as $row) {
            $cols = array_keys($values->getColumns());
            if (count($cols) === 1) {
                $members[] = $row->{$cols[0]};
            }
        }

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

        $clone = clone $this;
        $placeholders = [];
        $inParams = [];

        foreach ($members as $i => $member) {
            $paramName = ':p' . (++$clone->paramCounter);
            $placeholders[] = $paramName;
            $inParams[$paramName] = $member;
        }

        $clone->where[] = [
            'column' => $column,
            'op' => 'IN',
            'value' => $inParams,
            'paramName' => $placeholders,
        ];

        return $clone;
    }

    public function like(string $column, string $pattern): TableInterface
    {
        $clone = clone $this;
        $paramName = ':p' . (++$clone->paramCounter);
        $clone->where[] = ['column' => $column, 'op' => 'LIKE', 'value' => $pattern, 'paramName' => $paramName];
        return $clone;
    }

    // =========================================================================
    // Ordering
    // =========================================================================

    public function order(?string $spec): TableInterface
    {
        $orders = $spec ? OrderDef::parse($spec) : [];

        $clone = clone $this;
        $clone->orderBy = $orders;
        return $clone;
    }

    // =========================================================================
    // Set operations - implemented with SQL for same-DB tables
    // =========================================================================

    public function union(TableInterface $other): TableInterface
    {
        // For InMemoryTable with same DB, use SQL UNION
        if ($other instanceof self && $other->db === $this->db && $other->tableName === $this->tableName) {
            return $this->sqlUnion($other);
        }

        // Fall back to wrapper for different table types
        return new UnionTable($this, $other);
    }

    public function except(SetInterface $other): TableInterface
    {
        // For InMemoryTable with same DB, use SQL NOT IN
        if ($other instanceof self && $other->db === $this->db && $other->tableName === $this->tableName) {
            return $this->sqlExcept($other);
        }

        // Fall back to wrapper for different table types
        return new ExceptTable($this, $other);
    }

    public function or(Predicate $a, Predicate $b, Predicate ...$more): TableInterface
    {
        $allPredicates = [$a, $b, ...$more];

        // Filter out empty predicates (they match nothing)
        $predicates = array_values(array_filter(
            $allPredicates,
            fn($p) => !$p->isEmpty()
        ));

        // No predicates → nothing matches
        if (empty($predicates)) {
            return EmptyTable::from($this);
        }

        // If any predicate matches everything, OR is redundant
        foreach ($predicates as $p) {
            if ($p->isEmpty()) {
                continue; // Already filtered above, but just in case
            }
            // An empty predicate with no conditions would match everything,
            // but we filter those out above
        }

        $clone = clone $this;

        // Keep existing WHERE conditions (they will be ANDed with OR groups)
        // Only initialize orGroups if needed
        if (empty($clone->orGroups)) {
            $clone->orGroups = [];
        }

        // Extract conditions from each predicate and add as OR groups
        $unhandledPredicates = [];
        foreach ($predicates as $predicate) {
            $conditions = $this->extractPredicateConditions($predicate, $clone);
            if (!empty($conditions)) {
                $clone->orGroups[] = $conditions;
            } elseif (!$predicate->isBound()) {
                // Has unbound parameters - can't push to SQL
                $unhandledPredicates[] = $predicate;
            }
        }

        // If any predicates couldn't be converted to SQL, fall back to OrTable
        if (!empty($unhandledPredicates)) {
            return new OrTable($this, ...$predicates);
        }

        return $clone;
    }

    /**
     * Create SQL UNION of two InMemoryTable queries on same DB
     */
    private function sqlUnion(self $other): self
    {
        // Create a clone that will use UNION in its query
        $clone = clone $this;

        // Combine conditions: (this WHERE) OR (other WHERE)
        $thisConditions = !empty($this->orGroups) ? $this->orGroups : ($this->where ? [$this->where] : []);
        $otherConditions = !empty($other->orGroups) ? $other->orGroups : ($other->where ? [$other->where] : []);

        // If either has no conditions, it matches everything
        if (empty($thisConditions) && empty($otherConditions)) {
            $clone->orGroups = [];
            $clone->where = [];
        } elseif (empty($thisConditions)) {
            // this matches everything, union is everything
            $clone->orGroups = [];
            $clone->where = [];
        } elseif (empty($otherConditions)) {
            // other matches everything, union is everything
            $clone->orGroups = [];
            $clone->where = [];
        } else {
            // Renumber other's parameters to avoid collision
            $otherConditions = $this->renumberConditions($otherConditions, $clone);

            // Merge OR groups
            $clone->orGroups = [...$thisConditions, ...$otherConditions];
            $clone->where = [];
        }

        // Clear ordering (union results need fresh ordering)
        $clone->orderBy = [];
        $clone->limit = null;
        $clone->offset = 0;

        return $clone;
    }

    /**
     * Renumber parameter names in condition groups to avoid collision
     */
    private function renumberConditions(array $groups, self $clone): array
    {
        $result = [];
        foreach ($groups as $group) {
            $newGroup = [];
            foreach ($group as $filter) {
                $newFilter = $filter;

                if ($filter['op'] === 'IN' && is_array($filter['paramName'])) {
                    // Renumber IN parameters
                    $newPlaceholders = [];
                    $newParams = [];
                    foreach ($filter['value'] as $oldName => $val) {
                        $newName = ':p' . (++$clone->paramCounter);
                        $newPlaceholders[] = $newName;
                        $newParams[$newName] = $val;
                    }
                    $newFilter['paramName'] = $newPlaceholders;
                    $newFilter['value'] = $newParams;
                } elseif ($filter['paramName'] !== null && $filter['op'] !== 'NOT_IN_SUBQUERY') {
                    // Renumber simple parameter
                    $newName = ':p' . (++$clone->paramCounter);
                    $newFilter['paramName'] = $newName;
                }

                $newGroup[] = $newFilter;
            }
            $result[] = $newGroup;
        }
        return $result;
    }

    /**
     * Create SQL EXCEPT (NOT IN) of two InMemoryTable queries on same DB
     */
    private function sqlExcept(self $other): self
    {
        $clone = clone $this;

        // Build the exclusion condition: _rowid_ NOT IN (SELECT _rowid_ FROM ... WHERE other_conditions)
        [$otherWhere, $otherParams] = $other->buildWhereClause();

        // Renumber params from $other to avoid collision with $clone's params
        $renamedParams = [];
        $renameMap = [];
        foreach ($otherParams as $oldName => $value) {
            $newName = ':p' . (++$clone->paramCounter);
            $renamedParams[$newName] = $value;
            $renameMap[$oldName] = $newName;
        }

        // Apply renames to the where clause (handle longest names first to avoid partial replacement)
        $sortedOldNames = array_keys($renameMap);
        usort($sortedOldNames, fn($a, $b) => strlen($b) - strlen($a));
        foreach ($sortedOldNames as $oldName) {
            $otherWhere = str_replace($oldName, $renameMap[$oldName], $otherWhere);
        }

        $subquery = "SELECT _rowid_ FROM {$this->tableName}";
        if ($otherWhere) {
            $subquery .= " WHERE $otherWhere";
        }

        // Add as a special NOT IN condition
        $clone->where[] = [
            'column' => '_rowid_',
            'op' => 'NOT_IN_SUBQUERY',
            'value' => $renamedParams,
            'paramName' => $subquery,
        ];

        return $clone;
    }

    /**
     * Extract filter conditions from a Predicate
     */
    private function extractPredicateConditions(Predicate $predicate, self $clone): array
    {
        $conditions = [];

        foreach ($predicate->getConditions() as $cond) {
            $col = $cond['column'];
            $op = $cond['operator'];
            $val = $cond['value'];

            $paramName = ':p' . (++$clone->paramCounter);

            $condition = match ($op) {
                Operator::Eq => $val === null
                    ? ['column' => $col, 'op' => 'IS', 'value' => null, 'paramName' => null]
                    : ['column' => $col, 'op' => '=', 'value' => $val, 'paramName' => $paramName],
                Operator::Lt => ['column' => $col, 'op' => '<', 'value' => $val, 'paramName' => $paramName],
                Operator::Lte => ['column' => $col, 'op' => '<=', 'value' => $val, 'paramName' => $paramName],
                Operator::Gt => ['column' => $col, 'op' => '>', 'value' => $val, 'paramName' => $paramName],
                Operator::Gte => ['column' => $col, 'op' => '>=', 'value' => $val, 'paramName' => $paramName],
                Operator::Like => ['column' => $col, 'op' => 'LIKE', 'value' => $val, 'paramName' => $paramName],
                Operator::In => $this->buildInCondition($col, $val, $clone),
            };

            $conditions[] = $condition;
        }

        return $conditions;
    }

    /**
     * Build IN condition from SetInterface
     */
    private function buildInCondition(string $column, SetInterface $values, self $clone): array
    {
        $members = [];
        foreach ($values as $row) {
            $cols = array_keys($values->getColumns());
            if (count($cols) === 1) {
                $members[] = $row->{$cols[0]};
            }
        }

        $placeholders = [];
        $inParams = [];
        foreach ($members as $member) {
            $paramName = ':p' . (++$clone->paramCounter);
            $placeholders[] = $paramName;
            $inParams[$paramName] = $member;
        }

        return [
            'column' => $column,
            'op' => 'IN',
            'value' => $inParams,
            'paramName' => $placeholders,
        ];
    }

    // =========================================================================
    // Query building
    // =========================================================================

    /**
     * Build WHERE clause and parameter bindings
     *
     * @return array{string, array<string, mixed>} [SQL fragment, parameters]
     */
    private function buildWhereClause(): array
    {
        $parts = [];
        $params = [];

        // Build AND conditions from WHERE
        if (!empty($this->where)) {
            [$whereSql, $whereParams] = $this->buildConditionGroup($this->where);
            if ($whereSql) {
                $parts[] = $whereSql;
                $params = array_merge($params, $whereParams);
            }
        }

        // Build OR groups and AND them with WHERE
        if (!empty($this->orGroups)) {
            $groups = [];

            foreach ($this->orGroups as $group) {
                [$groupSql, $groupParams] = $this->buildConditionGroup($group);
                if ($groupSql) {
                    $groups[] = "($groupSql)";
                    $params = array_merge($params, $groupParams);
                }
            }

            if (!empty($groups)) {
                // Wrap OR groups in parentheses
                $orClause = implode(' OR ', $groups);
                $parts[] = "($orClause)";
            }
        }

        if (empty($parts)) {
            return ['', []];
        }

        return [implode(' AND ', $parts), $params];
    }

    /**
     * Build a group of AND conditions
     *
     * @return array{string, array<string, mixed>}
     */
    private function buildConditionGroup(array $filters): array
    {
        if (empty($filters)) {
            return ['', []];
        }

        $conditions = [];
        $params = [];

        foreach ($filters as $filter) {
            $col = $this->quoteIdentifier($filter['column']);

            if ($filter['op'] === 'IS') {
                $conditions[] = "$col IS NULL";
            } elseif ($filter['op'] === 'IN') {
                $conditions[] = "$col IN (" . implode(', ', $filter['paramName']) . ')';
                foreach ($filter['value'] as $name => $val) {
                    $params[$name] = $val;
                }
            } elseif ($filter['op'] === 'NOT_IN_SUBQUERY') {
                // Special case: NOT IN with subquery
                $conditions[] = "$col NOT IN ({$filter['paramName']})";
                foreach ($filter['value'] as $name => $val) {
                    $params[$name] = $val;
                }
            } else {
                $conditions[] = "$col {$filter['op']} {$filter['paramName']}";
                $params[$filter['paramName']] = $filter['value'];
            }
        }

        return [implode(' AND ', $conditions), $params];
    }

    /**
     * Build ORDER BY clause
     */
    private function buildOrderByClause(): string
    {
        if (empty($this->orderBy)) {
            return '';
        }

        $parts = [];
        foreach ($this->orderBy as $order) {
            $col = $this->quoteIdentifier($order->column);
            $dir = $order->asc ? 'ASC' : 'DESC';
            $parts[] = "$col $dir";
        }

        return ' ORDER BY ' . implode(', ', $parts);
    }

    /**
     * Build LIMIT/OFFSET clause
     */
    private function buildLimitClause(): string
    {
        $sql = '';

        if ($this->limit !== null) {
            $sql .= ' LIMIT ' . $this->limit;
        }

        if ($this->offset > 0) {
            if ($this->limit === null) {
                $sql .= ' LIMIT -1';  // SQLite requires LIMIT with OFFSET
            }
            $sql .= ' OFFSET ' . $this->offset;
        }

        return $sql;
    }

    // =========================================================================
    // Materialization
    // =========================================================================

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

        // Use _rowid_ with alias to avoid conflict with INTEGER PRIMARY KEY columns.
        // NOTE: __rowid__ is an internal alias. Do not be an asshole and destroy my library
        // by using it as a column name, forcing me to make my implementation slightly slower
        // just to protect against assholes.
        $selectList = '_rowid_ AS __rowid__, ' . implode(', ', array_map(
            fn($c) => $this->quoteIdentifier($c),
            $selectCols
        ));

        $sql = "SELECT $selectList FROM {$this->tableName}";

        [$whereSql, $whereParams] = $this->buildWhereClause();
        if ($whereSql) {
            $sql .= ' WHERE ' . $whereSql;
        }

        $sql .= $this->buildOrderByClause();
        $sql .= $this->buildLimitClause();

        //echo spl_object_id($this) . ': ' . $sql . "\n";
        $stmt = $this->db->prepare($sql);
        foreach ($whereParams as $name => $value) {
            $stmt->bindValue($name, $value);
        }

        $result = $stmt->execute();
        $columnDefs = $this->getColumns();

        while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
            $rowid = $row['__rowid__'];
            unset($row['__rowid__']);

            // Format Decimal columns to proper scale
            $this->formatDecimalColumns($row, $columnDefs);

            yield $rowid => (object) $row;
        }
    }

    /**
     * Format Decimal column values from scaled integer to decimal string
     *
     * E.g., 999 with scale 2 → "9.99"
     */
    private function formatDecimalColumns(array &$row, array $columnDefs): void
    {
        foreach ($row as $col => &$value) {
            if ($value === null) {
                continue;
            }
            $colDef = $columnDefs[$col] ?? null;
            if ($colDef !== null && $colDef->type === ColumnType::Decimal) {
                $scale = $colDef->getScale();
                // Convert scaled integer back to decimal string
                $unscaled = (string) $value;
                if ($scale === 0) {
                    $value = $unscaled;
                } else {
                    // Pad with leading zeros if needed
                    $unscaled = str_pad($unscaled, $scale + 1, '0', STR_PAD_LEFT);
                    $intPart = substr($unscaled, 0, -$scale);
                    $fracPart = substr($unscaled, -$scale);
                    $value = $intPart . '.' . $fracPart;
                }
            }
        }
    }

    public function load(string|int $rowId): ?object
    {
        $visibleCols = array_keys($this->getColumns());
        $selectList = implode(', ', array_map(
            fn($c) => $this->quoteIdentifier($c),
            $visibleCols
        ));

        $sql = "SELECT $selectList FROM {$this->tableName} WHERE _rowid_ = :rowid";

        [$whereSql, $whereParams] = $this->buildWhereClause();
        if ($whereSql) {
            $sql = "SELECT $selectList FROM {$this->tableName} WHERE _rowid_ = :rowid AND ($whereSql)";
        }

        $stmt = $this->db->prepare($sql);
        $stmt->bindValue(':rowid', $rowId);
        foreach ($whereParams as $name => $value) {
            $stmt->bindValue($name, $value);
        }

        $result = $stmt->execute();
        $row = $result->fetchArray(SQLITE3_ASSOC);

        return $row ? (object) $row : null;
    }

    public function count(): int
    {
        $sql = "SELECT COUNT(*) FROM {$this->tableName}";

        [$whereSql, $whereParams] = $this->buildWhereClause();
        if ($whereSql) {
            $sql .= ' WHERE ' . $whereSql;
        }

        // Apply limit/offset to count via subquery if needed
        if ($this->limit !== null || $this->offset > 0) {
            $innerSql = "SELECT 1 FROM {$this->tableName}";
            if ($whereSql) {
                $innerSql .= ' WHERE ' . $whereSql;
            }
            $innerSql .= $this->buildLimitClause();
            $sql = "SELECT COUNT(*) FROM ($innerSql)";
        }

        $stmt = $this->db->prepare($sql);
        foreach ($whereParams as $name => $value) {
            $stmt->bindValue($name, $value);
        }

        $result = $stmt->execute();
        $row = $result->fetchArray(SQLITE3_NUM);

        return (int) $row[0];
    }
}