<?php
namespace ParagonIE\EasyDB;
use ParagonIE\EasyDB\Exception\{
    MustBeEmpty,
    MustBeNonEmpty
};
use RuntimeException;
use TypeError;
use function
    array_merge,
    array_reduce,
    count,
    is_object,
    is_string,
    sprintf,
    str_repeat,
    str_replace,
    trim;
/**
 * Class EasyStatement
 * @package ParagonIE\EasyDB
 */
class EasyStatement
{
    /**
     * @var array<int, array{type:string, condition:self|string, values?:array<int, mixed>}> $parts
     */
    private array $parts = [];
    private ?EasyStatement $parent;
    private bool $allowEmptyInStatements = false;
    public function count(): int
    {
        return count($this->parts);
    }
    /**
     * Open a new statement.
     *
     * @return self
     * @psalm-suppress UnsafeInstantiation
     */
    public static function open(): self
    {
        return new static();
    }
    /**
     * @param bool $allow
     * @return self
     */
    public function setEmptyInStatementsAllowed(bool $allow = false): self
    {
        $this->allowEmptyInStatements = $allow;
        return $this;
    }
    /**
     * Alias for andWith().
     *
     * @param EasyStatement|string $condition
     * @param mixed ...$values
     * @return self
     * @psalm-taint-sink sql $condition
     */
    public function with(EasyStatement|string $condition, ...$values): self
    {
        return $this->andWith($condition, ...$values);
    }
    /**
     * Add a condition that will be applied with a logical "AND".
     *
     * @param string|self $condition
     * @param mixed ...$values
     * @return self
     *
     * @psalm-taint-sink sql $condition
     *
     * @throws MustBeEmpty
     */
    public function andWith(EasyStatement|string $condition, ...$values): self
    {
        if ($condition instanceof EasyStatement) {
            if (!empty($values)) {
                throw new MustBeEmpty("EasyStatement provided; must be only argument.");
            }
            $values = $condition->values();
            $condition = '(' . $condition . ')';
        }
        return $this->andWithString($condition, ...$values);
    }
    /**
     * Add a condition that will be applied with a logical "AND".
     *
     * @param string $condition
     * @param mixed ...$values
     *
     * @return self
     *
     * @psalm-taint-sink sql $condition
     */
    public function andWithString(string $condition, ...$values): self
    {
        $this->parts[] = [
            'type' => 'AND',
            'condition' => $condition,
            'values' => $values,
        ];
        return $this;
    }
    /**
     * Add a condition that will be applied with a logical "OR".
     *
     * @param string|self $condition
     * @param mixed ...$values
     * @return self
     *
     * @psalm-taint-sink sql $condition
     */
    public function orWith(EasyStatement|string $condition, ...$values): self
    {
        if ($condition instanceof EasyStatement) {
            if (!empty($values)) {
                throw new MustBeEmpty("EasyStatement provided; must be only argument.");
            }
            $values = $condition->values();
            $condition = '(' . $condition . ')';
        }
        return $this->orWithString($condition, ...$values);
    }
    /**
     * Add a condition that will be applied with a logical "OR".
     *
     * @param string $condition
     * @param mixed ...$values
     *
     * @return self
     *
     * @psalm-taint-sink sql $condition
     */
    public function orWithString(string $condition, ...$values): self
    {
        $this->parts[] = [
            'type' => 'OR',
            'condition' => $condition,
            'values' => $values,
        ];
        return $this;
    }
    /**
     * Alias for andIn().
     *
     * @param string $condition
     * @param array $values
     *
     * @return self
     * @throws MustBeNonEmpty
     *
     * @psalm-taint-sink sql $condition
     */
    public function in(string $condition, array $values): self
    {
        return $this->andIn($condition, $values);
    }
    /**
     * Add an IN condition that will be applied with a logical "AND".
     *
     * Instead of using ? to denote the placeholder, ?* must be used!
     *
     * @param string $condition
     * @param array $values
     *
     * @return self
     *
     * @throws MustBeNonEmpty
     * @throws RuntimeException
     * @throws TypeError
     *
     * @psalm-taint-sink sql $condition
     */
    public function andIn(string $condition, array $values): self
    {
        if (count($values) < 1) {
            if (!$this->allowEmptyInStatements) {
                throw new MustBeNonEmpty();
            }
            // Add a closed failure:
            $this->parts[] = [
                'type' => 'AND',
                'condition' => '1 = 0',
                'values' => []
            ];
            return $this;
        }
        try {
            return $this->andWith(
                $this->unpackCondition($condition, count($values)),
                ...$values
            );
        } catch (MustBeEmpty $ex) {
            throw new RuntimeException("Invalid state reached", 0, $ex);
        }
    }
    /**
     * Add an IN condition that will be applied with a logical "OR".
     *
     * Instead of using "?" to denote the placeholder, "?*" must be used!
     *
     * @param string $condition
     * @param array $values
     * @return self
     *
     * @throws MustBeNonEmpty
     *
     * @psalm-taint-sink sql $condition
     */
    public function orIn(string $condition, array $values): self
    {
        if (count($values) < 1) {
            if (!$this->allowEmptyInStatements) {
                throw new MustBeNonEmpty();
            }
            return $this;
        }
        try {
            return $this->orWith(
                $this->unpackCondition($condition, count($values)),
                ...$values
            );
        } catch (MustBeEmpty $ex) {
            throw new RuntimeException("Invalid state reached", 0, $ex);
        }
    }
    /**
     * Alias for andGroup().
     *
     * @return self
     */
    public function group(): self
    {
        return $this->andGroup();
    }
    /**
     * Start a new grouping that will be applied with a logical "AND".
     *
     * Exit the group with endGroup().
     *
     * @return self
     */
    public function andGroup(): self
    {
        $group = new self($this);
        $group->setEmptyInStatementsAllowed($this->allowEmptyInStatements);
        $this->parts[] = [
            'type' => 'AND',
            'condition' => $group,
        ];
        return $group;
    }
    /**
     * Start a new grouping that will be applied with a logical "OR".
     *
     * Exit the group with endGroup().
     *
     * @return self
     */
    public function orGroup(): self
    {
        $group = new self($this);
        $group->setEmptyInStatementsAllowed($this->allowEmptyInStatements);
        $this->parts[] = [
            'type' => 'OR',
            'condition' => $group,
        ];
        return $group;
    }
    /**
     * Alias for endGroup().
     *
     * @return self
     */
    public function end(): self
    {
        return $this->endGroup();
    }
    /**
     * Exit the current grouping and return the parent statement.
     *
     * @return self
     *
     * @throws RuntimeException
     *  If the current statement has no parent context.
     */
    public function endGroup(): self
    {
        if (empty($this->parent)) {
            throw new RuntimeException('Already at the top of the statement');
        }
        return $this->parent;
    }
    /**
     * Compile the current statement into PDO-ready SQL.
     *
     * @return string
     */
    public function sql(): string
    {
        if (empty($this->parts)) {
            return '1 = 1';
        }
        return array_reduce(
            $this->parts,
            /**
             * @psalm-param array{type:string, condition:self|string, values?:array<int, mixed>} $part
             */
            function (string $sql, array $part): string {
                /** @var string|self $condition */
                $condition = $part['condition'];
                if ($this->isGroup($condition)) {
                    // (...)
                    if (is_string($condition)) {
                        $statement = '(' . $condition . ')';
                    } else {
                        $statement = '(' . $condition->sql() . ')';
                    }
                } else {
                    // foo = ?
                    $statement = $condition;
                }
                /** @var string $statement */
                $statement = (string) $statement;
                $part['type'] = (string) $part['type'];
                if ($sql) {
                    $statement = match ($part['type']) {
                        'AND', 'OR' => $part['type'] . ' ' . $statement,
                        default => throw new RuntimeException(
                            sprintf('Invalid joiner %s', $part['type'])
                        ),
                    };
                }
                /** @psalm-taint-sink sql */
                return trim($sql . ' ' . $statement);
            },
            ''
        );
    }
    /**
     * Get the parameters attached to this statement.
     *
     * @return array
     */
    public function values(): array
    {
        return (array) array_reduce(
            $this->parts,
            /**
             * @psalm-param array{type:string, condition:self|string, values?:array<int, mixed>} $part
             */
            function (array $values, array $part): array {
                if ($this->isGroup($part['condition'])) {
                    /** @var EasyStatement $condition */
                    $condition = $part['condition'];
                    return array_merge(
                        $values,
                        $condition->values()
                    );
                } elseif (!isset($part['values'])) {
                    return $values;
                }
                return array_merge($values, $part['values']);
            },
            []
        );
    }
    /**
     * Convert the statement to a string.
     *
     * @return string
     */
    public function __toString(): string
    {
        return $this->sql();
    }
    /**
     * Don't instantiate directly. Instead, use open() (static method).
     *
     * EasyStatement constructor.
     * @param EasyStatement|null $parent
     */
    protected function __construct(?EasyStatement $parent = null)
    {
        $this->parent = $parent;
    }
    /**
     * Check if a condition is a sub-group.
     *
     * @param mixed $condition
     *
     * @return bool
     */
    protected function isGroup(mixed $condition): bool
    {
        if (!is_object($condition)) {
            return false;
        }
        return $condition instanceof EasyStatement;
    }
    /**
     * Replace a grouped placeholder with a list of placeholders.
     *
     * Given a count of 3, the placeholder ?* will become ?, ?, ?
     *
     * @param string $condition
     * @param int $count
     *
     * @return string
     *
     * @psalm-taint-sink sql $condition
     */
    private function unpackCondition(string $condition, int $count): string
    {
        // Replace a grouped placeholder with an matching count of placeholders.
        $params = '?' . str_repeat(', ?', $count - 1);
        return str_replace('?*', $params, $condition);
    }
}
 
  |