diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..bc52bcf7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,19 @@ +FROM php:8.2-cli + +RUN apt-get update && apt-get install -y \ + git \ + curl \ + wget \ + unzip \ + zip \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN docker-php-ext-install \ + pcntl \ + sockets + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +ENV COMPOSER_ALLOW_SUPERUSER=1 +ENV COMPOSER_HOME=/composer diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..75d65d8a --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,23 @@ +{ + "name": "PHP", + "build": { + "dockerfile": "./Dockerfile", + "context": ".." + }, + "customizations": { + "vscode": { + "extensions": [ + "bmewburn.vscode-intelephense-client", + "xdebug.php-pack", + "devsense.phptools-vscode", + "mehedidracula.php-namespace-resolver", + "devsense.composer-php-vscode", + "phiter.phpstorm-snippets" + ] + } + }, + "forwardPorts": [ + 8080 + ], + "remoteUser": "root" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..f33a02cd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for more information: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# https://containers.dev/guide/dependabot + +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: weekly diff --git a/composer.json b/composer.json index 836b55eb..8eb5399d 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "require": { "php": "^8.2", "ext-pcntl": "*", + "ext-sockets": "*", "adbario/php-dot-notation": "^3.1", + "ahjdev/amphp-sqlite3": "dev-main", "amphp/cache": "^2.0", "amphp/cluster": "^2.0", "amphp/file": "^v3.0.0", diff --git a/src/Database/Clause.php b/src/Database/Clause.php index 70953836..6e8e8551 100644 --- a/src/Database/Clause.php +++ b/src/Database/Clause.php @@ -21,6 +21,7 @@ abstract class Clause extends Grammar implements Builder use PrepareColumns; protected array $clauses; + protected array $arguments; protected function resolveWhereMethod( diff --git a/src/Database/Concerns/Query/BuildsQuery.php b/src/Database/Concerns/Query/BuildsQuery.php index 2df6f7fe..bfa4ff56 100644 --- a/src/Database/Concerns/Query/BuildsQuery.php +++ b/src/Database/Concerns/Query/BuildsQuery.php @@ -9,23 +9,17 @@ use Phenix\Database\Constants\Operator; use Phenix\Database\Constants\Order; use Phenix\Database\Constants\SQL; +use Phenix\Database\Dialects\DialectFactory; use Phenix\Database\Functions; use Phenix\Database\Having; +use Phenix\Database\QueryAst; use Phenix\Database\SelectCase; use Phenix\Database\Subquery; use Phenix\Database\Value; use Phenix\Util\Arr; -use function array_is_list; -use function array_keys; -use function array_unique; -use function array_values; -use function ksort; - trait BuildsQuery { - use HasLock; - public function table(string $table): static { $this->table = $table; @@ -70,74 +64,7 @@ public function selectAllColumns(): static return $this; } - public function insert(array $data): static - { - $this->action = Action::INSERT; - - $this->prepareDataToInsert($data); - - return $this; - } - - public function insertOrIgnore(array $values): static - { - $this->ignore = true; - - $this->insert($values); - - return $this; - } - - public function upsert(array $values, array $columns): static - { - $this->action = Action::INSERT; - - $this->uniqueColumns = $columns; - - $this->prepareDataToInsert($values); - - return $this; - } - - public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): static - { - $builder = new Subquery($this->driver); - $builder->selectAllColumns(); - - $subquery($builder); - - [$dml, $arguments] = $builder->toSql(); - - $this->rawStatement = trim($dml, '()'); - - $this->arguments = array_merge($this->arguments, $arguments); - - $this->action = Action::INSERT; - - $this->ignore = $ignore; - - $this->columns = $columns; - - return $this; - } - - public function update(array $values): static - { - $this->action = Action::UPDATE; - - $this->values = $values; - - return $this; - } - - public function delete(): static - { - $this->action = Action::DELETE; - - return $this; - } - - public function groupBy(Functions|array|string $column) + public function groupBy(Functions|array|string $column): static { $column = match (true) { $column instanceof Functions => (string) $column, @@ -164,7 +91,7 @@ public function having(Closure $clause): static return $this; } - public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC) + public function orderBy(SelectCase|array|string $column, Order $order = Order::DESC): static { $column = match (true) { $column instanceof SelectCase => '(' . $column . ')', @@ -196,50 +123,38 @@ public function page(int $page = 1, int $perPage = 15): static return $this; } - public function count(string $column = '*'): static - { - $this->action = Action::SELECT; - - $this->columns = [Functions::count($column)]; - - return $this; - } - - public function exists(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::EXISTS->value]; - - return $this; - } - - public function doesntExist(): static - { - $this->action = Action::EXISTS; - - $this->columns = [Operator::NOT_EXISTS->value]; - - return $this; - } - /** - * @return array + * @return array{0: string, 1: array} */ public function toSql(): array { - $sql = match ($this->action) { - Action::SELECT => $this->buildSelectQuery(), - Action::EXISTS => $this->buildExistsQuery(), - Action::INSERT => $this->buildInsertSentence(), - Action::UPDATE => $this->buildUpdateSentence(), - Action::DELETE => $this->buildDeleteSentence(), - }; + $ast = $this->buildAst(); + $dialect = DialectFactory::fromDriver($this->driver); - return [ - $sql, - $this->arguments, - ]; + return $dialect->compile($ast); + } + + protected function buildAst(): QueryAst + { + $ast = new QueryAst(); + $ast->action = $this->action; + $ast->table = $this->table; + $ast->columns = $this->columns; + $ast->values = $this->values ?? []; + $ast->wheres = $this->clauses ?? []; + $ast->joins = $this->joins ?? []; + $ast->groups = $this->groupBy ?? []; + $ast->orders = $this->orderBy ?? []; + $ast->limit = isset($this->limit) ? $this->limit[1] : null; + $ast->offset = isset($this->offset) ? $this->offset[1] : null; + $ast->lock = $this->lockType ?? null; + $ast->having = $this->having ?? null; + $ast->rawStatement = $this->rawStatement ?? null; + $ast->ignore = $this->ignore ?? false; + $ast->uniqueColumns = $this->uniqueColumns ?? []; + $ast->params = $this->arguments; + + return $ast; } protected function buildSelectQuery(): string @@ -304,25 +219,6 @@ protected function buildExistsQuery(): string return Arr::implodeDeeply($query); } - private function prepareDataToInsert(array $data): void - { - if (array_is_list($data)) { - foreach ($data as $record) { - $this->prepareDataToInsert($record); - } - - return; - } - - ksort($data); - - $this->columns = array_unique([...$this->columns, ...array_keys($data)]); - - $this->arguments = \array_merge($this->arguments, array_values($data)); - - $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); - } - private function buildInsertSentence(): string { $dml = [ diff --git a/src/Database/Concerns/Query/HasSentences.php b/src/Database/Concerns/Query/HasSentences.php deleted file mode 100644 index f667e701..00000000 --- a/src/Database/Concerns/Query/HasSentences.php +++ /dev/null @@ -1,200 +0,0 @@ -action = Action::SELECT; - - $query = Query::fromUri($uri); - - $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); - $currentPage = $currentPage === false ? $defaultPage : $currentPage; - - $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); - $perPage = $perPage === false ? $defaultPerPage : $perPage; - - $countQuery = clone $this; - - $total = $countQuery->count(); - - $data = $this->page((int) $currentPage, (int) $perPage)->get(); - - return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); - } - - public function count(string $column = '*'): int - { - $this->action = Action::SELECT; - - $this->countRows($column); - - [$dml, $params] = $this->toSql(); - - /** @var array $count */ - $count = $this->exec($dml, $params)->fetchRow(); - - return array_values($count)[0]; - } - - public function insert(array $data): bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function insertRow(array $data): int|string|bool - { - [$dml, $params] = $this->insertRows($data)->toSql(); - - try { - /** @var MysqlPooledResult $result */ - $result = $this->exec($dml, $params); - - return $result->getLastInsertId(); - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function exists(): bool - { - $this->action = Action::EXISTS; - - $this->existsRows(); - - [$dml, $params] = $this->toSql(); - - $results = $this->exec($dml, $params)->fetchRow(); - - return (bool) array_values($results)[0]; - } - - public function doesntExist(): bool - { - return ! $this->exists(); - } - - public function update(array $values): bool - { - $this->updateRow($values); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function delete(): bool - { - $this->deleteRows(); - - [$dml, $params] = $this->toSql(); - - try { - $this->exec($dml, $params); - - return true; - } catch (SqlQueryError|SqlTransactionError $e) { - report($e); - - return false; - } - } - - public function transaction(Closure $callback): mixed - { - /** @var SqlTransaction $transaction */ - $transaction = $this->connection->beginTransaction(); - - $this->transaction = $transaction; - - try { - $result = $callback($this); - - $transaction->commit(); - - unset($this->transaction); - - return $result; - } catch (Throwable $e) { - report($e); - - $transaction->rollBack(); - - unset($this->transaction); - - throw $e; - } - } - - public function beginTransaction(): SqlTransaction - { - $this->transaction = $this->connection->beginTransaction(); - - return $this->transaction; - } - - public function commit(): void - { - if ($this->transaction) { - $this->transaction->commit(); - $this->transaction = null; - } - } - - public function rollBack(): void - { - if ($this->transaction) { - $this->transaction->rollBack(); - $this->transaction = null; - } - } - - public function hasActiveTransaction(): bool - { - return isset($this->transaction) && $this->transaction !== null; - } - - protected function exec(string $dml, array $params = []): mixed - { - $executor = $this->hasActiveTransaction() ? $this->transaction : $this->connection; - - return $executor->prepare($dml)->execute($params); - } -} diff --git a/src/Database/Concerns/Query/HasTransaction.php b/src/Database/Concerns/Query/HasTransaction.php new file mode 100644 index 00000000..359b0690 --- /dev/null +++ b/src/Database/Concerns/Query/HasTransaction.php @@ -0,0 +1,75 @@ +connection->beginTransaction(); + + $this->transaction = $transaction; + + try { + $result = $callback($this); + + $transaction->commit(); + + unset($this->transaction); + + return $result; + } catch (Throwable $e) { + report($e); + + $transaction->rollBack(); + + unset($this->transaction); + + throw $e; + } + } + + public function beginTransaction(): SqlTransaction + { + $this->transaction = $this->connection->beginTransaction(); + + return $this->transaction; + } + + public function commit(): void + { + if ($this->transaction) { + $this->transaction->commit(); + $this->transaction = null; + } + } + + public function rollBack(): void + { + if ($this->transaction) { + $this->transaction->rollBack(); + $this->transaction = null; + } + } + + public function hasActiveTransaction(): bool + { + return isset($this->transaction) && $this->transaction !== null; + } + + protected function exec(string $dml, array $params = []): mixed + { + $executor = $this->hasActiveTransaction() ? $this->transaction : $this->connection; + + return $executor->prepare($dml)->execute($params); + } +} diff --git a/src/Database/Connections/ConnectionFactory.php b/src/Database/Connections/ConnectionFactory.php index 611f20e0..94e7ce4f 100644 --- a/src/Database/Connections/ConnectionFactory.php +++ b/src/Database/Connections/ConnectionFactory.php @@ -8,13 +8,14 @@ use Amp\Mysql\MysqlConnectionPool; use Amp\Postgres\PostgresConfig; use Amp\Postgres\PostgresConnectionPool; +use Amp\SQLite3\SQLite3WorkerConnection; use Closure; -use InvalidArgumentException; use Phenix\Database\Constants\Driver; use Phenix\Redis\ClientWrapper; use SensitiveParameter; use function Amp\Redis\createRedisClient; +use function Amp\SQLite3\connect; use function sprintf; class ConnectionFactory @@ -25,12 +26,15 @@ public static function make(Driver $driver, #[SensitiveParameter] array $setting Driver::MYSQL => self::createMySqlConnection($settings), Driver::POSTGRESQL => self::createPostgreSqlConnection($settings), Driver::REDIS => self::createRedisConnection($settings), - default => throw new InvalidArgumentException( - sprintf('Unsupported driver: %s', $driver->name) - ), + Driver::SQLITE => self::createSqliteConnection($settings), }; } + private static function createSqliteConnection(#[SensitiveParameter] array $settings): Closure + { + return static fn (): SQLite3WorkerConnection => connect($settings['database']); + } + private static function createMySqlConnection(#[SensitiveParameter] array $settings): Closure { return static function () use ($settings): MysqlConnectionPool { diff --git a/src/Database/Dialects/Compilers/DeleteCompiler.php b/src/Database/Dialects/Compilers/DeleteCompiler.php new file mode 100644 index 00000000..22076dbf --- /dev/null +++ b/src/Database/Dialects/Compilers/DeleteCompiler.php @@ -0,0 +1,39 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + + $parts[] = 'DELETE FROM'; + $parts[] = $ast->table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/ExistsCompiler.php b/src/Database/Dialects/Compilers/ExistsCompiler.php new file mode 100644 index 00000000..4360d540 --- /dev/null +++ b/src/Database/Dialects/Compilers/ExistsCompiler.php @@ -0,0 +1,49 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + $parts[] = 'SELECT'; + + $column = ! empty($ast->columns) ? $ast->columns[0] : 'EXISTS'; + $parts[] = $column; + + $subquery = []; + $subquery[] = 'SELECT 1 FROM'; + $subquery[] = $ast->table; + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $subquery[] = 'WHERE'; + $subquery[] = $whereCompiled->sql; + } + + $parts[] = '(' . Arr::implodeDeeply($subquery) . ')'; + $parts[] = 'AS'; + $parts[] = Value::from('exists'); + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } +} diff --git a/src/Database/Dialects/Compilers/InsertCompiler.php b/src/Database/Dialects/Compilers/InsertCompiler.php new file mode 100644 index 00000000..a22bdc95 --- /dev/null +++ b/src/Database/Dialects/Compilers/InsertCompiler.php @@ -0,0 +1,77 @@ +params; + + // INSERT [IGNORE] INTO + $parts[] = $this->compileInsertClause($ast); + + $parts[] = $ast->table; + + // (column1, column2, ...) + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + // VALUES (...), (...) or raw statement + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); + } + + // Dialect-specific UPSERT/ON CONFLICT handling + if (! empty($ast->uniqueColumns)) { + $parts[] = $this->compileUpsert($ast); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } + + protected function compileInsertClause(QueryAst $ast): string + { + if ($ast->ignore) { + return $this->compileInsertIgnore(); + } + + return 'INSERT INTO'; + } + + /** + * MySQL: INSERT IGNORE INTO + * PostgreSQL: INSERT INTO ... ON CONFLICT DO NOTHING (handled in compileUpsert) + * SQLite: INSERT OR IGNORE INTO + * + * @return string INSERT IGNORE clause + */ + abstract protected function compileInsertIgnore(): string; + + /** + * MySQL: ON DUPLICATE KEY UPDATE + * PostgreSQL: ON CONFLICT (...) DO UPDATE SET + * SQLite: ON CONFLICT (...) DO UPDATE SET + * + * @param QueryAst $ast Query AST with uniqueColumns + * @return string UPSERT clause + */ + abstract protected function compileUpsert(QueryAst $ast): string; +} diff --git a/src/Database/Dialects/Compilers/SelectCompiler.php b/src/Database/Dialects/Compilers/SelectCompiler.php new file mode 100644 index 00000000..be623ac6 --- /dev/null +++ b/src/Database/Dialects/Compilers/SelectCompiler.php @@ -0,0 +1,129 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $columns = empty($ast->columns) ? ['*'] : $ast->columns; + + $sql = [ + 'SELECT', + $this->compileColumns($columns, $ast->params), + 'FROM', + $ast->table, + ]; + + if (! empty($ast->joins)) { + $sql[] = $ast->joins; + } + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + if ($whereCompiled->sql !== '') { + $sql[] = 'WHERE'; + $sql[] = $whereCompiled->sql; + } + } + + if ($ast->having !== null) { + $sql[] = $ast->having; + } + + if (! empty($ast->groups)) { + $sql[] = Arr::implodeDeeply($ast->groups); + } + + if (! empty($ast->orders)) { + $sql[] = Arr::implodeDeeply($ast->orders); + } + + if ($ast->limit !== null) { + $sql[] = "LIMIT {$ast->limit}"; + } + + if ($ast->offset !== null) { + $sql[] = "OFFSET {$ast->offset}"; + } + + if ($ast->lock !== null) { + $lockSql = $this->compileLock($ast); + + if ($lockSql !== '') { + $sql[] = $lockSql; + } + } + + return new CompiledClause( + Arr::implodeDeeply($sql), + $ast->params + ); + } + + /** + * @param QueryAst $ast + * @return string + */ + abstract protected function compileLock(QueryAst $ast): string; + + /** + * @param array $columns + * @param array $params Reference to params array for subqueries + * @return string + */ + protected function compileColumns(array $columns, array &$params): string + { + $compiled = Arr::map($columns, function (string|Functions|SelectCase|Subquery $value, int|string $key) use (&$params): string { + return match (true) { + is_string($key) => (string) Alias::of($key)->as($value), + $value instanceof Functions => (string) $value, + $value instanceof SelectCase => (string) $value, + $value instanceof Subquery => $this->compileSubquery($value, $params), + default => $value, + }; + }); + + return Arr::implodeDeeply($compiled, ', '); + } + + /** + * @param Subquery $subquery + * @param array $params Reference to params array + * @return string + */ + private function compileSubquery(Subquery $subquery, array &$params): string + { + [$dml, $arguments] = $subquery->toSql(); + + if (! str_contains($dml, 'LIMIT 1')) { + throw new QueryErrorException('The subquery must be limited to one record'); + } + + $params = array_merge($params, $arguments); + + return $dml; + } +} diff --git a/src/Database/Dialects/Compilers/UpdateCompiler.php b/src/Database/Dialects/Compilers/UpdateCompiler.php new file mode 100644 index 00000000..1a40bcd2 --- /dev/null +++ b/src/Database/Dialects/Compilers/UpdateCompiler.php @@ -0,0 +1,55 @@ +whereCompiler = new WhereCompiler(); + } + + public function compile(QueryAst $ast): CompiledClause + { + $parts = []; + $params = []; + + $parts[] = 'UPDATE'; + $parts[] = $ast->table; + + // SET col1 = ?, col2 = ? + // Extract params from values (these are actual values, not placeholders) + $columns = []; + + foreach ($ast->values as $column => $value) { + $params[] = $value; + $columns[] = "{$column} = " . SQL::PLACEHOLDER->value; + } + + $parts[] = 'SET'; + $parts[] = Arr::implodeDeeply($columns, ', '); + + if (! empty($ast->wheres)) { + $whereCompiled = $this->whereCompiler->compile($ast->wheres); + + $parts[] = 'WHERE'; + $parts[] = $whereCompiled->sql; + + $params = array_merge($params, $ast->params); + } + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $params); + } +} diff --git a/src/Database/Dialects/Compilers/WhereCompiler.php b/src/Database/Dialects/Compilers/WhereCompiler.php new file mode 100644 index 00000000..280a6f9d --- /dev/null +++ b/src/Database/Dialects/Compilers/WhereCompiler.php @@ -0,0 +1,48 @@ +> $wheres + * @return CompiledClause + */ + public function compile(array $wheres): CompiledClause + { + if (empty($wheres)) { + return new CompiledClause('', []); + } + + $prepared = $this->prepareClauses($wheres); + $sql = Arr::implodeDeeply($prepared); + + // WHERE clauses don't add new params - they're already in QueryAst params + return new CompiledClause($sql, []); + } + + /** + * @param array> $clauses + * @return array> + */ + private function prepareClauses(array $clauses): array + { + return array_map(function (array $clause): array { + return array_map(function ($value): mixed { + return match (true) { + $value instanceof Operator => $value->value, + $value instanceof LogicalOperator => $value->value, + is_array($value) => '(' . Arr::implodeDeeply($value, ', ') . ')', + default => $value, + }; + }, $clause); + }, $clauses); + } +} diff --git a/src/Database/Dialects/Contracts/ClauseCompiler.php b/src/Database/Dialects/Contracts/ClauseCompiler.php new file mode 100644 index 00000000..ca8598e5 --- /dev/null +++ b/src/Database/Dialects/Contracts/ClauseCompiler.php @@ -0,0 +1,12 @@ + $params The parameters for prepared statements + */ + public function __construct( + public string $sql, + public array $params = [] + ) { + } +} diff --git a/src/Database/Dialects/Contracts/Dialect.php b/src/Database/Dialects/Contracts/Dialect.php new file mode 100644 index 00000000..48193bff --- /dev/null +++ b/src/Database/Dialects/Contracts/Dialect.php @@ -0,0 +1,18 @@ +} A tuple of SQL string and parameters + */ + public function compile(QueryAst $ast): array; + + public function capabilities(): DialectCapabilities; +} diff --git a/src/Database/Dialects/Contracts/DialectCapabilities.php b/src/Database/Dialects/Contracts/DialectCapabilities.php new file mode 100644 index 00000000..95795156 --- /dev/null +++ b/src/Database/Dialects/Contracts/DialectCapabilities.php @@ -0,0 +1,50 @@ +>, ->, etc.) + * @param bool $supportsAdvancedLocks Whether the dialect supports advanced locks (FOR NO KEY UPDATE, etc.) + * @param bool $supportsInsertIgnore Whether the dialect supports INSERT IGNORE syntax + * @param bool $supportsFulltextSearch Whether the dialect supports full-text search + * @param bool $supportsGeneratedColumns Whether the dialect supports generated/computed columns + */ + public function __construct( + public bool $supportsLocks = false, + public bool $supportsUpsert = false, + public bool $supportsReturning = false, + public bool $supportsJsonOperators = false, + public bool $supportsAdvancedLocks = false, + public bool $supportsInsertIgnore = false, + public bool $supportsFulltextSearch = false, + public bool $supportsGeneratedColumns = false, + ) { + } + + /** + * Check if a specific capability is supported. + * + * @param string $capability The capability name (e.g., 'locks', 'upsert') + * @return bool + */ + public function supports(string $capability): bool + { + $property = 'supports' . ucfirst($capability); + + return property_exists($this, $property) && $this->$property; + } +} diff --git a/src/Database/Dialects/DialectFactory.php b/src/Database/Dialects/DialectFactory.php new file mode 100644 index 00000000..193a442c --- /dev/null +++ b/src/Database/Dialects/DialectFactory.php @@ -0,0 +1,39 @@ + + */ + private static array $instances = []; + + private function __construct() + { + // Prevent instantiation + } + + public static function fromDriver(Driver $driver): Dialect + { + return self::$instances[$driver->value] ??= match ($driver) { + Driver::MYSQL => new MysqlDialect(), + Driver::POSTGRESQL => new PostgresDialect(), + Driver::SQLITE => new SqliteDialect(), + default => new MysqlDialect(), + }; + } + + public static function clearCache(): void + { + self::$instances = []; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php new file mode 100644 index 00000000..1940319d --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlDeleteCompiler.php @@ -0,0 +1,12 @@ + "{$column} = VALUES({$column})", + $ast->uniqueColumns + ); + + return 'ON DUPLICATE KEY UPDATE ' . Arr::implodeDeeply($columns, ', '); + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php new file mode 100644 index 00000000..d54f37d9 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlSelectCompiler.php @@ -0,0 +1,26 @@ +lock === null) { + return ''; + } + + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + default => '', + }; + } +} diff --git a/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php new file mode 100644 index 00000000..20c665f1 --- /dev/null +++ b/src/Database/Dialects/MySQL/Compilers/MysqlUpdateCompiler.php @@ -0,0 +1,12 @@ +capabilities = new DialectCapabilities( + supportsLocks: true, + supportsUpsert: true, + supportsReturning: false, + supportsJsonOperators: true, + supportsAdvancedLocks: false, + supportsInsertIgnore: true, + supportsFulltextSearch: true, + supportsGeneratedColumns: true, + ); + + $this->selectCompiler = new MysqlSelectCompiler(); + $this->insertCompiler = new MysqlInsertCompiler(); + $this->updateCompiler = new MysqlUpdateCompiler(); + $this->deleteCompiler = new MysqlDeleteCompiler(); + $this->existsCompiler = new MysqlExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php new file mode 100644 index 00000000..14161130 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresDeleteCompiler.php @@ -0,0 +1,12 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = EXCLUDED.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } + + public function compile(QueryAst $ast): CompiledClause + { + if ($ast->ignore && empty($ast->uniqueColumns)) { + $parts = []; + $parts[] = 'INSERT INTO'; + $parts[] = $ast->table; + $parts[] = '(' . Arr::implodeDeeply($ast->columns, ', ') . ')'; + + if ($ast->rawStatement !== null) { + $parts[] = $ast->rawStatement; + } else { + $parts[] = 'VALUES'; + + $placeholders = array_map(function (array $value): string { + return '(' . Arr::implodeDeeply($value, ', ') . ')'; + }, $ast->values); + + $parts[] = Arr::implodeDeeply(array_values($placeholders), ', '); + } + + $parts[] = 'ON CONFLICT DO NOTHING'; + + $sql = Arr::implodeDeeply($parts); + + return new CompiledClause($sql, $ast->params); + } + + return parent::compile($ast); + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php new file mode 100644 index 00000000..f9495347 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresSelectCompiler.php @@ -0,0 +1,32 @@ +lock === null) { + return ''; + } + + return match ($ast->lock) { + Lock::FOR_UPDATE => 'FOR UPDATE', + Lock::FOR_SHARE => 'FOR SHARE', + Lock::FOR_NO_KEY_UPDATE => 'FOR NO KEY UPDATE', + Lock::FOR_KEY_SHARE => 'FOR KEY SHARE', + Lock::FOR_UPDATE_SKIP_LOCKED => 'FOR UPDATE SKIP LOCKED', + Lock::FOR_SHARE_SKIP_LOCKED => 'FOR SHARE SKIP LOCKED', + Lock::FOR_NO_KEY_UPDATE_SKIP_LOCKED => 'FOR NO KEY UPDATE SKIP LOCKED', + Lock::FOR_UPDATE_NOWAIT => 'FOR UPDATE NOWAIT', + Lock::FOR_SHARE_NOWAIT => 'FOR SHARE NOWAIT', + Lock::FOR_NO_KEY_UPDATE_NOWAIT => 'FOR NO KEY UPDATE NOWAIT', + }; + } +} diff --git a/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php new file mode 100644 index 00000000..df52eb67 --- /dev/null +++ b/src/Database/Dialects/PostgreSQL/Compilers/PostgresUpdateCompiler.php @@ -0,0 +1,12 @@ +capabilities = new DialectCapabilities( + supportsLocks: true, + supportsUpsert: true, + supportsReturning: true, + supportsJsonOperators: true, + supportsAdvancedLocks: true, // FOR NO KEY UPDATE, FOR KEY SHARE, etc. + supportsInsertIgnore: false, // Uses ON CONFLICT instead + supportsFulltextSearch: true, + supportsGeneratedColumns: true, + ); + + $this->selectCompiler = new PostgresSelectCompiler(); + $this->insertCompiler = new PostgresInsertCompiler(); + $this->updateCompiler = new PostgresUpdateCompiler(); + $this->deleteCompiler = new PostgresDeleteCompiler(); + $this->existsCompiler = new PostgresExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php new file mode 100644 index 00000000..5dc363db --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/SqliteDeleteCompiler.php @@ -0,0 +1,12 @@ +uniqueColumns, ', '); + + $updateColumns = array_map(function (string $column): string { + return "{$column} = excluded.{$column}"; + }, $ast->uniqueColumns); + + return sprintf( + 'ON CONFLICT (%s) DO UPDATE SET %s', + $conflictColumns, + Arr::implodeDeeply($updateColumns, ', ') + ); + } +} diff --git a/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php new file mode 100644 index 00000000..f5bc1729 --- /dev/null +++ b/src/Database/Dialects/SQLite/Compilers/SqliteSelectCompiler.php @@ -0,0 +1,17 @@ +capabilities = new DialectCapabilities( + supportsLocks: false, // SQLite doesn't support row-level locks + supportsUpsert: true, // SQLite 3.24.0+ supports ON CONFLICT + supportsReturning: true, // SQLite 3.35.0+ supports RETURNING + supportsJsonOperators: true, // SQLite 3.38.0+ supports JSON functions + supportsAdvancedLocks: false, + supportsInsertIgnore: true, // INSERT OR IGNORE + supportsFulltextSearch: true, // FTS5 + supportsGeneratedColumns: true, // SQLite 3.31.0+ + ); + + $this->selectCompiler = new SqliteSelectCompiler(); + $this->insertCompiler = new SqliteInsertCompiler(); + $this->updateCompiler = new SqliteUpdateCompiler(); + $this->deleteCompiler = new SqliteDeleteCompiler(); + $this->existsCompiler = new SqliteExistsCompiler(); + } + + public function capabilities(): DialectCapabilities + { + return $this->capabilities; + } + + public function compile(QueryAst $ast): array + { + return match ($ast->action) { + Action::SELECT => $this->compileSelect($ast), + Action::INSERT => $this->compileInsert($ast), + Action::UPDATE => $this->compileUpdate($ast), + Action::DELETE => $this->compileDelete($ast), + Action::EXISTS => $this->compileExists($ast), + }; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileSelect(QueryAst $ast): array + { + $compiled = $this->selectCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileInsert(QueryAst $ast): array + { + $compiled = $this->insertCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileUpdate(QueryAst $ast): array + { + $compiled = $this->updateCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileDelete(QueryAst $ast): array + { + $compiled = $this->deleteCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } + + /** + * @return array{0: string, 1: array} + */ + private function compileExists(QueryAst $ast): array + { + $compiled = $this->existsCompiler->compile($ast); + + return [$compiled->sql, $compiled->params]; + } +} diff --git a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php index cd534151..33287321 100644 --- a/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php +++ b/src/Database/Models/QueryBuilders/DatabaseQueryBuilder.php @@ -7,9 +7,6 @@ use Amp\Sql\Common\SqlCommonConnectionPool; use Closure; use Phenix\App; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; use Phenix\Database\Exceptions\ModelException; @@ -22,36 +19,15 @@ use Phenix\Database\Models\Relationships\HasMany; use Phenix\Database\Models\Relationships\Relationship; use Phenix\Database\Models\Relationships\RelationshipParser; -use Phenix\Database\QueryBase; +use Phenix\Database\QueryBuilder; use Phenix\Util\Arr; use function array_key_exists; use function is_array; use function is_string; -class DatabaseQueryBuilder extends QueryBase +class DatabaseQueryBuilder extends QueryBuilder { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::table as protected; - BuildsQuery::from as protected; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } - use HasJoinClause; - protected DatabaseModel $model; /** diff --git a/src/Database/QueryAst.php b/src/Database/QueryAst.php new file mode 100644 index 00000000..7079cd0c --- /dev/null +++ b/src/Database/QueryAst.php @@ -0,0 +1,89 @@ + + */ + public array $columns = ['*']; + + /** + * Values for INSERT/UPDATE operations + * + * @var array + */ + public array $values = []; + + /** + * @var array + */ + public array $joins = []; + + /** + * @var array> + */ + public array $wheres = []; + + /** + * @var string|null + */ + public string|null $having = null; + + /** + * @var array + */ + public array $groups = []; + + /** + * @var array + */ + public array $orders = []; + + public int|null $limit = null; + + public int|null $offset = null; + + public Lock|null $lock = null; + + /** + * RETURNING clause columns (PostgreSQL, SQLite 3.35+) + * + * @var array + */ + public array $returning = []; + + /** + * Prepared statement parameters + * + * @var array + */ + public array $params = []; + + /** + * @var string|null + */ + public string|null $rawStatement = null; + + /** + * Whether to use INSERT IGNORE (MySQL) + * */ + public bool $ignore = false; + + /** + * Columns for UPSERT operations (ON DUPLICATE KEY / ON CONFLICT) + * + * @var array + */ + public array $uniqueColumns = []; +} diff --git a/src/Database/QueryBase.php b/src/Database/QueryBase.php index 7e7099cb..dd4457f8 100644 --- a/src/Database/QueryBase.php +++ b/src/Database/QueryBase.php @@ -4,14 +4,23 @@ namespace Phenix\Database; +use Closure; +use Phenix\Database\Concerns\Query\BuildsQuery; use Phenix\Database\Concerns\Query\HasDriver; +use Phenix\Database\Concerns\Query\HasJoinClause; +use Phenix\Database\Concerns\Query\HasLock; use Phenix\Database\Constants\Action; +use Phenix\Database\Constants\Operator; +use Phenix\Database\Constants\SQL; use Phenix\Database\Contracts\Builder; use Phenix\Database\Contracts\QueryBuilder; abstract class QueryBase extends Clause implements QueryBuilder, Builder { use HasDriver; + use BuildsQuery; + use HasLock; + use HasJoinClause; protected string $table; @@ -60,4 +69,117 @@ protected function resetBaseProperties(): void $this->arguments = []; $this->uniqueColumns = []; } + + public function count(string $column = '*'): array|int + { + $this->action = Action::SELECT; + + $this->columns = [Functions::count($column)]; + + return $this->toSql(); + } + + public function exists(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::EXISTS->value]; + + return $this->toSql(); + } + + public function doesntExist(): array|bool + { + $this->action = Action::EXISTS; + + $this->columns = [Operator::NOT_EXISTS->value]; + + return $this->toSql(); + } + + public function insert(array $data): array|bool + { + $this->action = Action::INSERT; + + $this->prepareDataToInsert($data); + + return $this->toSql(); + } + + public function insertOrIgnore(array $values): array|bool + { + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array|bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + return $this->toSql(); + } + + public function update(array $values): array|bool + { + $this->action = Action::UPDATE; + + $this->values = $values; + + return $this->toSql(); + } + + public function upsert(array $values, array $columns): array|bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + $this->prepareDataToInsert($values); + + return $this->toSql(); + } + + public function delete(): array|bool + { + $this->action = Action::DELETE; + + return $this->toSql(); + } + + protected function prepareDataToInsert(array $data): void + { + if (array_is_list($data)) { + foreach ($data as $record) { + $this->prepareDataToInsert($record); + } + + return; + } + + ksort($data); + + $this->columns = array_unique([...$this->columns, ...array_keys($data)]); + + $this->arguments = \array_merge($this->arguments, array_values($data)); + + $this->values[] = array_fill(0, count($data), SQL::PLACEHOLDER->value); + } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 54fdc4ae..6b1abd91 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -4,12 +4,16 @@ namespace Phenix\Database; +use Amp\Mysql\Internal\MysqlPooledResult; use Amp\Sql\Common\SqlCommonConnectionPool; +use Amp\Sql\SqlQueryError; +use Amp\Sql\SqlTransactionError; +use Closure; +use League\Uri\Components\Query; +use League\Uri\Http; use Phenix\App; use Phenix\Data\Collection; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; -use Phenix\Database\Concerns\Query\HasSentences; +use Phenix\Database\Concerns\Query\HasTransaction; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Connection; @@ -17,24 +21,7 @@ class QueryBuilder extends QueryBase { - use BuildsQuery, HasSentences { - HasSentences::count insteadof BuildsQuery; - HasSentences::insert insteadof BuildsQuery; - HasSentences::exists insteadof BuildsQuery; - HasSentences::doesntExist insteadof BuildsQuery; - HasSentences::update insteadof BuildsQuery; - HasSentences::delete insteadof BuildsQuery; - BuildsQuery::insert as protected insertRows; - BuildsQuery::insertOrIgnore as protected insertOrIgnoreRows; - BuildsQuery::upsert as protected upsertRows; - BuildsQuery::insertFrom as protected insertFromRows; - BuildsQuery::update as protected updateRow; - BuildsQuery::delete as protected deleteRows; - BuildsQuery::count as protected countRows; - BuildsQuery::exists as protected existsRows; - BuildsQuery::doesntExist as protected doesntExistRows; - } - use HasJoinClause; + use HasTransaction; protected SqlCommonConnectionPool $connection; @@ -67,6 +54,164 @@ public function connection(SqlCommonConnectionPool|string $connection): self return $this; } + public function count(string $column = '*'): int + { + $this->action = Action::SELECT; + + [$dml, $params] = parent::count($column); + + /** @var array $count */ + $count = $this->exec($dml, $params)->fetchRow(); + + return array_values($count)[0]; + } + + public function exists(): bool + { + $this->action = Action::EXISTS; + + [$dml, $params] = parent::exists(); + + $results = $this->exec($dml, $params)->fetchRow(); + + return (bool) array_values($results)[0]; + } + + public function doesntExist(): bool + { + return ! $this->exists(); + } + + public function paginate(Http $uri, int $defaultPage = 1, int $defaultPerPage = 15): Paginator + { + $this->action = Action::SELECT; + + $query = Query::fromUri($uri); + + $currentPage = filter_var($query->get('page') ?? $defaultPage, FILTER_SANITIZE_NUMBER_INT); + $currentPage = $currentPage === false ? $defaultPage : $currentPage; + + $perPage = filter_var($query->get('per_page') ?? $defaultPerPage, FILTER_SANITIZE_NUMBER_INT); + $perPage = $perPage === false ? $defaultPerPage : $perPage; + + $countQuery = clone $this; + + $total = $countQuery->count(); + + $data = $this->page((int) $currentPage, (int) $perPage)->get(); + + return new Paginator($uri, $data, (int) $total, (int) $currentPage, (int) $perPage); + } + + public function insert(array $data): bool + { + [$dml, $params] = parent::insert($data); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertOrIgnore(array $values): bool + { + $this->ignore = true; + + return $this->insert($values); + } + + public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): bool + { + $builder = new Subquery($this->driver); + $builder->selectAllColumns(); + + $subquery($builder); + + [$dml, $arguments] = $builder->toSql(); + + $this->rawStatement = trim($dml, '()'); + + $this->arguments = array_merge($this->arguments, $arguments); + + $this->action = Action::INSERT; + + $this->ignore = $ignore; + + $this->columns = $columns; + + try { + [$dml, $params] = $this->toSql(); + + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function insertRow(array $data): int|string|bool + { + [$dml, $params] = parent::insert($data); + + try { + /** @var MysqlPooledResult $result */ + $result = $this->exec($dml, $params); + + return $result->getLastInsertId(); + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function update(array $values): bool + { + [$dml, $params] = parent::update($values); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + + public function upsert(array $values, array $columns): bool + { + $this->action = Action::INSERT; + + $this->uniqueColumns = $columns; + + return $this->insert($values); + } + + public function delete(): bool + { + [$dml, $params] = parent::delete(); + + try { + $this->exec($dml, $params); + + return true; + } catch (SqlQueryError|SqlTransactionError $e) { + report($e); + + return false; + } + } + /** * @return Collection> */ @@ -88,9 +233,9 @@ public function get(): Collection } /** - * @return array|null + * @return object|array|null */ - public function first(): array|null + public function first(): object|array|null { $this->action = Action::SELECT; diff --git a/src/Database/QueryGenerator.php b/src/Database/QueryGenerator.php index 853df2f7..e2514f92 100644 --- a/src/Database/QueryGenerator.php +++ b/src/Database/QueryGenerator.php @@ -5,26 +5,11 @@ namespace Phenix\Database; use Closure; -use Phenix\Database\Concerns\Query\BuildsQuery; -use Phenix\Database\Concerns\Query\HasJoinClause; use Phenix\Database\Constants\Action; use Phenix\Database\Constants\Driver; class QueryGenerator extends QueryBase { - use BuildsQuery { - insert as protected insertRows; - insertOrIgnore as protected insertOrIgnoreRows; - upsert as protected upsertRows; - insertFrom as protected insertFromRows; - update as protected updateRow; - delete as protected deleteRows; - count as protected countRows; - exists as protected existsRows; - doesntExist as protected doesntExistRows; - } - use HasJoinClause; - public function __construct(Driver $driver = Driver::MYSQL) { parent::__construct(); @@ -41,53 +26,51 @@ public function __clone(): void public function insert(array $data): array { - return $this->insertRows($data)->toSql(); + return parent::insert($data); } public function insertOrIgnore(array $values): array { - return $this->insertOrIgnoreRows($values)->toSql(); + $this->ignore = true; + + $this->insert($values); + + return $this->toSql(); } public function upsert(array $values, array $columns): array { - return $this->upsertRows($values, $columns)->toSql(); + return parent::upsert($values, $columns); } public function insertFrom(Closure $subquery, array $columns, bool $ignore = false): array { - return $this->insertFromRows($subquery, $columns, $ignore)->toSql(); + return parent::insertFrom($subquery, $columns, $ignore); } public function update(array $values): array { - return $this->updateRow($values)->toSql(); + return parent::update($values); } public function delete(): array { - return $this->deleteRows()->toSql(); + return parent::delete(); } public function count(string $column = '*'): array { - $this->action = Action::SELECT; - - return $this->countRows($column)->toSql(); + return parent::count($column); } public function exists(): array { - $this->action = Action::EXISTS; - - return $this->existsRows()->toSql(); + return parent::exists(); } public function doesntExist(): array { - $this->action = Action::EXISTS; - - return $this->doesntExistRows()->toSql(); + return parent::doesntExist(); } public function get(): array diff --git a/src/Queue/ParallelQueue.php b/src/Queue/ParallelQueue.php index 752ec3b6..31683444 100644 --- a/src/Queue/ParallelQueue.php +++ b/src/Queue/ParallelQueue.php @@ -153,6 +153,12 @@ private function handleIntervalTick(): void { $this->cleanupCompletedTasks(); + if (empty($this->runningTasks) && parent::size() === 0) { + $this->disableProcessing(); + + return; + } + if (! empty($this->runningTasks)) { return; } diff --git a/src/Testing/Concerns/RefreshDatabase.php b/src/Testing/Concerns/RefreshDatabase.php index 1cc9674a..c7f1dc65 100644 --- a/src/Testing/Concerns/RefreshDatabase.php +++ b/src/Testing/Concerns/RefreshDatabase.php @@ -67,11 +67,21 @@ protected function runMigrations(): void protected function truncateDatabase(): void { - /** @var SqlCommonConnectionPool $connection */ + /** @var SqlCommonConnectionPool|object $connection */ $connection = App::make(Connection::default()); $driver = $this->resolveDriver(); + if ($driver === Driver::SQLITE) { + try { + $this->truncateSqliteDatabase($connection); + } catch (Throwable $e) { + report($e); + } + + return; + } + try { $tables = $this->getDatabaseTables($connection, $driver); } catch (Throwable) { @@ -123,7 +133,6 @@ protected function getDatabaseTables(SqlCommonConnectionPool $connection, Driver } } } else { - // Unsupported driver (sqlite, etc.) – return empty so caller exits gracefully. return []; } @@ -165,4 +174,49 @@ protected function truncateTables(SqlCommonConnectionPool $connection, Driver $d report($e); } } + + protected function truncateSqliteDatabase(object $connection): void + { + $stmt = $connection->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"); + $result = $stmt->execute(); + + $tables = []; + + foreach ($result as $row) { + $table = $row['name'] ?? null; + + if ($table) { + $tables[] = $table; + } + } + + $tables = $this->filterTruncatableTables($tables); + + if (empty($tables)) { + return; + } + + try { + $connection->prepare('BEGIN IMMEDIATE')->execute(); + } catch (Throwable) { + // If BEGIN fails, continue best-effort without explicit transaction + } + + try { + foreach ($tables as $table) { + $connection->prepare('DELETE FROM ' . '"' . str_replace('"', '""', $table) . '"')->execute(); + } + + try { + $connection->prepare('DELETE FROM sqlite_sequence')->execute(); + } catch (Throwable) { + } + } finally { + try { + $connection->prepare('COMMIT')->execute(); + } catch (Throwable) { + // Best-effort commit; ignore errors + } + } + } } diff --git a/tests/Unit/Database/Dialects/DialectFactoryTest.php b/tests/Unit/Database/Dialects/DialectFactoryTest.php new file mode 100644 index 00000000..e542bb7b --- /dev/null +++ b/tests/Unit/Database/Dialects/DialectFactoryTest.php @@ -0,0 +1,48 @@ +toBeInstanceOf(MysqlDialect::class); +}); + +test('DialectFactory creates PostgreSQL dialect for PostgreSQL driver', function () { + $dialect = DialectFactory::fromDriver(Driver::POSTGRESQL); + + expect($dialect)->toBeInstanceOf(PostgresDialect::class); +}); + +test('DialectFactory creates SQLite dialect for SQLite driver', function () { + $dialect = DialectFactory::fromDriver(Driver::SQLITE); + + expect($dialect)->toBeInstanceOf(SqliteDialect::class); +}); + +test('DialectFactory returns same instance for repeated calls (singleton)', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->toBe($dialect2); +}); + +test('DialectFactory clearCache clears cached instances', function () { + $dialect1 = DialectFactory::fromDriver(Driver::MYSQL); + + DialectFactory::clearCache(); + + $dialect2 = DialectFactory::fromDriver(Driver::MYSQL); + + expect($dialect1)->not->toBe($dialect2); +}); diff --git a/tests/Unit/RefreshDatabaseTest.php b/tests/Unit/RefreshDatabaseTest.php index 5f48887d..f4396e4d 100644 --- a/tests/Unit/RefreshDatabaseTest.php +++ b/tests/Unit/RefreshDatabaseTest.php @@ -65,3 +65,30 @@ $this->assertTrue(true); }); + +it('truncates tables for sqlite driver', function (): void { + Config::set('database.default', 'sqlite'); + + expect(Config::get('database.default'))->toBe('sqlite'); + + $connection = new class () { + public function prepare(string $sql): Statement + { + if (str_starts_with($sql, 'SELECT name FROM sqlite_master')) { + return new Statement(new Result([ + ['name' => 'users'], + ['name' => 'posts'], + ['name' => 'migrations'], + ])); + } + + return new Statement(new Result()); + } + }; + + $this->app->swap(Connection::default(), $connection); + + $this->refreshDatabase(); + + $this->assertTrue(true); +}); diff --git a/tests/fixtures/application/config/database.php b/tests/fixtures/application/config/database.php index 2bb5ceb4..10d3db79 100644 --- a/tests/fixtures/application/config/database.php +++ b/tests/fixtures/application/config/database.php @@ -6,6 +6,10 @@ 'default' => env('DB_CONNECTION', static fn () => 'mysql'), 'connections' => [ + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('DB_DATABASE', static fn () => base_path('database/database')), + ], 'mysql' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', static fn () => '127.0.0.1'), diff --git a/tests/fixtures/application/database/.gitignore b/tests/fixtures/application/database/.gitignore new file mode 100644 index 00000000..885029a5 --- /dev/null +++ b/tests/fixtures/application/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* \ No newline at end of file