diff --git a/AGENTS.md b/AGENTS.md index 7a75ee6af..0a7fa6d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,6 +205,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan These rules apply to all code, including ported code. +- **Laravel-style config files** — Group related settings under Laravel's standard section comment blocks, with concise, user-facing explanations. - Always use typed getters for values with one non-null type. Only use `get()` when null, union, or mixed values are meaningful. Add a test for any supported null behavior. - Cast environment-backed booleans and numbers in config files; if `null` is supported, cast only non-null values. Consumers must not repeat those casts. When a factory accepts raw configuration records, normalize types and documented optional defaults once at that boundary; never supply missing required members. - Required settings live in shipped config and must not have a code-level fallback, so missing or misspelled keys fail loudly. diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index c855d21cb..9b9ca8fbb 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -601,6 +601,84 @@ protected function run(string $query, array $bindings, Closure $callback): mixed return $result; } + /** + * Run a streaming SQL statement and log only its complete execution. + * + * @template TKey of array-key + * @template TValue + * + * @param Closure(string, array): iterable $callback + * @return Generator + * + * @throws CanceledException + * @throws QueryException + * @throws StreamClosedException + */ + protected function runStreaming(string $query, array $bindings, Closure $callback): Generator + { + foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) { + $beforeExecutingCallback($query, $bindings, $this); + } + + $this->reconnectIfMissingConnection(); + + $start = hrtime(true) / 1e9; + $hasYielded = false; + + $execute = function (string $query, array $bindings) use ($callback, &$hasYielded): Generator { + try { + foreach ($callback($query, $bindings) as $key => $value) { + $readWriteType = $this->latestReadWriteTypeRetrieved; + $hasYielded = true; + + try { + yield $key => $value; + } finally { + // A consumer may run another query while this operation is suspended. + $this->latestReadWriteTypeRetrieved = $readWriteType; + } + } + } catch (CanceledException|StreamClosedException $exception) { + throw $exception; + } catch (Exception $exception) { + ++$this->errorCount; + + throw $this->newQueryException($query, $bindings, $exception); + } + }; + + try { + try { + yield from $execute($query, $bindings); + } catch (QueryException $exception) { + if ($hasYielded) { + throw $exception; + } + + yield from $this->handleQueryException($exception, $query, $bindings, $execute); + } + } catch (CanceledException|StreamClosedException $exception) { + throw $exception; + } catch (Throwable $exception) { + $events = $this->events; + + if ($events?->hasListeners(QueryFailed::class)) { + $events->dispatch(new QueryFailed( + $query, + $bindings, + $this->getElapsedTime($start), + $this, + $exception, + $this->latestReadWriteTypeUsed(), + )); + } + + throw $exception; + } + + $this->logQuery($query, $bindings, $this->getElapsedTime($start)); + } + /** * Run a SQL statement. * @@ -624,28 +702,36 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal } catch (Exception $e) { ++$this->errorCount; - $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e)) - ? UniqueConstraintViolationException::class - : QueryException::class; + throw $this->newQueryException($query, $bindings, $e); + } + } - $queryException = new $exceptionType( - $this->getName(), - $query, - $this->prepareBindings($bindings), - $e, - $this->getConnectionDetails(), - $this->latestReadWriteTypeUsed(), - $this->getConfig('mask_bindings_in_exception_messages'), - ); + /** + * Create an exception containing the query and connection context. + */ + protected function newQueryException(string $query, array $bindings, Exception $previous): QueryException + { + $exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($previous)) + ? UniqueConstraintViolationException::class + : QueryException::class; - if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) { - ['index' => $index, 'columns' => $columns] = $this->parseUniqueConstraintViolation($e); + $queryException = new $exceptionType( + $this->getName(), + $query, + $this->prepareBindings($bindings), + $previous, + $this->getConnectionDetails(), + $this->latestReadWriteTypeUsed(), + $this->getConfig('mask_bindings_in_exception_messages'), + ); - $queryException->setIndex($index)->setColumns($columns); - } + if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) { + ['index' => $index, 'columns' => $columns] = $this->parseUniqueConstraintViolation($previous); - throw $queryException; + $queryException->setIndex($index)->setColumns($columns); } + + return $queryException; } /** @@ -1196,6 +1282,23 @@ public function useWriteConnectionWhenReading(bool $value = true): static return $this; } + /** + * Resolve and record the connection role for an operation. + * + * @return 'read'|'write' + */ + protected function resolveReadWriteType(bool $read = true): string + { + if ($read + && $this->transactions === 0 + && ! $this->readOnWriteConnection + && ! ($this->recordsModified && $this->getConfig('sticky'))) { + return $this->latestReadWriteTypeRetrieved = 'read'; + } + + return $this->latestReadWriteTypeRetrieved = 'write'; + } + /** * Invalidate the state remembered for the current physical session. */ diff --git a/src/database/src/Connectors/ConnectionFactory.php b/src/database/src/Connectors/ConnectionFactory.php index b55da6cec..27d750809 100755 --- a/src/database/src/Connectors/ConnectionFactory.php +++ b/src/database/src/Connectors/ConnectionFactory.php @@ -50,10 +50,7 @@ public function make(array $config, ?string $name = null): Connection // Next we will check to see if an extension has been registered for a driver // and will call the Closure if so, which allows us to have a more generic // resolver for the drivers themselves which applies to all connections. - $driver = $config['driver'] ?? null; - $resolver = $name !== null && isset($this->extensions[$name]) - ? $this->extensions[$name] - : ($driver !== null ? $this->extensions[$driver] ?? null : null); + $resolver = $this->getExtension($config, $name); if ($resolver !== null) { $connection = call_user_func($resolver, $config, $name); @@ -68,6 +65,18 @@ public function make(array $config, ?string $name = null): Connection return $this->createPdoConnectionFromConfig($config); } + /** + * Get the extension resolver for a connection configuration. + */ + public function getExtension(array $config, ?string $name): ?callable + { + $driver = $config['driver'] ?? null; + + return $name !== null && isset($this->extensions[$name]) + ? $this->extensions[$name] + : ($driver !== null ? $this->extensions[$driver] ?? null : null); + } + /** * Register an extension connection resolver. * diff --git a/src/database/src/Console/DbCommand.php b/src/database/src/Console/DbCommand.php index 4ec6cd367..61eded2b0 100644 --- a/src/database/src/Console/DbCommand.php +++ b/src/database/src/Console/DbCommand.php @@ -6,6 +6,8 @@ use Hypervel\Console\Command; use Hypervel\Database\ConfigurationUrlParser; +use Hypervel\Database\DatabaseCliConfiguration; +use Hypervel\Database\DatabaseCliManager; use Hypervel\Support\Arr; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -33,27 +35,38 @@ class DbCommand extends Command public function handle(): int { $connection = $this->getConnection(); + $configuration = $this->hypervel->make(DatabaseCliManager::class)->resolve($connection); - if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') { - $this->components->error('No host specified for this database connection.'); - $this->line(' Use the [--read] and [--write] options to specify a read or write connection.'); - $this->newLine(); + if ($configuration === null) { + $command = $this->getCommand($connection); - return Command::FAILURE; + if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') { + $this->components->error('No host specified for this database connection.'); + $this->line(' Use the [--read] and [--write] options to specify a read or write connection.'); + $this->newLine(); + + return Command::FAILURE; + } + + $configuration = new DatabaseCliConfiguration( + $command, + $this->commandArguments($connection), + $this->commandEnvironment($connection) ?? [], + ); } try { (new Process( - array_merge([$command = $this->getCommand($connection)], $this->commandArguments($connection)), + array_merge([$configuration->command], $configuration->arguments), null, - $this->commandEnvironment($connection) + $configuration->environment ))->setTimeout(null)->setTty(true)->mustRun(function ($type, $buffer) { $this->output->write($buffer); }); } catch (ProcessFailedException $e) { throw_unless($e->getProcess()->getExitCode() === 127, $e); - $this->error("{$command} not found in path."); + $this->error("{$configuration->command} not found in path."); return Command::FAILURE; } @@ -86,6 +99,10 @@ public function getConnection(): array $connection = $this->mergeConnectionConfiguration($connection, 'write'); } + if (is_array($connection['host'] ?? null)) { + $connection['host'] = $connection['host'][0] ?? null; + } + return $connection; } @@ -104,14 +121,18 @@ protected function mergeConnectionConfiguration(array $connection, string $type) $merge = $merge[0]; } + if (! empty($merge['url'])) { + $merge = (new ConfigurationUrlParser)->parseConfiguration($merge); + } + if (is_array($merge['host'] ?? null)) { - $merge['host'] = $merge['host'][0]; + $merge['host'] = $merge['host'][0] ?? null; } $connection = array_merge($connection, $merge); if (is_array($connection['host'] ?? null)) { - $connection['host'] = $connection['host'][0]; + $connection['host'] = $connection['host'][0] ?? null; } return Arr::except($connection, ['read', 'write']); @@ -146,12 +167,15 @@ public function commandEnvironment(array $connection): ?array */ public function getCommand(array $connection): string { - return [ + return match ($connection['driver']) { 'mysql' => 'mysql', 'mariadb' => 'mariadb', 'pgsql' => 'psql', 'sqlite' => 'sqlite3', - ][$connection['driver']]; + default => throw new UnexpectedValueException( + "Unsupported database CLI driver [{$connection['driver']}]. Register a resolver using DatabaseCliManager::extend()." + ), + }; } /** @@ -165,10 +189,6 @@ protected function getMysqlArguments(array $connection): array 'charset' => '--default-character-set=' . ($connection['charset'] ?? ''), ]; - if (! $connection['password']) { - unset($optionalArguments['password']); - } - return array_merge([ '--host=' . $connection['host'], '--port=' . $connection['port'], @@ -219,7 +239,7 @@ protected function getPgsqlEnvironment(array $connection): ?array protected function getOptionalArguments(array $args, array $connection): array { return array_values(array_filter($args, function ($key) use ($connection) { - return ! empty($connection[$key]); + return isset($connection[$key]) && $connection[$key] !== ''; }, ARRAY_FILTER_USE_KEY)); } } diff --git a/src/database/src/DatabaseCliConfiguration.php b/src/database/src/DatabaseCliConfiguration.php new file mode 100644 index 000000000..5f51e2f65 --- /dev/null +++ b/src/database/src/DatabaseCliConfiguration.php @@ -0,0 +1,21 @@ + $arguments + * @param array $environment + */ + public function __construct( + public string $command, + public array $arguments = [], + public array $environment = [], + ) { + } +} diff --git a/src/database/src/DatabaseCliManager.php b/src/database/src/DatabaseCliManager.php new file mode 100644 index 000000000..0d2779808 --- /dev/null +++ b/src/database/src/DatabaseCliManager.php @@ -0,0 +1,40 @@ + + */ + protected array $extensions = []; + + /** + * Register a database client resolver. + * + * Boot-only. The resolver persists on the auto-singleton manager for the + * worker lifetime and applies to every subsequent database CLI session. + * + * @param callable(array): DatabaseCliConfiguration $resolver + */ + public function extend(string $driver, callable $resolver): void + { + $this->extensions[$driver] = $resolver; + } + + /** + * Resolve a registered database client configuration. + */ + public function resolve(array $connection): ?DatabaseCliConfiguration + { + if (! isset($this->extensions[$connection['driver']])) { + return null; + } + + return ($this->extensions[$connection['driver']])($connection); + } +} diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index aa409de9b..b95a7dc97 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -193,7 +193,9 @@ protected function configuration(ConnectionName|string $name): array $config = $this->factory->parseConfig($config, $connectionName->base); - if ($connectionName->isRead() && $this->factory->hasReadConfig($config)) { + if ($connectionName->isRead() + && $this->factory->hasReadConfig($config) + && $this->factory->getExtension($config, $connectionName->base) === null) { return $this->factory->configForRead($config); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 0b2d7815e..2607b8a4d 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1658,7 +1658,8 @@ protected function performInsert(Builder $query): bool return true; } - $query->insert($attributes); + // Keep array-valued attributes inside the model's single row. + $query->insert([$attributes]); } // We will go ahead and set the exists property to true, so that it is set when @@ -1700,8 +1701,9 @@ protected function performInsertOrIgnore(Builder $query, array|string|null $uniq return true; } + // Keep array-valued attributes inside the model's single row. $result = $query->toBase()->insertOrIgnoreReturning( - $attributes, + [$attributes], ['*'], $uniqueBy ); diff --git a/src/database/src/Migrations/DatabaseMigrationRepository.php b/src/database/src/Migrations/DatabaseMigrationRepository.php index 156e9ec95..78d4f7b7f 100755 --- a/src/database/src/Migrations/DatabaseMigrationRepository.php +++ b/src/database/src/Migrations/DatabaseMigrationRepository.php @@ -113,7 +113,7 @@ public function getNextBatchNumber(): int */ public function getLastBatchNumber(): int { - return $this->table()->max('batch') ?? 0; + return (int) ($this->table()->max('batch') ?? 0); } /** @@ -123,14 +123,7 @@ public function createRepository(): void { $schema = $this->getConnection()->getSchemaBuilder(); - $schema->create($this->table, function ($table) { - // The migrations table is responsible for keeping track of which of the - // migrations have actually run for the application. We'll create the - // table to hold the migration file's path as well as the batch ID. - $table->increments('id'); - $table->string('migration'); - $table->integer('batch'); - }); + $schema->createMigrationRepositoryTable($this->table); } /** diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 573b9a5ad..ac2a58e3a 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -370,16 +370,10 @@ public function getRawPdo(): PDO|Closure|null */ public function getReadPdo(): PDO { - if ($this->transactions > 0) { - return $this->getPdo(); - } - - if ($this->readOnWriteConnection - || ($this->recordsModified && $this->getConfig('sticky'))) { + if ($this->resolveReadWriteType() === 'write') { return $this->getPdo(); } - $this->latestReadWriteTypeRetrieved = 'read'; $pdo = $this->resolveReadPdo(); return static::$sessionConfigurators === [] diff --git a/src/database/src/Pool/DatabasePool.php b/src/database/src/Pool/DatabasePool.php index c57902923..2b57953db 100644 --- a/src/database/src/Pool/DatabasePool.php +++ b/src/database/src/Pool/DatabasePool.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\ConnectionPool\UsageTracker; use Hypervel\Contracts\Container\Container; use Hypervel\Coordinator\Timer; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\SQLiteDatabase; @@ -60,23 +61,32 @@ public function __construct(Container $container, string $name) /** @var ConnectionFactory $factory */ $factory = $container->make('db.factory'); $config = $factory->parseConfig($config, $connectionName->base); + $poolConfig = $config; if ($connectionName->isRead() && $factory->hasReadConfig($config)) { - $config = $factory->configForRead($config); - $this->ensureNotDerivedInMemorySqlitePool($connectionName, $config); + $poolConfig = $factory->configForRead($config); + $this->ensureNotDerivedInMemorySqlitePool($connectionName, $poolConfig); + + if ($factory->getExtension($config, $connectionName->base) === null) { + $config = $poolConfig; + } else { + // Extensions own endpoint selection, but the pool still uses + // the selected read record's pool options and SQLite metadata. + $config[Connection::READ_WRITE_TYPE_CONFIG_KEY] = ConnectionName::READ; + } } $this->config = $config; $poolOptions = Arr::except( - Arr::get($this->config, 'pool', []), + Arr::get($poolConfig, 'pool', []), ['testing_enabled'], ); $minimum = array_key_exists('min_retained_connections', $poolOptions) ? $poolOptions['min_retained_connections'] : 1; $maximum = array_key_exists('max_connections', $poolOptions) ? $poolOptions['max_connections'] : 10; - if ($this->isInMemorySqlite() + if ($this->isInMemorySqlite($poolConfig) && is_int($minimum) && is_int($maximum) && $minimum >= 0 @@ -93,7 +103,7 @@ public function __construct(Container $container, string $name) $this->heartbeatTimer = new Timer($this->getLogger()); // The sole managed wrapper must retain one PDO for the database lifetime. - if ($this->isInMemorySqlite()) { + if ($this->isInMemorySqlite($poolConfig)) { $this->sharedInMemorySqlitePdo = $this->createSharedInMemorySqlitePdo(); } } @@ -156,13 +166,13 @@ protected function createUsageTracker(): ?UsageTracker /** * Check if this pool is for an in-memory SQLite database. */ - protected function isInMemorySqlite(): bool + protected function isInMemorySqlite(array $config): bool { - if (($this->config['driver'] ?? '') !== 'sqlite') { + if (($config['driver'] ?? '') !== 'sqlite') { return false; } - $database = $this->config['database'] ?? ''; + $database = $config['database'] ?? ''; return SQLiteDatabase::isInMemory($database); } diff --git a/src/database/src/Pool/PoolManager.php b/src/database/src/Pool/PoolManager.php index abdd23343..df9b5b337 100644 --- a/src/database/src/Pool/PoolManager.php +++ b/src/database/src/Pool/PoolManager.php @@ -117,6 +117,8 @@ protected function getPoolName(string $name): string /** @var ConnectionFactory $factory */ $factory = $this->container->make('db.factory'); + // Read records may be supplied entirely by the connection URL. + $config = $factory->parseConfig($config, $connectionName->base); return $factory->hasReadConfig($config) ? $connectionName->requested diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d7dd751f1..d911c4843 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -45,6 +45,7 @@ /** * @template TKey of array-key = int * @template TValue = \stdClass + * @template TBindingType of string = 'select'|'from'|'join'|'where'|'groupBy'|'having'|'order'|'union'|'unionOrder' */ class Builder implements BuilderContract { @@ -71,17 +72,7 @@ class Builder implements BuilderContract /** * The current query value bindings. * - * @var array{ - * select: list, - * from: list, - * join: list, - * where: list, - * groupBy: list, - * having: list, - * order: list, - * union: list, - * unionOrder: list, - * } + * @var array> */ public array $bindings = [ 'select' => [], @@ -400,7 +391,7 @@ protected function parseSub(mixed $query): array } if ($query instanceof self) { - $this->ensureNoTimeoutOnEmbeddedQuery($query); + $this->ensureCanEmbedQuery($query); $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); @@ -608,7 +599,7 @@ public function join(ExpressionContract|string $table, Closure|ExpressionContrac /** * Add a "join where" clause to the query. */ - public function joinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second, string $type = 'inner'): static + public function joinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second, string $type = 'inner'): static { return $this->join($table, $first, $operator, $second, $type, true); } @@ -670,7 +661,7 @@ public function leftJoin(ExpressionContract|string $table, Closure|ExpressionCon /** * Add a "join where" clause to the query. */ - public function leftJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string|null $second): static + public function leftJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { return $this->joinWhere($table, $first, $operator, $second, 'left'); } @@ -696,7 +687,7 @@ public function rightJoin(ExpressionContract|string $table, Closure|ExpressionCo /** * Add a "right join where" clause to the query. */ - public function rightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second): static + public function rightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { return $this->joinWhere($table, $first, $operator, $second, 'right'); } @@ -752,7 +743,7 @@ public function straightJoin(ExpressionContract|string $table, Closure|Expressio /** * Add a straight join where clause to the query. */ - public function straightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, ExpressionContract|string $second): static + public function straightJoinWhere(ExpressionContract|string $table, Closure|ExpressionContract|string $first, string $operator, mixed $second): static { return $this->joinWhere($table, $first, $operator, $second, 'straight_join'); } @@ -1357,6 +1348,8 @@ public function whereBetween(self|EloquentBuilder|Relation|ExpressionContract|st $values = $this->resolveDatePeriodBounds($values); } + $values = is_array($values) ? $values : iterator_to_array($values, false); + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'where'); @@ -1447,10 +1440,13 @@ public function orWhereNotBetweenColumns(self|EloquentBuilder|Relation|Expressio public function whereValueBetween(mixed $value, array $columns, string $boolean = 'and', bool $not = false): static { $type = 'valueBetween'; + $value = $this->flattenValue($value); $this->wheres[] = compact('type', 'value', 'columns', 'boolean', 'not'); - $this->addBinding($value, 'where'); + if (! $value instanceof ExpressionContract) { + $this->addBinding($value, 'where'); + } return $this; } @@ -1599,7 +1595,8 @@ public function whereDay(ExpressionContract|string $column, mixed $operator, mix $value = $value->format('d'); } - if (! $value instanceof ExpressionContract) { + // Leave expression and driver-owned value objects to their binding/grammar paths. + if (! is_object($value)) { $value = sprintf('%02d', $value); } @@ -1644,7 +1641,8 @@ public function whereMonth(ExpressionContract|string $column, mixed $operator, m $value = $value->format('m'); } - if (! $value instanceof ExpressionContract) { + // Leave expression and driver-owned value objects to their binding/grammar paths. + if (! is_object($value)) { $value = sprintf('%02d', $value); } @@ -1733,7 +1731,7 @@ public function whereNested(Closure $callback, string $boolean = 'and'): static /** * Create a new query instance for nested where condition. */ - public function forNestedWhere(): self + public function forNestedWhere(): static { $query = $this->newQuery(); @@ -1780,7 +1778,7 @@ protected function whereSub(ExpressionContract|string $column, string $operator, $query = $callback instanceof self ? $callback : $callback->toBase(); } - $this->ensureNoTimeoutOnEmbeddedQuery($query); + $this->ensureCanEmbedQuery($query); $this->wheres[] = compact( 'type', @@ -1861,7 +1859,7 @@ public function orWhereNotExists(Closure|self|EloquentBuilder $callback): static */ public function addWhereExistsQuery(self $query, string $boolean = 'and', bool $not = false): static { - $this->ensureNoTimeoutOnEmbeddedQuery($query); + $this->ensureCanEmbedQuery($query); $type = $not ? 'NotExists' : 'Exists'; @@ -2254,8 +2252,8 @@ public function groupByRaw(string $sql, array $bindings = []): static */ public function having( ExpressionContract|Closure|string $column, - DateTimeInterface|string|int|float|null $operator = null, - ExpressionContract|DateTimeInterface|string|int|float|null $value = null, + mixed $operator = null, + mixed $value = null, string $boolean = 'and', ): static { $type = 'Basic'; @@ -2306,8 +2304,8 @@ public function having( */ public function orHaving( ExpressionContract|Closure|string $column, - DateTimeInterface|string|int|float|null $operator = null, - ExpressionContract|DateTimeInterface|string|int|float|null $value = null, + mixed $operator = null, + mixed $value = null, ): static { [$value, $operator] = $this->prepareValueAndOperator( $value, @@ -2393,6 +2391,8 @@ public function havingBetween(string $column, iterable $values, string $boolean $values = $this->resolveDatePeriodBounds($values); } + $values = is_array($values) ? $values : iterator_to_array($values, false); + $this->havings[] = compact('type', 'column', 'values', 'boolean', 'not'); $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'having'); @@ -2764,7 +2764,7 @@ public function union(Closure|self|EloquentBuilder $query, bool $all = false): s $query = $query->toBase(); } - $this->ensureNoTimeoutOnEmbeddedQuery($query); + $this->ensureCanEmbedQuery($query); $this->unions[] = compact('query', 'all'); @@ -3161,9 +3161,17 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): */ public function getCountForPagination(array $columns = ['*']): int { - $results = $this->withoutFetchUsing( - fn () => $this->runPaginationCountQuery($columns) - ); + $results = $this->withoutFetchUsing(function () use ($columns) { + $query = $this; + + // Count preparation needs the completed clauses without consuming the page's callbacks. + if ($this->beforeQueryCallbacks !== []) { + $query = $this->clone(); + $query->applyBeforeQueryCallbacks(); + } + + return $query->runPaginationCountQuery($columns); + }); // Once we have run the pagination count query, we will get the resulting count and // take into account what type of query it was. When there is a group by we will @@ -3192,15 +3200,18 @@ protected function runPaginationCountQuery(array $columns = ['*']): array // The clone becomes an inner derived table, so its timeout belongs on the executed count statement. $countQuery->timeout = $clone->timeout; + $countQuery->useWritePdo = $clone->useWritePdo; $clone->timeout = null; if (is_null($clone->columns) && ! empty($this->joins)) { $clone->select($clone->getDefaultSelectColumn()); } + $sql = $clone->toSql(); + + // Inner bindings belong to the derived table, not outer clauses that aggregation may clear. return $countQuery - ->from(new Expression('(' . $clone->toSql() . ') as ' . $this->grammar->wrap('aggregate_table'))) - ->mergeBindings($clone) + ->fromRaw('(' . $sql . ') as ' . $this->grammar->wrap('aggregate_table'), $clone->getBindings()) ->setAggregate('count', $this->withoutSelectAliases($columns)) ->get()->all(); } @@ -3216,7 +3227,7 @@ protected function runPaginationCountQuery(array $columns = ['*']): array /** * Clone the existing query instance for usage in a pagination subquery. */ - protected function cloneForPaginationCount(): self + protected function cloneForPaginationCount(): static { return $this->cloneWithout(['orders', 'limit', 'offset']) ->cloneWithoutBindings(['order']); @@ -3988,13 +3999,16 @@ public function truncate(): void /** * Get a new instance of the query builder. */ - public function newQuery(): self + public function newQuery(): static { + // @phpstan-ignore return.type (Constructor arguments do not carry the template types bound by the subclass.) return new static($this->connection, $this->grammar, $this->processor); } /** * Create a new query instance for a sub-query. + * + * @return self */ protected function forSubQuery(): self { @@ -4064,17 +4078,7 @@ public function getBindings(): array /** * Get the raw array of bindings. * - * @return array{ - * select: list, - * from: list, - * join: list, - * where: list, - * groupBy: list, - * having: list, - * order: list, - * union: list, - * unionOrder: list, - * } + * @return array> */ public function getRawBindings(): array { @@ -4085,7 +4089,7 @@ public function getRawBindings(): array * Set the bindings on the query builder. * * @param list $bindings - * @param "from"|"groupBy"|"having"|"join"|"order"|"select"|"union"|"unionOrder"|"where" $type + * @param TBindingType $type * * @throws InvalidArgumentException */ @@ -4103,7 +4107,7 @@ public function setBindings(array $bindings, string $type = 'where'): static /** * Add a binding to the query. * - * @param "from"|"groupBy"|"having"|"join"|"order"|"select"|"union"|"unionOrder"|"where" $type + * @param TBindingType $type * * @throws InvalidArgumentException */ @@ -4231,7 +4235,7 @@ public function useWritePdo(): static * * @return $this * - * @phpstan-this-out self + * @phpstan-this-out self */ public function fetchUsing(mixed ...$fetchUsing): static { @@ -4252,11 +4256,11 @@ protected function isQueryable(mixed $value): bool } /** - * Ensure an embedded query does not carry a statement-level timeout. + * Ensure the query can be embedded in another statement. * * @throws InvalidArgumentException */ - protected function ensureNoTimeoutOnEmbeddedQuery(self $query): void + protected function ensureCanEmbedQuery(self $query): void { if ($query->timeout !== null) { throw new InvalidArgumentException( @@ -4287,6 +4291,8 @@ public function cloneWithout(array $properties): static /** * Clone the query without the given bindings. + * + * @param list $except */ public function cloneWithoutBindings(array $except): static { diff --git a/src/database/src/Query/JoinClause.php b/src/database/src/Query/JoinClause.php index 955c86120..0bc5f78b7 100644 --- a/src/database/src/Query/JoinClause.php +++ b/src/database/src/Query/JoinClause.php @@ -50,7 +50,8 @@ public function __construct(Builder $parentQuery, string $type, ExpressionContra { $this->type = $type; $this->table = $table; - $this->parentClass = get_class($parentQuery); + // Parent queries need the root builder's constructor, not a join constructor. + $this->parentClass = $parentQuery instanceof self ? $parentQuery->parentClass : get_class($parentQuery); $this->parentGrammar = $parentQuery->getGrammar(); $this->parentProcessor = $parentQuery->getProcessor(); $this->parentConnection = $parentQuery->getConnection(); diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index fdc6ddf93..9058ebdaa 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -21,6 +21,9 @@ use function Hypervel\Support\enum_value; +/** + * @template TColumnDefinition of ColumnDefinition = ColumnDefinition + */ class Blueprint { use Macroable; @@ -707,6 +710,8 @@ public function foreign(array|string $columns, ?string $name = null): ForeignKey /** * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). + * + * @return TColumnDefinition */ public function id(string $column = 'id'): ColumnDefinition { @@ -715,6 +720,8 @@ public function id(string $column = 'id'): ColumnDefinition /** * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). + * + * @return TColumnDefinition */ public function increments(string $column): ColumnDefinition { @@ -723,6 +730,8 @@ public function increments(string $column): ColumnDefinition /** * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). + * + * @return TColumnDefinition */ public function integerIncrements(string $column): ColumnDefinition { @@ -731,6 +740,8 @@ public function integerIncrements(string $column): ColumnDefinition /** * Create a new auto-incrementing tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255). + * + * @return TColumnDefinition */ public function tinyIncrements(string $column): ColumnDefinition { @@ -739,6 +750,8 @@ public function tinyIncrements(string $column): ColumnDefinition /** * Create a new auto-incrementing small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535). + * + * @return TColumnDefinition */ public function smallIncrements(string $column): ColumnDefinition { @@ -747,6 +760,8 @@ public function smallIncrements(string $column): ColumnDefinition /** * Create a new auto-incrementing medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215). + * + * @return TColumnDefinition */ public function mediumIncrements(string $column): ColumnDefinition { @@ -755,6 +770,8 @@ public function mediumIncrements(string $column): ColumnDefinition /** * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). + * + * @return TColumnDefinition */ public function bigIncrements(string $column): ColumnDefinition { @@ -763,6 +780,8 @@ public function bigIncrements(string $column): ColumnDefinition /** * Create a new char column on the table. + * + * @return TColumnDefinition */ public function char(string $column, ?int $length = null): ColumnDefinition { @@ -773,6 +792,8 @@ public function char(string $column, ?int $length = null): ColumnDefinition /** * Create a new string column on the table. + * + * @return TColumnDefinition */ public function string(string $column, ?int $length = null): ColumnDefinition { @@ -783,6 +804,8 @@ public function string(string $column, ?int $length = null): ColumnDefinition /** * Create a new tiny text column on the table (up to 255 bytes on MySQL/MariaDB). + * + * @return TColumnDefinition */ public function tinyText(string $column): ColumnDefinition { @@ -791,6 +814,8 @@ public function tinyText(string $column): ColumnDefinition /** * Create a new text column on the table (up to 65,535 bytes on MySQL/MariaDB). + * + * @return TColumnDefinition */ public function text(string $column): ColumnDefinition { @@ -799,6 +824,8 @@ public function text(string $column): ColumnDefinition /** * Create a new medium text column on the table (up to 16,777,215 bytes on MySQL/MariaDB). + * + * @return TColumnDefinition */ public function mediumText(string $column): ColumnDefinition { @@ -807,6 +834,8 @@ public function mediumText(string $column): ColumnDefinition /** * Create a new long text column on the table (up to 4,294,967,295 bytes on MySQL/MariaDB). + * + * @return TColumnDefinition */ public function longText(string $column): ColumnDefinition { @@ -816,6 +845,8 @@ public function longText(string $column): ColumnDefinition /** * Create a new integer column on the table. * MySQL/MariaDB (4-byte): -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned). + * + * @return TColumnDefinition */ public function integer(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -825,6 +856,8 @@ public function integer(string $column, bool $autoIncrement = false, bool $unsig /** * Create a new tiny integer column on the table. * MySQL/MariaDB (1-byte): -128 to 127 (signed) or 0 to 255 (unsigned). + * + * @return TColumnDefinition */ public function tinyInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -834,6 +867,8 @@ public function tinyInteger(string $column, bool $autoIncrement = false, bool $u /** * Create a new small integer column on the table. * MySQL/MariaDB (2-byte): -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned). + * + * @return TColumnDefinition */ public function smallInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -843,6 +878,8 @@ public function smallInteger(string $column, bool $autoIncrement = false, bool $ /** * Create a new medium integer column on the table. * MySQL/MariaDB (3-byte): -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned). + * + * @return TColumnDefinition */ public function mediumInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -852,6 +889,8 @@ public function mediumInteger(string $column, bool $autoIncrement = false, bool /** * Create a new big integer column on the table. * MySQL/MariaDB (8-byte): -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned). + * + * @return TColumnDefinition */ public function bigInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -860,6 +899,8 @@ public function bigInteger(string $column, bool $autoIncrement = false, bool $un /** * Create a new unsigned integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). + * + * @return TColumnDefinition */ public function unsignedInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -868,6 +909,8 @@ public function unsignedInteger(string $column, bool $autoIncrement = false): Co /** * Create a new unsigned tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255). + * + * @return TColumnDefinition */ public function unsignedTinyInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -876,6 +919,8 @@ public function unsignedTinyInteger(string $column, bool $autoIncrement = false) /** * Create a new unsigned small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535). + * + * @return TColumnDefinition */ public function unsignedSmallInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -884,6 +929,8 @@ public function unsignedSmallInteger(string $column, bool $autoIncrement = false /** * Create a new unsigned medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215). + * + * @return TColumnDefinition */ public function unsignedMediumInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -892,6 +939,8 @@ public function unsignedMediumInteger(string $column, bool $autoIncrement = fals /** * Create a new unsigned big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). + * + * @return TColumnDefinition */ public function unsignedBigInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -957,6 +1006,8 @@ public function foreignUuidFor(Model|string $model, ?string $column = null): For /** * Create a new float column on the table. + * + * @return TColumnDefinition */ public function float(string $column, int $precision = 53): ColumnDefinition { @@ -965,6 +1016,8 @@ public function float(string $column, int $precision = 53): ColumnDefinition /** * Create a new double column on the table. + * + * @return TColumnDefinition */ public function double(string $column): ColumnDefinition { @@ -973,6 +1026,8 @@ public function double(string $column): ColumnDefinition /** * Create a new decimal column on the table. + * + * @return TColumnDefinition */ public function decimal(string $column, int $total = 8, int $places = 2): ColumnDefinition { @@ -981,6 +1036,8 @@ public function decimal(string $column, int $total = 8, int $places = 2): Column /** * Create a new boolean column on the table. + * + * @return TColumnDefinition */ public function boolean(string $column): ColumnDefinition { @@ -989,6 +1046,8 @@ public function boolean(string $column): ColumnDefinition /** * Create a new enum column on the table. + * + * @return TColumnDefinition */ public function enum(string $column, array $allowed): ColumnDefinition { @@ -999,6 +1058,8 @@ public function enum(string $column, array $allowed): ColumnDefinition /** * Create a new set column on the table. + * + * @return TColumnDefinition */ public function set(string $column, array $allowed): ColumnDefinition { @@ -1007,6 +1068,8 @@ public function set(string $column, array $allowed): ColumnDefinition /** * Create a new json column on the table. + * + * @return TColumnDefinition */ public function json(string $column): ColumnDefinition { @@ -1015,6 +1078,8 @@ public function json(string $column): ColumnDefinition /** * Create a new jsonb column on the table. + * + * @return TColumnDefinition */ public function jsonb(string $column): ColumnDefinition { @@ -1023,6 +1088,8 @@ public function jsonb(string $column): ColumnDefinition /** * Create a new date column on the table. + * + * @return TColumnDefinition */ public function date(string $column): ColumnDefinition { @@ -1031,6 +1098,8 @@ public function date(string $column): ColumnDefinition /** * Create a new date-time column on the table. + * + * @return TColumnDefinition */ public function dateTime(string $column, ?int $precision = null): ColumnDefinition { @@ -1041,6 +1110,8 @@ public function dateTime(string $column, ?int $precision = null): ColumnDefiniti /** * Create a new date-time column (with time zone) on the table. + * + * @return TColumnDefinition */ public function dateTimeTz(string $column, ?int $precision = null): ColumnDefinition { @@ -1051,6 +1122,8 @@ public function dateTimeTz(string $column, ?int $precision = null): ColumnDefini /** * Create a new time column on the table. + * + * @return TColumnDefinition */ public function time(string $column, ?int $precision = null): ColumnDefinition { @@ -1061,6 +1134,8 @@ public function time(string $column, ?int $precision = null): ColumnDefinition /** * Create a new time column (with time zone) on the table. + * + * @return TColumnDefinition */ public function timeTz(string $column, ?int $precision = null): ColumnDefinition { @@ -1071,6 +1146,8 @@ public function timeTz(string $column, ?int $precision = null): ColumnDefinition /** * Create a new timestamp column on the table. + * + * @return TColumnDefinition */ public function timestamp(string $column, ?int $precision = null): ColumnDefinition { @@ -1081,6 +1158,8 @@ public function timestamp(string $column, ?int $precision = null): ColumnDefinit /** * Create a new timestamp (with time zone) column on the table. + * + * @return TColumnDefinition */ public function timestampTz(string $column, ?int $precision = null): ColumnDefinition { @@ -1092,7 +1171,7 @@ public function timestampTz(string $column, ?int $precision = null): ColumnDefin /** * Add nullable creation and update timestamps to the table. * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Support\Collection */ public function timestamps(?int $precision = null): Collection { @@ -1107,7 +1186,7 @@ public function timestamps(?int $precision = null): Collection * * Alias for self::timestamps(). * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Support\Collection */ public function nullableTimestamps(?int $precision = null): Collection { @@ -1117,7 +1196,7 @@ public function nullableTimestamps(?int $precision = null): Collection /** * Add nullable creation and update timestampTz columns to the table. * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Support\Collection */ public function timestampsTz(?int $precision = null): Collection { @@ -1132,7 +1211,7 @@ public function timestampsTz(?int $precision = null): Collection * * Alias for self::timestampsTz(). * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Support\Collection */ public function nullableTimestampsTz(?int $precision = null): Collection { @@ -1142,7 +1221,7 @@ public function nullableTimestampsTz(?int $precision = null): Collection /** * Add creation and update datetime columns to the table. * - * @return \Hypervel\Support\Collection + * @return \Hypervel\Support\Collection */ public function datetimes(?int $precision = null): Collection { @@ -1154,6 +1233,8 @@ public function datetimes(?int $precision = null): Collection /** * Add a "deleted at" timestamp for the table. + * + * @return TColumnDefinition */ public function softDeletes(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition { @@ -1162,6 +1243,8 @@ public function softDeletes(string $column = 'deleted_at', ?int $precision = nul /** * Add a "deleted at" timestampTz for the table. + * + * @return TColumnDefinition */ public function softDeletesTz(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition { @@ -1170,6 +1253,8 @@ public function softDeletesTz(string $column = 'deleted_at', ?int $precision = n /** * Add a "deleted at" datetime column to the table. + * + * @return TColumnDefinition */ public function softDeletesDatetime(string $column = 'deleted_at', ?int $precision = null): ColumnDefinition { @@ -1178,6 +1263,8 @@ public function softDeletesDatetime(string $column = 'deleted_at', ?int $precisi /** * Create a new year column on the table. + * + * @return TColumnDefinition */ public function year(string $column): ColumnDefinition { @@ -1186,6 +1273,8 @@ public function year(string $column): ColumnDefinition /** * Create a new binary column on the table. + * + * @return TColumnDefinition */ public function binary(string $column, ?int $length = null, bool $fixed = false): ColumnDefinition { @@ -1194,6 +1283,8 @@ public function binary(string $column, ?int $length = null, bool $fixed = false) /** * Create a new UUID column on the table. + * + * @return TColumnDefinition */ public function uuid(string $column = 'uuid'): ColumnDefinition { @@ -1213,6 +1304,8 @@ public function foreignUuid(string $column): ForeignIdColumnDefinition /** * Create a new ULID column on the table. + * + * @return TColumnDefinition */ public function ulid(string $column = 'ulid', ?int $length = 26): ColumnDefinition { @@ -1233,6 +1326,8 @@ public function foreignUlid(string $column, ?int $length = 26): ForeignIdColumnD /** * Create a new IP address column on the table. + * + * @return TColumnDefinition */ public function ipAddress(string $column = 'ip_address'): ColumnDefinition { @@ -1241,6 +1336,8 @@ public function ipAddress(string $column = 'ip_address'): ColumnDefinition /** * Create a new MAC address column on the table. + * + * @return TColumnDefinition */ public function macAddress(string $column = 'mac_address'): ColumnDefinition { @@ -1249,6 +1346,8 @@ public function macAddress(string $column = 'mac_address'): ColumnDefinition /** * Create a new geometry column on the table. + * + * @return TColumnDefinition */ public function geometry(string $column, ?string $subtype = null, int $srid = 0): ColumnDefinition { @@ -1257,6 +1356,8 @@ public function geometry(string $column, ?string $subtype = null, int $srid = 0) /** * Create a new geography column on the table. + * + * @return TColumnDefinition */ public function geography(string $column, ?string $subtype = null, int $srid = 4326): ColumnDefinition { @@ -1265,6 +1366,8 @@ public function geography(string $column, ?string $subtype = null, int $srid = 4 /** * Create a new generated, computed column on the table. + * + * @return TColumnDefinition */ public function computed(string $column, string $expression): ColumnDefinition { @@ -1273,6 +1376,8 @@ public function computed(string $column, string $expression): ColumnDefinition /** * Create a new vector column on the table. + * + * @return TColumnDefinition */ public function vector(string $column, ?int $dimensions = null): ColumnDefinition { @@ -1283,6 +1388,8 @@ public function vector(string $column, ?int $dimensions = null): ColumnDefinitio /** * Create a new tsvector column on the table. + * + * @return TColumnDefinition */ public function tsvector(string $column): ColumnDefinition { @@ -1409,6 +1516,8 @@ public function nullableUlidMorphs(string $name, ?string $indexName = null, ?str /** * Add the `remember_token` column to the table. + * + * @return TColumnDefinition */ public function rememberToken(): ColumnDefinition { @@ -1417,6 +1526,8 @@ public function rememberToken(): ColumnDefinition /** * Create a new custom column on the table. + * + * @return TColumnDefinition */ public function rawColumn(string $column, string $definition): ColumnDefinition { @@ -1488,21 +1599,33 @@ protected function createIndexName(string $type, array $columns): string /** * Add a new column to the blueprint. + * + * @return TColumnDefinition */ public function addColumn(string $type, string $name, array $parameters = []): ColumnDefinition { - return $this->addColumnDefinition(new ColumnDefinition( + return $this->addColumnDefinition($this->newColumnDefinition( array_merge(compact('type', 'name'), $parameters) )); } + /** + * Create a new column definition. + * + * @return TColumnDefinition + */ + protected function newColumnDefinition(array $attributes): ColumnDefinition + { + return new ColumnDefinition($attributes); + } + /** * Add a new column definition to the blueprint. * - * @template TColumnDefinition of \Hypervel\Database\Schema\ColumnDefinition + * @template TDefinition of \Hypervel\Database\Schema\ColumnDefinition * - * @param TColumnDefinition $definition - * @return TColumnDefinition + * @param TDefinition $definition + * @return TDefinition */ protected function addColumnDefinition(ColumnDefinition $definition): ColumnDefinition { @@ -1605,7 +1728,7 @@ public function getGrammar(): Grammar /** * Get the columns on the blueprint. * - * @return \Hypervel\Database\Schema\ColumnDefinition[] + * @return list<\Hypervel\Database\Schema\ColumnDefinition> */ public function getColumns(): array { @@ -1641,7 +1764,7 @@ public function getState(): ?BlueprintState /** * Get the columns on the blueprint that should be added. * - * @return \Hypervel\Database\Schema\ColumnDefinition[] + * @return array */ public function getAddedColumns(): array { diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index ad297f980..8f2d30eb6 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -481,6 +481,21 @@ public function create(string $table, Closure $callback): void })); } + /** + * Create the migration repository table for this database driver. + */ + public function createMigrationRepositoryTable(string $table): void + { + $this->create($table, function (Blueprint $blueprint): void { + // The migrations table is responsible for keeping track of which of the + // migrations have actually run for the application. We'll create the + // table to hold the migration name as well as the batch ID. + $blueprint->increments('id'); + $blueprint->string('migration'); + $blueprint->integer('batch'); + }); + } + /** * Drop a table from the schema. */ @@ -543,6 +558,23 @@ public function dropAllTypes(): void throw new LogicException('This database driver does not support dropping all types.'); } + /** + * Truncate the given tables if they contain rows. + * + * @param list $tables + */ + public function truncateTables(array $tables): void + { + foreach ($tables as $table) { + // A stale read replica must not skip cleanup of rows on the write connection. + $query = $this->connection->table($table)->useWritePdo(); + + if ($query->exists()) { + $query->truncate(); + } + } + } + /** * Rename a table on the schema. */ diff --git a/src/database/src/StreamClosedException.php b/src/database/src/StreamClosedException.php new file mode 100644 index 000000000..bd38703eb --- /dev/null +++ b/src/database/src/StreamClosedException.php @@ -0,0 +1,11 @@ + ### Combining Database Reset Traits diff --git a/src/docs/database.md b/src/docs/database.md index 5cdf1c8d7..08b61bcaa 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -15,6 +15,7 @@ - [Monitoring Cumulative Query Time](#monitoring-cumulative-query-time) - [Database Transactions](#database-transactions) - [Connecting to the Database CLI](#connecting-to-the-database-cli) + - [Custom Database Clients](#custom-database-clients) - [Inspecting Your Databases](#inspecting-your-databases) - [Monitoring Your Databases](#monitoring-your-databases) @@ -221,7 +222,7 @@ For the full option reference and custom maintenance behavior, see the [pool doc For a connection with separate read and write hosts, each base pool slot may lazily open one write PDO and one read PDO. It does not open one PDO per configured host. If `max_connections` is `10`, a worker may therefore hold up to roughly 20 server-side database connections for that configured connection once both sides have been used. Size your database server, PgBouncer, PgDog, or other pooler capacity with that in mind. Increase `max_connections` for more concurrent database work per worker, not simply because you configured more read hosts. -Explicit `::read` connections use a separate read-side pool built from the merged read configuration, including the base `pool` settings unless the read configuration overrides them. Explicit `::write` connections do not create a separate pool, but a coroutine that uses both `mysql` and `mysql::write` at the same time may borrow two slots from the base pool. Most applications do not need these suffixes in normal query paths because Hypervel already routes reads, writes, transactions, and sticky reads automatically. +When a read side is configured, explicit `::read` connections use a separate read-side pool built from the merged read configuration, including the base `pool` settings unless the read configuration overrides them. Drivers registered through `DB::extend` still receive the complete connection configuration so they can select their own endpoints; the pool's options come from the merged read configuration. Without a read side, `::read` uses the base pool. Explicit `::write` connections do not create a separate pool, but a coroutine that uses both `mysql` and `mysql::write` at the same time may borrow two slots from the base pool. Most applications do not need these suffixes in normal query paths because Hypervel already routes reads, writes, transactions, and sticky reads automatically. Heartbeat and max lifetime recycling apply to Hypervel's worker pool whether the connection points directly at the database or through a proxy / pooler. They help long-running workers avoid stale sockets and rotate old idle connection generations before those connections are used by a request. @@ -349,12 +350,34 @@ DB::extend('clickhouse', function (array $config, ?string $name): Connection { }); ``` -The extension name may be a driver name or a configured connection name. A connection-specific extension takes precedence over a driver extension. The configuration includes the normalized `connect_timeout` value, allowing the driver to apply the pool's connection deadline to its client. +The extension name may be a driver name or a configured connection name. A connection-specific extension takes precedence over a driver extension. The resolver receives the complete connection configuration with its base URL parsed and any `read` and `write` records retained. The driver owns endpoint selection and parsing of role-specific URLs. Pooled connections also include the normalized `connect_timeout` value, allowing the driver to apply the pool's connection deadline to its client. Custom connections implement their own query execution, transactions, escaping, health check, reconnection, and cleanup behavior. They must also return their driver key, such as `clickhouse`, from the protected `getDefaultDriverName` method. Hypervel uses this value when the connection has no configured driver name; an explicitly configured driver name still takes precedence. Hypervel's database pool calls these connection methods without assuming PDO, so a native or HTTP driver does not need to create a fake PDO instance. +Drivers with separate read and write resources may call the protected `resolveReadWriteType` method before selecting a resource. It returns `read` or `write`, honoring active transactions, forced write routing, and sticky reads. Pass `false` for a write operation. The method also records the selected role for query events and exceptions. Resource lookup remains the driver's responsibility; if a read falls back to the write resource, record that fallback in `latestReadWriteTypeRetrieved`. + +An explicit `::read` connection with a configured read side carries `read_write_type = 'read'`. The driver must use that side for every operation, including statements and forced-write reads, just as Hypervel's PDO drivers do. Without a read side, use the connection's ordinary fallback behavior; direct resolution may still supply the read marker. Do not require a write marker for `::write`: pooled resolution forces write routing through `useWriteConnectionWhenReading`, which the role resolver already honors. + +Use the protected `run` method when your execution callback returns a completed response. If execution continues while rows are consumed, use `runStreaming` instead. It accepts the same query, bindings, and callback arguments, with the callback returning an iterable. Hooks and execution begin when iteration starts, and success is logged only after normal exhaustion. The recorded duration includes consumer work between yields, which also counts toward cumulative query-duration thresholds. Iteration failures follow the normal query-exception and `QueryFailed` paths; cancellation passes through unchanged. The callback remains responsible for closing its resources in a `finally` block when iteration ends or is abandoned. + +If an active stream is explicitly closed, its next advancement must throw `Hypervel\Database\StreamClosedException` instead of ending as though the query completed. `runStreaming` passes this exception through unchanged, without incrementing the connection's error count, logging a successful query, or dispatching query events. Resource cleanup still belongs in the driver's `finally` block. + +`runStreaming` calls the existing lost-connection retry hook only before the first value has been yielded. Drivers must also reject retries when their operation cannot be replayed safely, including when a progress callback has already exposed part of the response. The streaming boundary restores the outer operation's role when a consumer resumes it after running another query. Existing PDO cursors retain their execute-time event boundary. + +Both execution methods construct database errors through the protected `newQueryException` method. Drivers whose parameter types need custom error formatting may override this method to return a `QueryException` subclass. Preserve the original query, bindings, and cause; exceptions that are not database failures may be rethrown unchanged. The base implementation continues to prepare bindings and enrich unique-constraint errors with the reported index or columns. + +Query builders with statement-level options may override the protected `Query\Builder::ensureCanEmbedQuery` method to reject options that belong on the outer statement. It runs when attaching a subquery, scalar or exists predicate, or union member; call the parent to preserve the built-in rejection of embedded timeouts. + +The migration repository delegates its table definition to `Schema\Builder::createMigrationRepositoryTable`. A driver may override this method when it needs a different physical schema, while retaining the standard repository and migration commands. Its table must support storing migration names and integer batch numbers; the default definition also includes an auto-incrementing `id`. + +The native `DatabaseTruncation` testing trait delegates to `Schema\Builder::truncateTables` after applying its table filters. It passes the complete list of selected schema-qualified names with the connection's table prefix temporarily disabled. The default implementation checks for rows on the write connection and truncates non-empty tables through the query builder, so replica lag cannot skip cleanup. Drivers with engine-specific reset behavior may override this bulk method while keeping `getTables` accurate and using the native testing traits. If a selected table cannot be safely reset, throw an exception instead of silently leaving test data behind. + +To add column modifiers, a `Schema\Blueprint` subclass may override the protected `newColumnDefinition(array $attributes)` method and return its own `ColumnDefinition` subclass. Declare `@extends Blueprint` on the Blueprint subclass so static analysis recognizes the custom return type from inherited helpers such as `string`, `unsignedBigInteger`, and `timestamps`. Foreign-ID helpers retain their specialized definition and constraint methods. Column-list accessors continue to return base definitions because a Blueprint may contain several definition types. + +Custom query builders may declare their binding-slot names through the third `Query\Builder` template argument, after the result key and row types. Initialize the additional keys in the builder's `bindings` array; the existing binding methods validate those keys at runtime. The `newQuery`, `forNestedWhere`, and protected `cloneForPaginationCount` methods return `static`, retaining the concrete builder and its binding types. Overrides must preserve that return contract. The protected `forSubQuery` method returns a base query builder because a join's subquery belongs to its parent query, not the join itself. + ### Static Analysis @@ -737,7 +760,37 @@ If the connection has separate read and write hosts, you may connect to either h php artisan db mysql --read ``` -The `--read` and `--write` options understand list-style read / write configuration and host arrays. When a side contains multiple hosts, the command connects to the first configured host for that side. +The `--read` and `--write` options understand list-style read / write configuration and host arrays. The command selects the first record for the requested side, applies that record's URL if present, and selects its first host. URL values override matching connection options, while omitted values remain inherited. A base connection with a host array also uses its first host when neither option is given. + + +### Custom Database Clients + +Custom database drivers may support the `db` command by registering a resolver with `DatabaseCliManager` in a service provider's `boot` method. The resolver returns the executable, argument list, and optional environment variables for the client: + +```php +use Hypervel\Database\DatabaseCliConfiguration; +use Hypervel\Database\DatabaseCliManager; + +/** + * Bootstrap any application services. + */ +public function boot(DatabaseCliManager $clients): void +{ + $clients->extend('analytics', function (array $connection): DatabaseCliConfiguration { + return new DatabaseCliConfiguration( + command: 'analytics-client', + arguments: [$connection['database']], + environment: ['ANALYTICS_HOST' => $connection['host']], + ); + }); +} +``` + +The resolver receives the connection configuration after URL parsing, read/write selection, and host-list selection. It is called once per command invocation and does not need to open a database connection. Validate any requirements specific to your client in the resolver; a client that uses a local file or socket does not need a host. + +Pass arguments as separate list entries, not a shell command string. Use the environment map for credentials when the client supports it. Both arguments and environment variables default to empty arrays. + +Resolvers remain registered for the worker lifetime, so register them only during application boot. Registering a resolver for a built-in driver replaces its client configuration. Otherwise, built-in drivers continue through `DbCommand`'s existing argument and environment helpers, including any subclass overrides. ## Inspecting Your Databases diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 5868315a1..2a0323283 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -13,7 +13,6 @@ - [Service Providers](#service-providers) - [Registering Bindings](#registering-bindings) - [Bootstrapping Services](#bootstrapping-services) - - [Conditional Providers](#conditional-providers) - [Deferred Providers](#deferred-providers) - [Coroutine Safety](#coroutine-safety) - [Request-Specific State](#request-specific-state) @@ -310,23 +309,6 @@ public function boot(): void Do not store request-specific state on service provider properties. When serving HTTP requests, the application registers and boots its providers once before the server workers are forked, not once per request. - -### Conditional Providers - -Hypervel service providers may override the `isEnabled` method to opt out of registration based on configuration, environment, or feature flags. When this method returns `false`, the provider's `register` and `boot` methods will not be called: - -```php -/** - * Determine whether this provider should be registered and booted. - */ -public function isEnabled(): bool -{ - return config()->boolean('courier.enabled', false); -} -``` - -Hypervel calls `isEnabled` before the provider's `register` method. Configuration merged by that provider is not available yet, so this method may only read configuration already loaded by the application or framework. The fallback above is intentional because an unpublished package option may be absent. - ### Deferred Providers @@ -576,6 +558,8 @@ When a package constructs `DatabaseStore`, `DatabaseSessionHandler`, `DatabaseQu Laravel's base `Connection` class exposes PDO methods. Hypervel's base `Connection` is driver-neutral, while its built-in SQL connections extend `PdoConnection`. Ported code that calls `getPdo`, `getReadPdo`, or another PDO-specific method should accept or narrow to `PdoConnection`. See [extending database connections](/docs/{{version}}/database#extending-database-connections) when porting a custom driver. +Custom query builders overriding `newQuery`, `forNestedWhere`, or `cloneForPaginationCount` must declare `static` returns and preserve the concrete builder class. Keep `forSubQuery` separate: join subqueries return the parent query builder. See the [database extension guide](/docs/{{version}}/database#extending-database-connections) for these return contracts. + Laravel's nested `direct` connection endpoint and `::direct` suffix are not available. Configure the direct endpoint as a normal named connection and point the pooled connection's `migrations_connection` option at it. Model casts are not applied to direct query builder operations or Eloquent key helpers. When ported code passes already-encoded binary strings to query builder `where`, bulk `update`, or `upsert` calls, or to Eloquent `find`, `whereKey`, or `whereKeyNot`, wrap them in `Hypervel\Database\BinaryParameter`. See [binding binary values](/docs/{{version}}/database#binding-binary-values) and [binary casting](/docs/{{version}}/eloquent-mutators#binary-casting). @@ -596,7 +580,7 @@ Hypervel provides Redis, database, file, filesystem storage, Swoole table, sessi For local in-memory caching, use the [Swoole table cache](/docs/{{version}}/cache#swoole-table-cache). A Swoole table is shared by the workers on one application node. For applications running across several nodes, the [stack cache](/docs/{{version}}/cache#building-cache-stacks) may combine a short-lived Swoole L1 cache with a shared Redis L2 cache. `Cache::memo()` may also wrap a store with per-coroutine memoization at runtime. -The Redis cache store supports two tag modes. The default `all` mode follows Laravel's classic tagged-cache behavior. In `any` mode, tags are invalidation indexes: retrieve values by their plain keys, and flushing any one assigned tag removes the value. Review the [Redis tag mode documentation](/docs/{{version}}/cache#redis-tag-modes) before changing `REDIS_CACHE_TAG_MODE`. +If your application uses Redis cache tags, review [Redis Tag Modes](/docs/{{version}}/cache#redis-tag-modes) before porting. Hypervel's tagged-cache storage is not interchangeable with Laravel's. Custom cache tag sets must declare `TagSet::reset(): bool` and `TagSet::flush(): bool`. Hypervel uses these results to report a rejected tagged flush instead of returning unconditional success. Custom `VersionedTagSet` subclasses should override `writeTagId()` for bulk reset persistence; `resetTag()` keeps returning the generated identifier. diff --git a/src/foundation/src/Testing/DatabaseTransactions.php b/src/foundation/src/Testing/DatabaseTransactions.php index 7dcfa9659..e8d3576c4 100644 --- a/src/foundation/src/Testing/DatabaseTransactions.php +++ b/src/foundation/src/Testing/DatabaseTransactions.php @@ -82,10 +82,13 @@ protected function beginDatabaseTransactionWork(): void $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); - $connection->beginTransaction(); - if ($dispatcher !== null) { - $connection->setEventDispatcher($dispatcher); + try { + $connection->beginTransaction(); + } finally { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } } } } @@ -103,14 +106,16 @@ protected function rollbackDatabaseTransactionWork(): void $connection->unsetEventDispatcher(); - if ($connection instanceof DatabaseConnection) { - $connection->forgetRecordModificationState(); - } - - $connection->rollBack(); + try { + if ($connection instanceof DatabaseConnection) { + $connection->forgetRecordModificationState(); + } - if ($dispatcher !== null) { - $connection->setEventDispatcher($dispatcher); + $connection->rollBack(); + } finally { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } } } } diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 939b62232..c0193e9f6 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -222,29 +222,30 @@ protected function truncateTablesForConnection(ConnectionInterface $connection, $connection->unsetEventDispatcher(); - (new Collection($this->getAllTablesForConnection($connection, $name))) - ->when( - $this->tablesToTruncate($connection, $name), - function (Collection $tables, array $tablesToTruncate) { - return $tables->filter(fn (array $table) => $this->tableExistsIn($table, $tablesToTruncate)); - }, - function (Collection $tables) use ($connection, $name) { - $exceptTables = $this->exceptTables($connection, $name); - - return $tables->reject(fn (array $table) => $this->tableExistsIn($table, $exceptTables)); - } - ) - ->each(function (array $table) use ($connection) { - $connection->withoutTablePrefix(function ($connection) use ($table) { - $table = $connection->table($table['schema_qualified_name']); - - if ($table->exists()) { - $table->truncate(); + try { + $tables = (new Collection($this->getAllTablesForConnection($connection, $name))) + ->when( + $this->tablesToTruncate($connection, $name), + function (Collection $tables, array $tablesToTruncate) { + return $tables->filter(fn (array $table) => $this->tableExistsIn($table, $tablesToTruncate)); + }, + function (Collection $tables) use ($connection, $name) { + $exceptTables = $this->exceptTables($connection, $name); + + return $tables->reject(fn (array $table) => $this->tableExistsIn($table, $exceptTables)); } - }); - }); + ) + ->pluck('schema_qualified_name') + ->all(); - $connection->setEventDispatcher($dispatcher); + $connection->withoutTablePrefix( + fn ($connection) => $connection->getSchemaBuilder()->truncateTables($tables) + ); + } finally { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } + } } /** diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index d9b875b08..fefc7f8f2 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -242,10 +242,13 @@ protected function beginDatabaseTransactionWork(): void $dispatcher = $connection->getEventDispatcher(); $connection->unsetEventDispatcher(); - $connection->beginTransaction(); - if ($dispatcher) { - $connection->setEventDispatcher($dispatcher); + try { + $connection->beginTransaction(); + } finally { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } } } } @@ -263,18 +266,20 @@ protected function rollbackDatabaseTransactionWork(): void $connection->unsetEventDispatcher(); - if (! $connection->inTransaction()) { - RefreshDatabaseState::$migrated = false; - } - - if ($connection instanceof DatabaseConnection) { - $connection->forgetRecordModificationState(); - } + try { + if (! $connection->inTransaction()) { + RefreshDatabaseState::$migrated = false; + } - $connection->rollBack(); + if ($connection instanceof DatabaseConnection) { + $connection->forgetRecordModificationState(); + } - if ($dispatcher) { - $connection->setEventDispatcher($dispatcher); + $connection->rollBack(); + } finally { + if ($dispatcher !== null) { + $connection->setEventDispatcher($dispatcher); + } } } } diff --git a/src/support/src/ConfigurationUrlParser.php b/src/support/src/ConfigurationUrlParser.php index 54521826d..03f1be5d3 100644 --- a/src/support/src/ConfigurationUrlParser.php +++ b/src/support/src/ConfigurationUrlParser.php @@ -43,10 +43,16 @@ public function parseConfiguration(array|string $config): array $rawComponents = $this->parseUrl($url); - $decodedComponents = $this->parseStringsToNativeTypes( - array_map('rawurldecode', $rawComponents) + $decodedComponents = array_map( + static fn (string|int $value): string|int => is_string($value) ? rawurldecode($value) : $value, + $rawComponents, ); + // The sqlite:/// rewrite and read/write URLs use "null" for an omitted host. + if (($decodedComponents['host'] ?? null) === 'null') { + $decodedComponents['host'] = null; + } + return array_merge( $config, $this->getPrimaryOptions($decodedComponents), diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index a845765ae..b7a36af7c 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -11,6 +11,7 @@ * @method static void blueprintResolver(\Closure $resolver) * @method static void create(string $table, \Closure $callback) * @method static bool createDatabase(string $name) + * @method static void createMigrationRepositoryTable(string $table) * @method static void defaultMorphKeyType(string $type) * @method static void defaultStringLength(int $length) * @method static void defaultTimePrecision(int|null $precision) @@ -57,6 +58,7 @@ * @method static string qualifyIndexName(string $name) * @method static void rename(string $from, string $to) * @method static void table(string $table, \Closure $callback) + * @method static void truncateTables(array $tables) * @method static void whenTableDoesntHaveColumn(string $table, string $column, \Closure $callback) * @method static void whenTableDoesntHaveIndex(string $table, array|string $index, \Closure $callback, string|null $type = null) * @method static void whenTableHasColumn(string $table, string $column, \Closure $callback) diff --git a/src/testbench/src/Concerns/InteractsWithMigrations.php b/src/testbench/src/Concerns/InteractsWithMigrations.php index e82d5bef2..bbf6c5361 100644 --- a/src/testbench/src/Concerns/InteractsWithMigrations.php +++ b/src/testbench/src/Concerns/InteractsWithMigrations.php @@ -33,9 +33,8 @@ trait InteractsWithMigrations protected function setUpInteractsWithMigrations(): void { if ($this->usesInMemoryDatabaseForMigrationState()) { - $this->afterApplicationCreated(static function (): void { - static::usesTestingFeature(new ResetRefreshDatabaseState); - }); + // A later setup hook may skip or fail before after-application callbacks run. + static::usesTestingFeature(new ResetRefreshDatabaseState); } } diff --git a/tests/Database/DatabaseCliManagerTest.php b/tests/Database/DatabaseCliManagerTest.php new file mode 100644 index 000000000..9496dd5d7 --- /dev/null +++ b/tests/Database/DatabaseCliManagerTest.php @@ -0,0 +1,80 @@ +assertNull($manager->resolve(['driver' => 'custom'])); + $this->assertNull($manager->resolve(['driver' => 'mysql'])); + } + + public function testResolverReceivesTheConnectionAndRunsOnce(): void + { + $manager = new DatabaseCliManager; + $connection = ['driver' => 'custom', 'database' => 'analytics']; + $configuration = new DatabaseCliConfiguration( + 'custom-client', + ['--database', 'analytics'], + ['CLIENT_PASSWORD' => 'secret', 'INHERITED_OPTION' => false], + ); + $calls = 0; + + $manager->extend('custom', function (array $resolved) use ($connection, $configuration, &$calls): DatabaseCliConfiguration { + ++$calls; + $this->assertSame($connection, $resolved); + + return $configuration; + }); + + $this->assertSame($configuration, $manager->resolve($connection)); + $this->assertSame(1, $calls); + $this->assertSame('custom-client', $configuration->command); + $this->assertSame(['--database', 'analytics'], $configuration->arguments); + $this->assertSame(['CLIENT_PASSWORD' => 'secret', 'INHERITED_OPTION' => false], $configuration->environment); + } + + public function testCallableObjectsCanResolveBuiltInDrivers(): void + { + $manager = new DatabaseCliManager; + $manager->extend('mysql', new class { + /** + * Build the replacement client configuration. + */ + public function __invoke(array $connection): DatabaseCliConfiguration + { + return new DatabaseCliConfiguration('custom-mysql', [$connection['database']]); + } + }); + + $configuration = $manager->resolve(['driver' => 'mysql', 'database' => 'app']); + + $this->assertNotNull($configuration); + $this->assertSame('custom-mysql', $configuration->command); + $this->assertSame(['app'], $configuration->arguments); + $this->assertSame([], $configuration->environment); + } + + public function testRegisteringAgainReplacesTheResolver(): void + { + $manager = new DatabaseCliManager; + $manager->extend('custom', static fn (array $connection): DatabaseCliConfiguration => new DatabaseCliConfiguration('first')); + $manager->extend('custom', static fn (array $connection): DatabaseCliConfiguration => new DatabaseCliConfiguration('second')); + + $configuration = $manager->resolve(['driver' => 'custom']); + + $this->assertNotNull($configuration); + $this->assertSame('second', $configuration->command); + $this->assertSame([], $configuration->arguments); + $this->assertSame([], $configuration->environment); + } +} diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index 75e9442be..8068de272 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -507,6 +507,28 @@ public function testConfigFirstExtensionCreatesNeutralConnectionWithoutResolving $this->assertSame('', $receivedConfig['prefix']); } + public function testConfigFirstExtensionReceivesLiteralUrlCredentials(): void + { + $factory = new ConnectionFactory(new Container); + $factory->extend('http', static fn (array $config): FactoryNonPdoConnection => new FactoryNonPdoConnection( + $config['database'], + $config['prefix'], + $config, + )); + + $connection = $factory->make([ + 'url' => 'http://0:18446744073709551615@true:8123/analytics', + 'username' => 'base-user', + 'password' => 'base-password', + ], 'analytics'); + + $this->assertInstanceOf(FactoryNonPdoConnection::class, $connection); + $this->assertSame('0', $connection->getConfig('username')); + $this->assertSame('18446744073709551615', $connection->getConfig('password')); + $this->assertSame('true', $connection->getConfig('host')); + $this->assertSame(8123, $connection->getConfig('port')); + } + public function testConnectionExtensionMustReturnANeutralConnection(): void { $factory = new ConnectionFactory(new Container); diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index db96ee9e8..38a68e27d 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database\DatabaseConnectionTest; +use Closure; use DateTime; use ErrorException; use Exception; @@ -31,11 +32,15 @@ use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\Grammars\Grammar as SchemaGrammar; use Hypervel\Database\SQLiteConnection; +use Hypervel\Database\StreamClosedException; +use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Testbench\TestCase; +use InvalidArgumentException; use LogicException; use Mockery as m; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -877,6 +882,542 @@ public function testRunSkipsQueryFailedDispatchWhenNoListenersAreRegistered(): v ]); } + public function testStreamingRunsLazilyAndLogsOnlyAfterExhaustion(): void + { + $connection = new NeutralConnectionForTest(config: ['name' => 'analytics']); + $connection->enableQueryLog(); + $received = null; + $event = null; + $connection->beforeExecuting(static function (string &$query, array &$bindings): void { + $query = 'select ? as modified'; + $bindings = [2]; + }); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(QueryExecuted::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->andReturnUsing( + static function (QueryExecuted $dispatched) use (&$event): void { + $event = $dispatched; + }, + ); + $connection->setEventDispatcher($events); + + $stream = $this->runStreamingQuery($connection, function (string $query, array $bindings) use ($connection, &$received): Generator { + $received = [$query, $bindings]; + $connection->setLatestReadWriteTypeForTest('read'); + + yield 'first' => 2; + yield 'second' => 3; + }); + + $this->assertNull($received); + $this->assertNull($event); + $this->assertSame([], $connection->getQueryLog()); + + $stream->rewind(); + + $this->assertSame(['select ? as modified', [2]], $received); + $this->assertSame('first', $stream->key()); + $this->assertSame(2, $stream->current()); + $this->assertNull($event); + $this->assertSame([], $connection->getQueryLog()); + + usleep(3000); + $connection->setLatestReadWriteTypeForTest('write'); + $stream->next(); + + $this->assertSame('second', $stream->key()); + $this->assertSame(3, $stream->current()); + $this->assertNull($event); + + $connection->setLatestReadWriteTypeForTest('write'); + $stream->next(); + + $this->assertFalse($stream->valid()); + $this->assertInstanceOf(QueryExecuted::class, $event); + $this->assertSame('select ? as modified', $event->sql); + $this->assertSame([2], $event->bindings); + $this->assertSame('read', $event->readWriteType); + $this->assertSame('analytics', $event->connectionName); + $this->assertGreaterThanOrEqual(3.0, $event->time); + $this->assertSame($event->time, $connection->totalQueryDuration()); + $this->assertSame([[ + 'query' => 'select ? as modified', + 'bindings' => [2], + 'time' => $event->time, + 'readWriteType' => 'read', + ]], $connection->getQueryLog()); + } + + public function testStreamingReconnectsMissingResourcesOnlyWhenAdvanced(): void + { + $connection = new NeutralConnectionForTest; + $connection->driverResourcesPresent = false; + $reconnects = 0; + $connection->setReconnector(static function (NeutralConnectionForTest $connection) use (&$reconnects): void { + ++$reconnects; + $connection->driverResourcesPresent = true; + }); + + $stream = $this->runStreamingQuery($connection, static fn (): array => [1]); + + $this->assertSame(0, $reconnects); + $this->assertSame([1], iterator_to_array($stream)); + $this->assertSame(1, $reconnects); + } + + public function testStreamingEmptyAndPretendResultsStillLogCompletion(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + + $this->assertSame([], iterator_to_array($this->runStreamingQuery($connection, static fn (): array => []))); + $this->assertCount(1, $connection->getQueryLog()); + + $queries = $connection->pretend(function () use ($connection): void { + $stream = $this->runStreamingQuery($connection, function () use ($connection): array { + $this->assertTrue($connection->pretending()); + + return []; + }); + + $this->assertSame([], iterator_to_array($stream)); + }); + + $this->assertSame('select 1', $queries[0]['query']); + } + + public function testStreamingRetriesALostConnectionBeforeTheFirstValue(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $reconnects = 0; + $attempts = 0; + $connection->setReconnector(static function () use (&$reconnects): void { + ++$reconnects; + }); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(QueryExecuted::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->with(m::type(QueryExecuted::class)); + $connection->setEventDispatcher($events); + + $stream = $this->runStreamingQuery($connection, static function () use ($connection, &$attempts): Generator { + $connection->setLatestReadWriteTypeForTest('read'); + + if (++$attempts === 1) { + throw new RuntimeException('server has gone away'); + } + + yield 1; + }); + + $this->assertSame([1], iterator_to_array($stream)); + $this->assertSame(2, $attempts); + $this->assertSame(1, $reconnects); + $this->assertSame(1, $connection->getErrorCount()); + $this->assertCount(1, $connection->getQueryLog()); + } + + public function testStreamingReportsOneFinalFailureWhenTheRetryAlsoFails(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $reconnects = 0; + $attempts = 0; + $failure = new RuntimeException('server has gone away'); + $connection->setReconnector(static function () use (&$reconnects): void { + ++$reconnects; + }); + $event = null; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(QueryFailed::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->andReturnUsing( + static function (QueryFailed $dispatched) use (&$event): void { + $event = $dispatched; + }, + ); + $connection->setEventDispatcher($events); + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static function () use (&$attempts, $failure): never { + ++$attempts; + + throw $failure; + })); + } catch (QueryException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertSame($failure, $thrown->getPrevious()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($thrown, $event->exception); + $this->assertSame(2, $attempts); + $this->assertSame(1, $reconnects); + $this->assertSame(2, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + } + + public function testStreamingLateFailureRetainsItsRoleAndNeverRetries(): void + { + $connection = new NeutralConnectionForTest(config: ['name' => 'analytics', 'host' => 'analytics.internal']); + $connection->enableQueryLog(); + $connection->setReconnector(static fn (): never => throw new LogicException('Unexpected retry.')); + $failure = new RuntimeException('server has gone away'); + $event = null; + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(QueryFailed::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->once()->andReturnUsing( + static function (QueryFailed $dispatched) use (&$event): void { + $event = $dispatched; + }, + ); + $connection->setEventDispatcher($events); + $stream = $this->runStreamingQuery($connection, static function () use ($connection, $failure): Generator { + $connection->setLatestReadWriteTypeForTest('read'); + + yield 1; + + throw $failure; + }); + $stream->rewind(); + $connection->setLatestReadWriteTypeForTest('write'); + $thrown = null; + + try { + $stream->next(); + } catch (QueryException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertSame($failure, $thrown->getPrevious()); + $this->assertSame('read', $thrown->readWriteType); + $this->assertSame('analytics.internal', $thrown->getConnectionDetails()['host']); + $this->assertSame('select ?', $thrown->getSql()); + $this->assertSame([1], $thrown->getBindings()); + $this->assertInstanceOf(QueryFailed::class, $event); + $this->assertSame($thrown, $event->exception); + $this->assertSame('read', $event->readWriteType); + $this->assertSame(1, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + } + + public function testAbandonedStreamingCleansUpWithoutSuccess(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $connection->setEventDispatcher($events); + $cleaned = false; + + foreach ($this->runStreamingQuery($connection, static function () use (&$cleaned): Generator { + try { + yield 1; + yield 2; + } finally { + $cleaned = true; + } + }) as $value) { + $this->assertSame(1, $value); + break; + } + + $this->assertTrue($cleaned); + $this->assertSame([], $connection->getQueryLog()); + $this->assertSame(0.0, $connection->totalQueryDuration()); + $this->assertSame(0, $connection->getErrorCount()); + } + + public function testStreamingConsumerExceptionsCleanUpWithoutQueryEvents(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $connection->setEventDispatcher($events); + $failure = new RuntimeException('consumer failed'); + $cleaned = false; + $thrown = null; + + try { + foreach ($this->runStreamingQuery($connection, static function () use (&$cleaned): Generator { + try { + yield 1; + } finally { + $cleaned = true; + } + }) as $value) { + throw $failure; + } + } catch (RuntimeException $exception) { + $thrown = $exception; + } + + $this->assertSame($failure, $thrown); + $this->assertTrue($cleaned); + $this->assertSame(0, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + } + + public function testStreamingCancellationCleansUpWithoutWrappingOrEvents(): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $connection->setEventDispatcher($events); + $cancellation = new CanceledException('stream canceled'); + $cleaned = false; + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static function () use ($cancellation, &$cleaned): Generator { + try { + yield 1; + + throw $cancellation; + } finally { + $cleaned = true; + } + })); + } catch (CanceledException $exception) { + $thrown = $exception; + } + + $this->assertSame($cancellation, $thrown); + $this->assertTrue($cleaned); + $this->assertSame(0, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + } + + #[DataProvider('closedStreamProvider')] + public function testClosedStreamingCleansUpWithoutWrappingOrEvents(bool $yieldFirst): void + { + $connection = new NeutralConnectionForTest; + $connection->enableQueryLog(); + $connection->setReconnector(static fn (): never => throw new LogicException('Unexpected retry.')); + $events = m::mock(Dispatcher::class); + $events->shouldNotReceive('hasListeners'); + $events->shouldNotReceive('dispatch'); + $connection->setEventDispatcher($events); + $closed = new StreamClosedException('The stream is closed.'); + $cleaned = false; + $thrown = null; + $values = []; + + try { + foreach ($this->runStreamingQuery($connection, static function () use ($yieldFirst, $closed, &$cleaned): Generator { + try { + if ($yieldFirst) { + yield 1; + } + + throw $closed; + } finally { + $cleaned = true; + } + }) as $value) { + $values[] = $value; + } + } catch (StreamClosedException $exception) { + $thrown = $exception; + } + + $this->assertSame($closed, $thrown); + $this->assertSame($yieldFirst ? [1] : [], $values); + $this->assertTrue($cleaned); + $this->assertSame(0, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + $this->assertSame(0.0, $connection->totalQueryDuration()); + } + + /** + * Provide closure before and after the first streamed value. + */ + public static function closedStreamProvider(): array + { + return [ + 'before first value' => [false], + 'after first value' => [true], + ]; + } + + public function testStreamingUsesTheDriverRetryPolicyBeforeAnyValue(): void + { + $connection = new class extends NeutralConnectionForTest { + public int $retryDecisions = 0; + + /** + * Reject retries through the driver's retry policy. + */ + protected function tryAgainIfCausedByLostConnection(QueryException $e, string $query, array $bindings, Closure $callback): mixed + { + ++$this->retryDecisions; + + throw $e; + } + }; + $failure = new RuntimeException('server has gone away'); + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static fn (): never => throw $failure)); + } catch (QueryException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertSame($failure, $thrown->getPrevious()); + $this->assertSame(1, $connection->retryDecisions); + } + + public function testStreamingNeverRetriesWithinATransaction(): void + { + $connection = new NeutralTransactionConnectionForTest; + $connection->setReconnector(static fn (): never => throw new LogicException('Unexpected retry.')); + $connection->beginTransaction(); + $failure = new RuntimeException('server has gone away'); + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static fn (): never => throw $failure)); + } catch (QueryException $exception) { + $thrown = $exception; + } finally { + $connection->rollBack(); + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertSame($failure, $thrown->getPrevious()); + $this->assertSame(1, $connection->getErrorCount()); + } + + public function testQueryExceptionConstructionCanBeOverriddenForBufferedAndStreamingQueries(): void + { + $connection = new class extends NeutralConnectionForTest { + public int $exceptionsCreated = 0; + + /** + * Create a query exception with driver-specific context. + */ + protected function newQueryException(string $query, array $bindings, Exception $previous): QueryException + { + ++$this->exceptionsCreated; + + return new QueryException('custom', $query, $bindings, $previous); + } + }; + $failure = new RuntimeException('query failed'); + + foreach (['run', 'runStreaming'] as $methodName) { + $method = (new ReflectionClass(Connection::class))->getMethod($methodName); + $thrown = null; + + try { + $result = $method->invoke($connection, 'select ?', [1], static fn (): never => throw $failure); + + if ($result instanceof Generator) { + iterator_to_array($result); + } + } catch (QueryException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertSame('custom', $thrown->getConnectionName()); + $this->assertSame($failure, $thrown->getPrevious()); + } + + $this->assertSame(2, $connection->exceptionsCreated); + $this->assertSame(2, $connection->getErrorCount()); + } + + public function testStreamingQueryExceptionsHonorBindingMasking(): void + { + foreach ([false, true] as $maskBindings) { + $connection = new NeutralConnectionForTest(config: [ + 'mask_bindings_in_exception_messages' => $maskBindings, + ]); + $failure = new RuntimeException('query failed'); + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static fn (): never => throw $failure)); + } catch (QueryException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(QueryException::class, $thrown); + $this->assertStringContainsString('SQL: select ' . ($maskBindings ? '?' : '1') . ')', $thrown->getMessage()); + $this->assertSame([1], $thrown->getBindings()); + $this->assertSame($failure, $thrown->getPrevious()); + } + } + + public function testDriversCanPropagateNonDatabaseExceptionsWithoutWrapping(): void + { + $connection = new class extends NeutralConnectionForTest { + /** + * Preserve failures outside the database exception boundary. + */ + protected function newQueryException(string $query, array $bindings, Exception $previous): QueryException + { + throw $previous; + } + }; + $connection->enableQueryLog(); + $failure = new InvalidArgumentException('Invalid query parameters.'); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->twice()->with(QueryFailed::class)->andReturnTrue(); + $events->shouldReceive('dispatch')->twice()->with(m::on( + static fn (QueryFailed $event): bool => $event->exception === $failure, + )); + $connection->setEventDispatcher($events); + + foreach (['run', 'runStreaming'] as $methodName) { + $method = (new ReflectionClass(Connection::class))->getMethod($methodName); + $thrown = null; + + try { + $result = $method->invoke($connection, 'select ?', [1], static fn (): never => throw $failure); + + if ($result instanceof Generator) { + iterator_to_array($result); + } + } catch (InvalidArgumentException $exception) { + $thrown = $exception; + } + + $this->assertSame($failure, $thrown); + } + + $this->assertSame(2, $connection->getErrorCount()); + $this->assertSame([], $connection->getQueryLog()); + } + + public function testStreamingPreservesUniqueConstraintEnrichment(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $failure = new PDOException('UNIQUE constraint failed: users.email, users.team_id'); + $thrown = null; + + try { + iterator_to_array($this->runStreamingQuery($connection, static fn (): never => throw $failure)); + } catch (UniqueConstraintViolationException $exception) { + $thrown = $exception; + } + + $this->assertInstanceOf(UniqueConstraintViolationException::class, $thrown); + $this->assertSame(['email', 'team_id'], $thrown->columns); + $this->assertNull($thrown->index); + $this->assertSame($failure, $thrown->getPrevious()); + } + public function testRunMethodNeverRetriesIfWithinTransaction() { $this->expectException(QueryException::class); @@ -1781,6 +2322,128 @@ public function testGetRawQueryLog() $this->assertEquals(1.23, $log[0]['time']); } + #[DataProvider('readWriteRoutingProvider')] + public function testNeutralConnectionsResolveAndRecordReadWriteRouting( + bool $read, + bool $sticky, + bool $modified, + bool $forceWrite, + bool $transaction, + string $expected, + ): void { + $connection = new NeutralTransactionConnectionForTest(config: ['sticky' => $sticky]); + $connection->setRecordModificationState($modified); + $connection->useWriteConnectionWhenReading($forceWrite); + + if ($transaction) { + $connection->beginTransaction(); + } + + try { + $method = (new ReflectionClass(Connection::class))->getMethod('resolveReadWriteType'); + + $this->assertSame($expected, $method->invoke($connection, $read)); + $this->assertSame($expected, $connection->latestReadWriteTypeForTest()); + } finally { + if ($transaction) { + $connection->rollBack(); + } + } + } + + /** + * Provide the connection's read / write routing states. + */ + public static function readWriteRoutingProvider(): array + { + return [ + 'ordinary read' => [true, false, false, false, false, 'read'], + 'explicit write' => [false, false, false, false, false, 'write'], + 'sticky before modification' => [true, true, false, false, false, 'read'], + 'sticky after modification' => [true, true, true, false, false, 'write'], + 'non-sticky after modification' => [true, false, true, false, false, 'read'], + 'forced write' => [true, false, false, true, false, 'write'], + 'active transaction' => [true, false, false, false, true, 'write'], + ]; + } + + public function testNeutralRoutingRetainsTheConfiguredRoleForDerivedConnectionDiagnostics(): void + { + $method = (new ReflectionClass(Connection::class))->getMethod('resolveReadWriteType'); + + foreach (['read', 'write'] as $role) { + $connection = new NeutralConnectionForTest(config: [Connection::READ_WRITE_TYPE_CONFIG_KEY => $role]); + $connection->enableQueryLog(); + + foreach ([true, false] as $read) { + $this->assertSame($read ? 'read' : 'write', $method->invoke($connection, $read)); + $this->assertSame($role, $connection->latestReadWriteTypeForTest()); + + $connection->logQuery('select 1', []); + } + + $this->assertSame([$role, $role], array_column($connection->getQueryLog(), 'readWriteType')); + } + } + + public function testPoolResetClearsNeutralRoutingDecisions(): void + { + $connection = new NeutralConnectionForTest(config: ['sticky' => true]); + $method = (new ReflectionClass(Connection::class))->getMethod('resolveReadWriteType'); + $connection->recordsHaveBeenModified(); + $connection->useWriteConnectionWhenReading(); + + $this->assertSame('write', $method->invoke($connection)); + + $connection->resetForPool(); + + $this->assertNull($connection->latestReadWriteTypeForTest()); + $this->assertSame('read', $method->invoke($connection)); + $this->assertSame('read', $connection->latestReadWriteTypeForTest()); + } + + public function testPdoReadsUseTheWriteConnectionDuringATransaction(): void + { + $writePdo = new PDO('sqlite::memory:'); + $readPdo = new PDO('sqlite::memory:'); + $connection = new PdoConnection($writePdo); + $connection->setReadPdo($readPdo); + $connection->beginTransaction(); + + try { + $this->assertSame($writePdo, $connection->getReadPdo()); + } finally { + $connection->rollBack(); + } + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testPdoReadsHonorAndReleaseForcedWriteRouting(): void + { + [$connection, $writePdo, $readPdo] = $this->getReadWriteConnection(sticky: false); + $connection->useWriteConnectionWhenReading(); + + $this->assertSame($writePdo, $connection->getReadPdo()); + + $connection->useWriteConnectionWhenReading(false); + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testPdoReadsRecordWriteRoleWhenNoReadResourceIsConfigured(): void + { + $writePdo = new PDOStub; + $connection = new PdoConnection($writePdo); + $connection->enableQueryLog(); + + $this->assertSame($writePdo, $connection->getReadPdo()); + + $connection->logQuery('select 1', []); + + $this->assertSame('write', $connection->getQueryLog()[0]['readWriteType']); + } + public function testStickyReadConnectionsUseWritePdoAfterRecordsModified(): void { [$connection, $writePdo, $readPdo] = $this->getReadWriteConnection(sticky: true); @@ -2078,6 +2741,15 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConn } } + /** + * Invoke a driver's streaming execution boundary. + */ + protected function runStreamingQuery(Connection $connection, Closure $callback): Generator + { + return (new ReflectionClass(Connection::class))->getMethod('runStreaming') + ->invoke($connection, 'select ?', [1], $callback); + } + protected function getSqliteTransactionConnection(): PdoConnection { return new PdoConnection( diff --git a/tests/Database/DatabaseDbCommandLaunchTest.php b/tests/Database/DatabaseDbCommandLaunchTest.php new file mode 100644 index 000000000..c62b5ccdb --- /dev/null +++ b/tests/Database/DatabaseDbCommandLaunchTest.php @@ -0,0 +1,145 @@ + 'application-secret']; + } + + /** + * Customize the MySQL-specific client arguments. + */ + protected function getMysqlArguments(array $connection): array + { + return [...parent::getMysqlArguments($connection), '--local-infile=0']; + } + }; + + $output = $this->prepareCommand($command, new DatabaseCliManager, [ + 'driver' => 'mysql', + 'host' => 'database-host', + 'port' => 3306, + 'username' => 'root', + 'password' => '', + 'database' => 'app', + ]); + $this->expectProcess([ + 'custom-mysql', '--host=database-host', '--port=3306', '--user=root', + 'app', '--local-infile=0', '--interactive', + ], ['MYSQL_PWD' => 'application-secret']); + + $this->assertSame(0, $command->handle()); + $this->assertSame('connected', $output->fetch()); + } + + public function testHostlessExtensionResolvesOnceForTheLaunch(): void + { + $manager = new DatabaseCliManager; + $connection = ['driver' => 'custom', 'database' => 'analytics']; + $configuration = new DatabaseCliConfiguration( + 'custom-client', + ['--database', 'analytics'], + ['CLIENT_PASSWORD' => 'secret'], + ); + $calls = 0; + $manager->extend('custom', function (array $resolved) use ($connection, $configuration, &$calls): DatabaseCliConfiguration { + ++$calls; + $this->assertSame($connection, $resolved); + + return $configuration; + }); + + $command = new DbCommand; + $output = $this->prepareCommand($command, $manager, $connection); + $this->expectProcess(['custom-client', '--database', 'analytics'], ['CLIENT_PASSWORD' => 'secret']); + + $this->assertSame(0, $command->handle()); + $this->assertSame(1, $calls); + $this->assertSame('connected', $output->fetch()); + } + + /** + * Prepare the command's application and console input/output. + */ + private function prepareCommand(DbCommand $command, DatabaseCliManager $manager, array $connection): BufferedOutput + { + $application = m::mock(Application::class); + $application->shouldReceive('make')->once()->with(DatabaseCliManager::class)->andReturn($manager); + $application->shouldReceive('make')->once()->with('config')->andReturn(new Repository([ + 'database' => ['default' => 'testing', 'connections' => ['testing' => $connection]], + ])); + + $input = new ArrayInput([]); + $input->bind($command->getDefinition()); + $output = new BufferedOutput; + $command->setHypervel($application); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + return $output; + } + + /** + * Verify the process launch without opening a terminal or client. + */ + private function expectProcess(array $arguments, array $environment): void + { + $execution = m::mock(); + $execution->shouldReceive('setTty')->once()->with(true)->andReturnSelf(); + $execution->shouldReceive('mustRun')->once()->with(m::type(Closure::class)) + ->andReturnUsing(function (Closure $callback) use ($execution): m\MockInterface { + $callback('out', 'connected'); + + return $execution; + }); + + $process = m::mock('overload:' . Process::class); + $process->shouldReceive('__construct')->once()->with($arguments, null, $environment); + $process->shouldReceive('setTimeout')->once()->with(null)->andReturn($execution); + } +} diff --git a/tests/Database/DatabaseDbCommandTest.php b/tests/Database/DatabaseDbCommandTest.php index cdbce2d9a..9f5f1760a 100644 --- a/tests/Database/DatabaseDbCommandTest.php +++ b/tests/Database/DatabaseDbCommandTest.php @@ -5,12 +5,19 @@ namespace Hypervel\Tests\Database; use Hypervel\Config\Repository; +use Hypervel\Console\OutputStyle; +use Hypervel\Console\View\Components\Factory; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Console\DbCommand; +use Hypervel\Database\DatabaseCliManager; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; use UnexpectedValueException; class DatabaseDbCommandTest extends TestCase @@ -107,6 +114,30 @@ public function testDefaultConnectionIsReadFromConfigRepository(): void $this->assertSame('write-host', $connection['host']); } + #[DataProvider('hostConfigurations')] + public function testHostListsAreNormalizedAfterRoleSelection(array $configuration, array $input, ?string $expected): void + { + $connection = $this->getConnection([ + 'mysql' => $this->mysqlConfig($configuration), + ], $input); + + $this->assertSame($expected, $connection['host']); + } + + /** + * Provide base and role host lists, including empty effective lists. + */ + public static function hostConfigurations(): array + { + return [ + 'base hosts' => [['host' => ['first', 'second']], [], 'first'], + 'empty read override' => [['host' => ['first', 'second'], 'read' => []], ['--read' => true], 'first'], + 'empty base hosts' => [['host' => []], [], null], + 'empty role hosts' => [['read' => ['host' => []]], ['--read' => true], null], + 'empty inherited hosts' => [['host' => [], 'read' => ['username' => 'reader']], ['--read' => true], null], + ]; + } + public function testUnknownConnectionUsesTheCommandSpecificError(): void { $this->expectException(UnexpectedValueException::class); @@ -139,16 +170,257 @@ public function testUrlConfigIsParsedBeforeReadWriteMerge(): void $this->assertArrayNotHasKey('write', $connection); } + #[DataProvider('roleUrls')] + public function testRoleUrlOverridesBaseUrlAndRoleFields( + string $role, + array $override, + string $password = 'role-secret', + ): void { + $connection = $this->getConnection([ + 'mysql' => $this->mysqlConfig([ + 'url' => 'mysql://base-user:base-secret@base-host:3306/base_database?charset=latin1', + $role => $override, + ]), + ], ['--' . $role => true]); + + $this->assertSame('mysql', $connection['driver']); + $this->assertSame('role-host', $connection['host']); + $this->assertSame(3307, $connection['port']); + $this->assertSame('role_database', $connection['database']); + $this->assertSame('role-user', $connection['username']); + $this->assertSame($password, $connection['password']); + $this->assertSame('utf8mb4', $connection['charset']); + $this->assertArrayNotHasKey('url', $connection); + $this->assertArrayNotHasKey('read', $connection); + $this->assertArrayNotHasKey('write', $connection); + } + + /** + * Provide associative and list-valued role overrides with their own URLs. + */ + public static function roleUrls(): array + { + $override = [ + 'url' => 'mysql://role-user:role-secret@role-host:3307/role_database?charset=utf8mb4', + 'host' => 'record-host', + 'port' => 3308, + 'database' => 'record_database', + 'username' => 'record-user', + 'password' => 'record-secret', + 'charset' => 'ascii', + ]; + + return [ + 'associative read' => ['read', $override], + 'first write record' => ['write', [$override, ['url' => 'mysql://ignored:invalid/app']]], + 'literal numeric password' => [ + 'read', + array_replace($override, [ + 'url' => 'mysql://role-user:123456@role-host:3307/role_database?charset=utf8mb4', + ]), + '123456', + ], + ]; + } + + public function testPartialRoleUrlPreservesInheritedValues(): void + { + $connection = $this->getConnection([ + 'mysql' => $this->mysqlConfig([ + 'url' => 'mysql://base-user:base-secret@base-host:3306/base_database?charset=utf8mb4', + 'read' => ['url' => 'mysql://read-host'], + ]), + ], ['--read' => true]); + + $this->assertSame('read-host', $connection['host']); + $this->assertSame(3306, $connection['port']); + $this->assertSame('base_database', $connection['database']); + $this->assertSame('base-user', $connection['username']); + $this->assertSame('base-secret', $connection['password']); + $this->assertSame('utf8mb4', $connection['charset']); + $this->assertArrayNotHasKey('url', $connection); + } + + public function testRoleUrlHostListUsesTheFirstHost(): void + { + $connection = $this->getConnection([ + 'mysql' => $this->mysqlConfig([ + 'read' => ['url' => 'mysql://url-host/app?host[]=read-one&host[]=read-two'], + ]), + ], ['--read' => true]); + + $this->assertSame('read-one', $connection['host']); + } + + public function testMalformedSelectedRoleUrlFails(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The database configuration URL is malformed.'); + + $this->getConnection([ + 'mysql' => $this->mysqlConfig([ + 'read' => ['url' => 'mysql://read-host:invalid/app'], + ]), + ], ['--read' => true]); + } + + public function testUnselectedRoleUrlIsNotParsed(): void + { + $connection = $this->getConnection([ + 'mysql' => $this->mysqlConfig([ + 'write' => ['url' => 'mysql://write-host:invalid/app'], + ]), + ], ['--read' => true]); + + $this->assertSame('read-host', $connection['host']); + $this->assertArrayNotHasKey('write', $connection); + } + + #[DataProvider('mysqlDrivers')] + public function testMysqlClientsUseTheExistingArguments(string $driver): void + { + $command = new DbCommand; + $connection = $this->mysqlConfig(['driver' => $driver]); + + $this->assertSame($driver, $command->getCommand($connection)); + $this->assertSame([ + '--host=write-host', '--port=3306', '--user=root', 'app', + ], $command->commandArguments($connection)); + $this->assertNull($command->commandEnvironment($connection)); + } + + #[DataProvider('mysqlDrivers')] + public function testMysqlClientsIncludeConfiguredOptionalArguments(string $driver): void + { + $command = new DbCommand; + $connection = $this->mysqlConfig([ + 'driver' => $driver, + 'password' => 'secret', + 'unix_socket' => '/tmp/mysql.sock', + 'charset' => 'utf8mb4', + ]); + + $this->assertSame([ + '--host=write-host', '--port=3306', '--user=root', + '--password=secret', '--socket=/tmp/mysql.sock', + '--default-character-set=utf8mb4', 'app', + ], $command->commandArguments($connection)); + } + + #[DataProvider('mysqlDrivers')] + public function testMysqlClientsPreserveZeroPassword(string $driver): void + { + $connection = $this->mysqlConfig(['driver' => $driver, 'password' => '0']); + + $this->assertSame([ + '--host=write-host', '--port=3306', '--user=root', '--password=0', 'app', + ], (new DbCommand)->commandArguments($connection)); + } + + /** + * Provide the drivers that share MySQL client argument formatting. + */ + public static function mysqlDrivers(): array + { + return [['mysql'], ['mariadb']]; + } + + public function testPostgresUsesEnvironmentVariablesWithoutChangingArguments(): void + { + $command = new DbCommand; + $connection = $this->mysqlConfig([ + 'driver' => 'pgsql', 'port' => 5432, 'password' => 'secret', + ]); + + $this->assertSame('psql', $command->getCommand($connection)); + $this->assertSame(['app'], $command->commandArguments($connection)); + $this->assertSame([ + 'PGUSER' => 'root', + 'PGHOST' => 'write-host', + 'PGPORT' => 5432, + 'PGPASSWORD' => 'secret', + ], $command->commandEnvironment($connection)); + $this->assertSame(['app'], $command->commandArguments($connection)); + } + + public function testPostgresPreservesZeroCredentials(): void + { + $connection = $this->mysqlConfig([ + 'driver' => 'pgsql', 'port' => 5432, 'username' => '0', 'password' => '0', + ]); + + $this->assertSame([ + 'PGUSER' => '0', + 'PGHOST' => 'write-host', + 'PGPORT' => 5432, + 'PGPASSWORD' => '0', + ], (new DbCommand)->commandEnvironment($connection)); + } + + public function testPostgresOmitsEmptyEnvironmentVariables(): void + { + $command = new DbCommand; + $connection = $this->mysqlConfig([ + 'driver' => 'pgsql', 'host' => '', 'port' => null, 'username' => '', + ]); + + $this->assertSame([], $command->commandEnvironment($connection)); + } + + public function testSqliteConfigurationDoesNotRequireAHost(): void + { + $command = new DbCommand; + $connection = ['driver' => 'sqlite', 'database' => '/tmp/app database.sqlite']; + + $this->assertSame('sqlite3', $command->getCommand($connection)); + $this->assertSame(['/tmp/app database.sqlite'], $command->commandArguments($connection)); + $this->assertNull($command->commandEnvironment($connection)); + } + + public function testUnknownDriverHasATargetedExtensionError(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Unsupported database CLI driver [custom]. Register a resolver using DatabaseCliManager::extend().'); + + (new DbCommand)->getCommand(['driver' => 'custom']); + } + + public function testMissingBuiltInHostReturnsFailureWithTheExistingGuidance(): void + { + $command = new DbCommand; + $application = $this->applicationWithConfig([ + 'mysql' => $this->mysqlConfig(['host' => []]), + ]); + $application->shouldReceive('make')->once()->with(DatabaseCliManager::class)->andReturn(new DatabaseCliManager); + $command->setHypervel($application); + + $input = $this->inputFor($command, []); + $output = new BufferedOutput; + $style = new OutputStyle($input, $output); + $command->setInput($input); + $command->setOutput($style); + (new ReflectionProperty($command, 'components'))->setValue($command, new Factory($style)); + + $this->assertSame(DbCommand::FAILURE, $command->handle()); + $this->assertStringContainsString('No host specified for this database connection.', $output->fetch()); + } + + /** + * Resolve a connection through the command's normal configuration path. + */ private function getConnection(array $connections, array $input = []): array { - $command = new TestableDbCommand; + $command = new DbCommand; $command->setHypervel($this->applicationWithConfig($connections)); $command->setInput($this->inputFor($command, $input)); return $command->getConnection(); } - private function applicationWithConfig(array $connections): Application|m\MockInterface + /** + * Create an application with the supplied connection configuration. + */ + private function applicationWithConfig(array $connections): Application&m\MockInterface { $application = m::mock(Application::class); $application->shouldReceive('make') @@ -164,6 +436,9 @@ private function applicationWithConfig(array $connections): Application|m\MockIn return $application; } + /** + * Bind input to the database command's definition. + */ private function inputFor(DbCommand $command, array $input): InputInterface { $arrayInput = new ArrayInput($input); @@ -172,6 +447,9 @@ private function inputFor(DbCommand $command, array $input): InputInterface return $arrayInput; } + /** + * Build a MySQL connection configuration. + */ private function mysqlConfig(array $overrides = []): array { return array_merge([ @@ -190,11 +468,3 @@ private function mysqlConfig(array $overrides = []): array ], $overrides); } } - -class TestableDbCommand extends DbCommand -{ - public function setInput(InputInterface $input): void - { - $this->input = $input; - } -} diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 8307b55a6..36be0914e 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -54,6 +54,7 @@ use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Query\Builder as BaseBuilder; use Hypervel\Database\Query\Grammars\Grammar; +use Hypervel\Database\Query\Grammars\PostgresGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Support\CarbonImmutable; @@ -1100,7 +1101,7 @@ public function testInsertProcess() $model = $this->getMockBuilder(ModelStub::class)->onlyMethods(['newModelQuery', 'updateTimestamps', 'refresh'])->getMock(); $query = m::mock(Builder::class); - $query->shouldReceive('insert')->once()->with(['name' => 'taylor']); + $query->shouldReceive('insert')->once()->with([['name' => 'taylor']]); $query->shouldReceive('getConnection')->once()->andReturn(m::mock(ConnectionInterface::class, ['getName' => 'default'])); $model->expects($this->once())->method('newModelQuery')->willReturn($query); $model->expects($this->once())->method('updateTimestamps'); @@ -1119,6 +1120,54 @@ public function testInsertProcess() $this->assertTrue($model->exists); } + #[TestWith([['tags' => ['api'], 'tenant_id' => 42], 'insert into "stub" ("tags", "tenant_id") values (?, ?)', [['api'], 42]])] + #[TestWith([['labels' => ['region' => 'eu']], 'insert into "stub" ("labels") values (?)', [['region' => 'eu']]])] + #[TestWith([['tags' => [], 'labels' => ['region' => 'eu']], 'insert into "stub" ("labels", "tags") values (?, ?)', [['region' => 'eu'], []]])] + #[TestWith([['name' => 'taylor'], 'insert into "stub" ("name") values (?)', ['taylor']])] + public function testNonIncrementingModelInsertsOneRow(array $attributes, string $sql, array $bindings): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getName')->andReturn('testing'); + $grammar = new Grammar($connection); + $processor = new Processor; + $connection->shouldReceive('query')->andReturnUsing( + fn () => new BaseBuilder($connection, $grammar, $processor) + ); + $connection->shouldReceive('insert')->once()->with($sql, $bindings)->andReturnTrue(); + + Model::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection); + + $model = new class extends ModelStub { + public bool $incrementing = false; + + public bool $timestamps = false; + }; + + $created = $model->newQuery()->create($attributes); + + $this->assertSame($attributes, $created->getAttributes()); + $this->assertTrue($created->exists); + $this->assertTrue($created->wasRecentlyCreated); + $this->assertFalse($created->isDirty()); + } + + public function testNonIncrementingModelWithoutAttributesDoesNotInsert(): void + { + $model = $this->getMockBuilder(ModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); + $model->setConnection('testing'); + $model->setIncrementing(false); + $model->timestamps = false; + $query = m::mock(Builder::class); + $query->shouldNotReceive('insert'); + $model->expects($this->once())->method('newModelQuery')->willReturn($query); + + $this->assertTrue($model->save()); + $this->assertFalse($model->exists); + $this->assertFalse($model->wasRecentlyCreated); + } + public function testInsertIsCanceledIfCreatingEventReturnsFalse() { $model = $this->getMockBuilder(ModelStub::class)->onlyMethods(['newModelQuery'])->getMock(); @@ -1143,7 +1192,7 @@ public function testInsertOrIgnoreProcessWithIncrementing(): void $query->shouldReceive('toBase')->once()->andReturn($baseQuery); $baseQuery->shouldReceive('insertOrIgnoreReturning') ->once() - ->with(['name' => 'taylor'], ['*'], null) + ->with([['name' => 'taylor']], ['*'], null) ->andReturn(new BaseCollection([(object) ['id' => 1, 'name' => 'taylor']])); $query->shouldReceive('getConnection') ->once() @@ -1176,7 +1225,7 @@ public function testInsertOrIgnoreProcessWithConflict(): void $query->shouldReceive('toBase')->once()->andReturn($baseQuery); $baseQuery->shouldReceive('insertOrIgnoreReturning') ->once() - ->with(['name' => 'taylor'], ['*'], null) + ->with([['name' => 'taylor']], ['*'], null) ->andReturn(new BaseCollection); $query->shouldReceive('getConnection') ->once() @@ -1206,7 +1255,7 @@ public function testInsertOrIgnoreProcessWithNonIncrementing(): void $query->shouldReceive('toBase')->once()->andReturn($baseQuery); $baseQuery->shouldReceive('insertOrIgnoreReturning') ->once() - ->with(['name' => 'taylor'], ['*'], null) + ->with([['name' => 'taylor']], ['*'], null) ->andReturn(new BaseCollection([(object) ['name' => 'taylor']])); $query->shouldReceive('getConnection') ->once() @@ -1240,7 +1289,7 @@ public function testInsertOrIgnoreProcessWithNamedUnique(): void $query->shouldReceive('toBase')->once()->andReturn($baseQuery); $baseQuery->shouldReceive('insertOrIgnoreReturning') ->once() - ->with(['name' => 'taylor'], ['*'], ['name']) + ->with([['name' => 'taylor']], ['*'], ['name']) ->andReturn(new BaseCollection); $query->shouldReceive('getConnection') ->once() @@ -1260,6 +1309,38 @@ public function testInsertOrIgnoreProcessWithNamedUnique(): void $this->assertFalse($model->wasRecentlyCreated); } + #[TestWith([true, ['tags' => ['api'], 'name' => 'taylor'], ['name'], 'insert into "stub" ("name", "tags") values (?, ?) on conflict ("name") do nothing returning *', ['taylor', ['api']]])] + #[TestWith([false, ['tags' => ['api'], 'name' => 'taylor'], ['name'], 'insert into "stub" ("name", "tags") values (?, ?) on conflict ("name") do nothing returning *', ['taylor', ['api']]])] + #[TestWith([false, ['tags' => [], 'labels' => ['region' => 'eu']], ['labels'], 'insert into "stub" ("labels", "tags") values (?, ?) on conflict ("labels") do nothing returning *', [['region' => 'eu'], []]])] + #[TestWith([true, ['name' => 'taylor'], ['name'], 'insert into "stub" ("name") values (?) on conflict ("name") do nothing returning *', ['taylor']])] + public function testInsertOrIgnorePreservesOneModelRow(bool $incrementing, array $attributes, array $uniqueBy, string $sql, array $bindings): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getName')->andReturn('testing'); + $grammar = new PostgresGrammar($connection); + $processor = new Processor; + $connection->shouldReceive('query')->andReturnUsing( + fn () => new BaseBuilder($connection, $grammar, $processor) + ); + $connection->shouldReceive('selectFromWriteConnection')->once() + ->with($sql, $bindings)->andReturn([(object) ['id' => 1, ...$attributes]]); + $connection->shouldReceive('recordsHaveBeenModified')->once()->with(true); + + Model::setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); + $resolver->shouldReceive('connection')->andReturn($connection); + + $model = new class($attributes) extends ModelStub { + public bool $timestamps = false; + }; + $model->setIncrementing($incrementing); + + $this->assertTrue($model->saveOrIgnore(uniqueBy: $uniqueBy)); + $this->assertSame($incrementing ? 1 : null, $model->getKey()); + $this->assertTrue($model->exists); + $this->assertTrue($model->wasRecentlyCreated); + } + public function testInsertOrIgnoreThrowsOnExistingModel(): void { $this->expectException(LogicException::class); diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 4b6d14e73..5b0c307a2 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -17,6 +17,7 @@ use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use Mockery as m; use PDO; class DatabaseManagerTest extends TestCase @@ -422,6 +423,43 @@ public function testNonPooledReadConnectionCanBeResolvedFromSplitConfig(): void $this->assertNotNull($connection->getPdo()); } + public function testNonPooledReadExtensionRetainsCompleteConfigurationThroughReconnect(): void + { + $config = [ + 'driver' => 'http', + 'database' => 'analytics', + 'host' => 'base.test', + 'read' => [ + ['host' => 'read-one.test', 'username' => 'reader-one'], + ['host' => 'read-two.test', 'username' => 'reader-two'], + ], + 'write' => ['host' => 'write.test'], + ]; + $this->db->addConnection($config, 'read-extension'); + $manager = $this->db->getDatabaseManager(); + $receivedConfigurations = []; + $manager->extend('http', static function (array $config) use (&$receivedConfigurations): Connection { + $receivedConfigurations[] = $config; + + return m::mock(Connection::class . '[replaceDriverResources]', [$config['database'], $config['prefix'], $config]) + ->shouldAllowMockingProtectedMethods(); + }); + $expected = $config + [ + 'prefix' => '', + 'name' => 'read-extension', + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'read', + ]; + + $connection = $manager->connection('read-extension::read'); + + $this->assertSame([$expected], $receivedConfigurations); + $this->assertSame($expected + ['mask_bindings_in_exception_messages' => false], $connection->getConfig()); + $connection->shouldReceive('replaceDriverResources')->once()->with(m::type(Connection::class)); + + $this->assertSame($connection, $manager->reconnect('read-extension::read')); + $this->assertSame([$expected, $expected], $receivedConfigurations); + } + public function testNonPooledReadConnectionReconnectsUsingReadSuffix(): void { $filesystem = new Filesystem; diff --git a/tests/Database/DatabaseMigrationRepositoryTest.php b/tests/Database/DatabaseMigrationRepositoryTest.php index 5f2026e2a..4a0f5f35f 100755 --- a/tests/Database/DatabaseMigrationRepositoryTest.php +++ b/tests/Database/DatabaseMigrationRepositoryTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Database; -use Closure; use Hypervel\Database\Connection; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\Migrations\DatabaseMigrationRepository; @@ -13,6 +12,7 @@ use Hypervel\Support\Collection; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseMigrationRepositoryTest extends TestCase { @@ -87,27 +87,40 @@ public function testGetNextBatchNumberReturnsLastBatchNumberPlusOne() $this->assertEquals(2, $repo->getNextBatchNumber()); } - public function testGetLastBatchNumberReturnsMaxBatch() + #[DataProvider('batchNumberProvider')] + public function testGetLastBatchNumberReturnsMaxBatch(int|string|null $value, int $expected): void { $repo = $this->getRepository(); $query = m::mock(QueryBuilder::class); $connectionMock = m::mock(Connection::class); $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); $repo->getConnection()->shouldReceive('table')->once()->with('migrations')->andReturn($query); - $query->shouldReceive('max')->once()->andReturn(1); + $query->shouldReceive('max')->once()->with('batch')->andReturn($value); $query->shouldReceive('useWritePdo')->once()->andReturn($query); - $this->assertEquals(1, $repo->getLastBatchNumber()); + $this->assertSame($expected, $repo->getLastBatchNumber()); } - public function testCreateRepositoryCreatesProperDatabaseTable() + /** + * Provide native and string-valued migration batches. + */ + public static function batchNumberProvider(): array + { + return [ + 'native integer' => [1, 1], + 'numeric string' => ['42', 42], + 'empty repository' => [null, 0], + ]; + } + + public function testCreateRepositoryDelegatesItsTableDefinitionToTheSchemaBuilder(): void { $repo = $this->getRepository(); $schema = m::mock(SchemaBuilder::class); $connectionMock = m::mock(Connection::class); $repo->getConnectionResolver()->shouldReceive('connection')->with(null)->andReturn($connectionMock); $repo->getConnection()->shouldReceive('getSchemaBuilder')->once()->andReturn($schema); - $schema->shouldReceive('create')->once()->with('migrations', m::type(Closure::class)); + $schema->shouldReceive('createMigrationRepositoryTable')->once()->with('migrations'); $repo->createRepository(); } diff --git a/tests/Database/DatabaseQueryBuilderBindingTest.php b/tests/Database/DatabaseQueryBuilderBindingTest.php new file mode 100644 index 000000000..5e6f6d758 --- /dev/null +++ b/tests/Database/DatabaseQueryBuilderBindingTest.php @@ -0,0 +1,200 @@ +builder()->from('users') + ->having('active', true) + ->orHaving('verified', '=', false) + ->having(function (Builder $query): void { + $query->having('enabled', '=', true)->orHaving('invited', false); + }); + + $this->assertSame( + 'select * from "users" having "active" = ? or "verified" = ? and ("enabled" = ? or "invited" = ?)', + $query->toSql() + ); + $this->assertSame([true, false, true, false], $query->getBindings()); + } + + public function testHavingRetainsTheFrameworkArrayCoercion(): void + { + $query = $this->builder()->from('users') + ->having('id', [2, 3]) + ->orHaving('id', '=', [[4, 5]]); + + $this->assertSame('select * from "users" having "id" = ? or "id" = ?', $query->toSql()); + $this->assertSame([2, 4], $query->getBindings()); + } + + public function testHavingPreservesDriverOwnedValueObjects(): void + { + $value = new stdClass; + $query = $this->builder()->from('users')->having('id', $value)->orHaving('id', '=', $value); + + $this->assertSame('select * from "users" having "id" = ? or "id" = ?', $query->toSql()); + $this->assertSame([$value, $value], $query->getBindings()); + } + + public function testHavingExpressionShorthandDoesNotAddBindings(): void + { + $query = $this->builder()->from('users') + ->having('id', new Expression('2')) + ->orHaving('id', new Expression('3')); + + $this->assertSame('select * from "users" having "id" = 2 or "id" = 3', $query->toSql()); + $this->assertSame([], $query->getBindings()); + } + + #[DataProvider('valueBetweenMethods')] + public function testValueBetweenExpressionsDoNotAddBindings(string $method, string $boolean, string $operator): void + { + $query = $this->builder()->from('users')->where('active', true); + $query->{$method}(new Expression('2'), ['lower', 'upper']); + + $this->assertSame( + 'select * from "users" where "active" = ? ' . $boolean . ' 2 ' . $operator . ' "lower" and "upper"', + $query->toSql() + ); + $this->assertSame([true], $query->getBindings()); + } + + #[DataProvider('valueBetweenMethods')] + public function testValueBetweenArraysContributeOneScalarBinding(string $method, string $boolean, string $operator): void + { + $query = $this->builder()->from('users')->where('active', true); + $query->{$method}([[2, 3]], ['lower', 'upper']); + + $this->assertSame( + 'select * from "users" where "active" = ? ' . $boolean . ' ? ' . $operator . ' "lower" and "upper"', + $query->toSql() + ); + $this->assertSame([true, 2], $query->getBindings()); + } + + /** + * Cover the Boolean and negated value-between entry points. + */ + public static function valueBetweenMethods(): iterable + { + yield 'where' => ['whereValueBetween', 'and', 'between']; + yield 'or where' => ['orWhereValueBetween', 'or', 'between']; + yield 'where not' => ['whereValueNotBetween', 'and', 'not between']; + yield 'or where not' => ['orWhereValueNotBetween', 'or', 'not between']; + } + + public function testBooleanHavingAndInlineValueBetweenExecuteWithoutExtraBindings(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $query = $connection->query()->selectRaw('1 AS active') + ->whereValueBetween(new Expression('2'), [new Expression('1'), new Expression('3')]) + ->groupBy('active')->having('active', true); + + $this->assertSame([true], $query->getBindings()); + $this->assertSame(1, $query->get()->sole()->active); + } + + #[DataProvider('datePartMethods')] + public function testDatePartsPreserveDriverOwnedValueObjects(string $method, string $part): void + { + $value = new stdClass; + $query = $this->builder()->from('users')->{$method}('created_at', $value); + + $this->assertSame('select * from "users" where ' . $part . '("created_at") = ?', $query->toSql()); + $this->assertSame([$value], $query->getBindings()); + } + + #[DataProvider('datePartMethods')] + public function testDatePartsKeepScalarDateTimeAndExpressionBehavior(string $method, string $part): void + { + $query = $this->builder()->from('users') + ->{$method}('created_at', 5) + ->{$method}('created_at', '=', new DateTimeImmutable('2026-05-05')) + ->{$method}('created_at', '=', new Expression('5')); + + $predicate = $part . '("created_at")'; + $this->assertSame( + 'select * from "users" where ' . $predicate . ' = ? and ' . $predicate . ' = ? and ' . $predicate . ' = 5', + $query->toSql() + ); + $this->assertSame(['05', '05'], $query->getBindings()); + } + + /** + * Cover the two date-part formatting boundaries. + */ + public static function datePartMethods(): iterable + { + yield 'day' => ['whereDay', 'day']; + yield 'month' => ['whereMonth', 'month']; + } + + #[DataProvider('betweenMethods')] + public function testBetweenMaterializesGeneratorBoundsOnce(string $method, string $clause): void + { + $iterations = 0; + $bounds = (static function () use (&$iterations) { + ++$iterations; + + yield 'lower' => new Expression('1'); + yield 'upper' => 3; + })(); + $query = $this->builder()->from('users')->{$method}('id', $bounds); + $sql = 'select * from "users" ' . $clause . ' "id" between 1 and ?'; + + $this->assertSame(1, $iterations); + $this->assertSame($sql, $query->toSql()); + $this->assertSame($sql, $query->toSql()); + $this->assertSame([3], $query->getBindings()); + $this->assertSame(1, $iterations); + } + + #[DataProvider('betweenMethods')] + public function testBetweenAcceptsCollectionBounds(string $method, string $clause): void + { + $query = $this->builder()->from('users') + ->{$method}('id', new Collection(['lower' => 1, 'upper' => 3])); + + $this->assertSame('select * from "users" ' . $clause . ' "id" between ? and ?', $query->toSql()); + $this->assertSame([1, 3], $query->getBindings()); + } + + /** + * Cover the distinct where and having bound collectors. + */ + public static function betweenMethods(): iterable + { + yield 'where' => ['whereBetween', 'where']; + yield 'having' => ['havingBetween', 'having']; + } + + /** + * Construct a builder without opening a database connection. + */ + protected function builder(): Builder + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + + return new Builder($connection, new Grammar($connection), new Processor); + } +} diff --git a/tests/Database/DatabaseQueryBuilderEmbeddingTest.php b/tests/Database/DatabaseQueryBuilderEmbeddingTest.php new file mode 100644 index 000000000..c346e2355 --- /dev/null +++ b/tests/Database/DatabaseQueryBuilderEmbeddingTest.php @@ -0,0 +1,107 @@ +builder()->from('users')->where('active', true); + $child = $this->builder()->select('id')->from('memberships')->where('enabled', true); + $failure = new InvalidArgumentException('Apply statement options to the outer query.'); + $outer->embeddingFailure = $failure; + $sql = $outer->toSql(); + $bindings = $outer->getBindings(); + + try { + $attach($outer, $child); + $this->fail('The attachment must invoke the embedding validation override.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([$child], $outer->validatedQueries); + $this->assertSame($sql, $outer->toSql()); + $this->assertSame($bindings, $outer->getBindings()); + $this->assertSame('select "id" from "memberships" where "enabled" = ?', $child->toSql()); + $this->assertSame([true], $child->getBindings()); + } + + #[DataProvider('attachmentPaths')] + public function testEmbeddingValidationRunsOnlyWhenTheChildIsAttached(Closure $attach): void + { + $outer = $this->builder()->from('users'); + $child = $this->builder()->select('id')->from('memberships')->where('enabled', true); + $attach($outer, $child); + $sql = $outer->toSql(); + $bindings = $outer->getBindings(); + + $outer->embeddingFailure = new InvalidArgumentException('This must not run during compilation.'); + $child->timeout(5); + + $this->assertSame($sql, $outer->toSql()); + $this->assertSame($bindings, $outer->getBindings()); + $this->assertSame([$child], $outer->validatedQueries); + $this->assertSame(5, $child->timeout); + } + + /** + * Exercise each distinct attachment-time validation site. + */ + public static function attachmentPaths(): iterable + { + yield 'parsed subquery' => [static fn (Builder $outer, Builder $child): Builder => $outer->selectSub($child, 'member_id')]; + yield 'scalar subquery' => [static fn (Builder $outer, Builder $child): Builder => $outer->where('id', '=', $child)]; + yield 'exists subquery' => [static fn (Builder $outer, Builder $child): Builder => $outer->whereExists($child)]; + yield 'union member' => [static fn (Builder $outer, Builder $child): Builder => $outer->union($child)]; + } + + /** + * Construct a builder without opening a database connection. + */ + protected function builder(): EmbeddingAwareQueryBuilder + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + + return new EmbeddingAwareQueryBuilder($connection, new Grammar($connection), new Processor); + } +} + +class EmbeddingAwareQueryBuilder extends Builder +{ + /** + * @var list + */ + public array $validatedQueries = []; + + public ?InvalidArgumentException $embeddingFailure = null; + + /** + * Extend embedding validation while retaining the framework timeout rule. + */ + protected function ensureCanEmbedQuery(Builder $query): void + { + parent::ensureCanEmbedQuery($query); + + $this->validatedQueries[] = $query; + + if ($this->embeddingFailure !== null) { + throw $this->embeddingFailure; + } + } +} diff --git a/tests/Database/DatabaseQueryBuilderJoinTest.php b/tests/Database/DatabaseQueryBuilderJoinTest.php new file mode 100644 index 000000000..6bdb223ac --- /dev/null +++ b/tests/Database/DatabaseQueryBuilderJoinTest.php @@ -0,0 +1,139 @@ +builder()->from('users') + ->join('contacts', function (JoinClause $join): void { + $join->on('users.id', '=', 'contacts.user_id') + ->where('contacts.active', true) + ->join('addresses', function (JoinClause $nested): void { + $nested->on(function (JoinClause $conditions): void { + $conditions->on('contacts.id', '=', 'addresses.contact_id') + ->where('addresses.kind', 'home') + ->orWhere('addresses.kind', 'work'); + }); + }); + }) + ->where('users.tenant_id', 7); + + $this->assertSame( + 'select * from "users" inner join ("contacts" inner join "addresses" on ("contacts"."id" = "addresses"."contact_id" and "addresses"."kind" = ? or "addresses"."kind" = ?)) on "users"."id" = "contacts"."user_id" and "contacts"."active" = ? where "users"."tenant_id" = ?', + $query->toSql() + ); + $this->assertSame(['home', 'work', true, 7], $query->getBindings()); + } + + public function testNestedJoinSupportsClosureSubqueries(): void + { + $query = $this->builder()->from('users') + ->join('contacts', function (JoinClause $join): void { + $join->on('users.id', '=', 'contacts.user_id') + ->join('addresses', function (JoinClause $nested): void { + $nested->on('contacts.id', '=', 'addresses.contact_id') + ->whereExists(function (Builder $query): void { + $this->assertSame(Builder::class, $query::class); + + $query->selectRaw('1')->from('countries') + ->whereColumn('countries.id', '=', 'addresses.country_id') + ->where('countries.active', true); + }); + }); + }); + + $this->assertSame( + 'select * from "users" inner join ("contacts" inner join "addresses" on "contacts"."id" = "addresses"."contact_id" and exists (select 1 from "countries" where "countries"."id" = "addresses"."country_id" and "countries"."active" = ?)) on "users"."id" = "contacts"."user_id"', + $query->toSql() + ); + $this->assertSame([true], $query->getBindings()); + } + + public function testNestedJoinFactoriesRetainTheRootBuilderSubclassAndDependencies(): void + { + $builder = $this->builder(); + $root = new class($builder->getConnection(), $builder->getGrammar(), $builder->getProcessor()) extends Builder {}; + $root->from('users')->join('contacts', function (JoinClause $join) use ($root): void { + $join->on('users.id', '=', 'contacts.user_id') + ->join('addresses', function (JoinClause $nested) use ($root): void { + $nested->on(function (JoinClause $conditions) use ($root): void { + $this->assertSame($root->getConnection(), $conditions->getConnection()); + $this->assertSame($root->getGrammar(), $conditions->getGrammar()); + $this->assertSame($root->getProcessor(), $conditions->getProcessor()); + + $conditions->on('contacts.id', '=', 'addresses.contact_id') + ->where('addresses.country_id', '=', function (Builder $query) use ($root): void { + $this->assertSame($root::class, $query::class); + $this->assertNotSame($root, $query); + $this->assertSame($root->getConnection(), $query->getConnection()); + $this->assertSame($root->getGrammar(), $query->getGrammar()); + $this->assertSame($root->getProcessor(), $query->getProcessor()); + + $query->select('id')->from('countries')->where('code', 'GB'); + }); + }); + }); + }); + + $this->assertSame( + 'select * from "users" inner join ("contacts" inner join "addresses" on ("contacts"."id" = "addresses"."contact_id" and "addresses"."country_id" = (select "id" from "countries" where "code" = ?))) on "users"."id" = "contacts"."user_id"', + $root->toSql() + ); + $this->assertSame(['GB'], $root->getBindings()); + } + + #[DataProvider('joinWhereOperands')] + public function testJoinWhereHelpersAcceptValueOperands(string $method, string $join, mixed $value, string $predicate, array $bindings): void + { + $query = $this->builder(); + $query->grammar = new MySqlGrammar($query->getConnection()); + $query->from('users')->{$method}('contacts', 'contacts.active', '=', $value); + + $this->assertSame('select * from `users` ' . $join . ' `contacts` on `contacts`.`active` ' . $predicate, $query->toSql()); + $this->assertSame($bindings, $query->getBindings()); + } + + /** + * Cover each value-comparison helper and its binding or inline operand path. + */ + public static function joinWhereOperands(): iterable + { + foreach ([ + 'joinWhere' => 'inner join', + 'leftJoinWhere' => 'left join', + 'rightJoinWhere' => 'right join', + 'straightJoinWhere' => 'straight_join', + ] as $method => $join) { + yield $method . ' boolean' => [$method, $join, true, '= ?', [true]]; + yield $method . ' integer' => [$method, $join, 1, '= ?', [1]]; + yield $method . ' null' => [$method, $join, null, 'is null', []]; + yield $method . ' expression' => [$method, $join, new Expression('1'), '= 1', []]; + } + } + + /** + * Construct a builder without opening a database connection. + */ + protected function builder(): Builder + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + + return new Builder($connection, new Grammar($connection), new Processor); + } +} diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php new file mode 100644 index 000000000..cc20129d4 --- /dev/null +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -0,0 +1,215 @@ +exec('create table events (tenant_id integer)'); + $read->exec('create table events (tenant_id integer)'); + $write->exec('insert into events values (1), (2), (2)'); + $read->exec('insert into events values (1)'); + + $connection = new SQLiteConnection($write); + $connection->setReadPdo($read); + $query = $connection->table('events')->select('tenant_id')->groupBy('tenant_id'); + + if ($useWritePdo) { + $query->useWritePdo(); + } + + $this->assertCount($expectedCount, $query->get()); + $this->assertSame($expectedCount, $query->getCountForPagination()); + $this->assertSame($useWritePdo, $query->useWritePdo); + $this->assertCount($expectedCount, $query->get()); + $this->assertSame(1, $connection->table('events')->count()); + $this->assertSame($read, $connection->getRawReadPdo()); + $this->assertSame($write, $connection->getRawPdo()); + } + + /** + * Supply the default reader and explicit writer routes. + */ + public static function readWriteRouting(): iterable + { + yield 'reader' => [false, 1]; + yield 'writer' => [true, 2]; + } + + public function testCallbackGroupingIsAppliedBeforeChoosingTheCountQuery(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table events (tenant_id integer)'); + $connection->unprepared('insert into events values (1), (2), (2)'); + $callbackCalls = 0; + $query = $connection->table('events')->select('tenant_id')->fetchUsing(PDO::FETCH_ASSOC) + ->beforeQuery(function (Builder $query) use (&$callbackCalls): void { + ++$callbackCalls; + $query->groupBy('tenant_id')->orderBy('tenant_id')->fetchUsing(PDO::FETCH_COLUMN); + }); + + $this->assertSame(2, $query->getCountForPagination()); + $this->assertSame(1, $callbackCalls); + $this->assertNull($query->groups); + $this->assertNull($query->orders); + $this->assertSame([PDO::FETCH_ASSOC], $query->fetchUsing); + $this->assertCount(1, $query->beforeQueryCallbacks); + $this->assertSame([1, 2], $query->get()->all()); + $this->assertSame(2, $callbackCalls); + $this->assertSame([], $query->beforeQueryCallbacks); + } + + public function testPlainCountRemovesCallbackSuppliedProjectionOrderingAndPagination(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:')); + $connection->unprepared('create table events (tenant_id integer)'); + $connection->unprepared('insert into events values (1), (2), (2)'); + $connection->enableQueryLog(); + $query = $connection->table('events')->where('tenant_id', '>', 0) + ->beforeQuery(function (Builder $query): void { + $query->selectRaw('tenant_id + ? as bucket', [10]) + ->orderByRaw('tenant_id + ?', [20])->limit(1)->offset(1); + }); + + $this->assertSame(3, $query->getCountForPagination()); + $this->assertSame('select count(*) as "aggregate" from "events" where "tenant_id" > ?', $connection->getQueryLog()[0]['query']); + $this->assertSame([0], $connection->getQueryLog()[0]['bindings']); + $this->assertNull($query->columns); + $this->assertNull($query->orders); + $this->assertNull($query->limit); + $this->assertNull($query->offset); + $this->assertSame([0], $query->getBindings()); + $this->assertCount(1, $query->beforeQueryCallbacks); + } + + public function testGroupedCountTransfersCallbackTimeoutAndRoutingToTheOuterStatement(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new Builder($connection, new MySqlGrammar($connection), new Processor); + $query->from('events')->groupBy('tenant_id')->beforeQuery(function (Builder $query): void { + $query->timeout(5)->useWritePdo(); + }); + $connection->shouldReceive('select')->once()->with( + 'select /*+ MAX_EXECUTION_TIME(5000) */ count(*) as `aggregate` from (select * from `events` group by `tenant_id`) as `aggregate_table`', + [], + false, + [], + )->andReturn([['aggregate' => 2]]); + + $this->assertSame(2, $query->getCountForPagination()); + $this->assertNull($query->timeout); + $this->assertFalse($query->useWritePdo); + $this->assertCount(1, $query->beforeQueryCallbacks); + } + + public function testCallbackFailurePreservesIdentityAndRestoresTheOriginalFetchMode(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn(''); + $query = new Builder($connection, new Grammar($connection), new Processor); + $exception = new RuntimeException('Cannot prepare the query.'); + $query->from('events')->select('tenant_id')->fetchUsing(PDO::FETCH_COLUMN) + ->beforeQuery(static function () use ($exception): never { + throw $exception; + }); + + try { + $query->getCountForPagination(); + $this->fail('The before-query exception was not thrown.'); + } catch (RuntimeException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertCount(1, $query->beforeQueryCallbacks); + $this->assertSame([PDO::FETCH_COLUMN], $query->fetchUsing); + $query->beforeQueryCallbacks = []; + $connection->shouldReceive('select')->once()->with( + 'select "tenant_id" from "events"', + [], + true, + [PDO::FETCH_COLUMN], + )->andReturn([1, 2]); + $this->assertSame([1, 2], $query->get()->all()); + } + + #[DataProvider('tablePrefixes')] + public function testRetainedInnerBindingsBelongToTheDerivedTable(string $prefix): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getTablePrefix')->andReturn($prefix); + $processor = m::mock(Processor::class); + $query = new PaginationWithOrderingBuilder($connection, new Grammar($connection), $processor); + $query->from('events')->selectRaw('tenant_id + ? as bucket', [11]) + ->where('tenant_id', '>', 12)->groupBy('tenant_id') + ->havingRaw('count(*) > ?', [13])->orderByRaw('tenant_id + ?', [14]) + ->limit(5)->offset(10) + ->beforeQuery(function (Builder $query): void { + $query->where('active', 15)->useWritePdo(); + }); + $originalBindings = $query->getRawBindings(); + $originalOrders = $query->orders; + + $connection->shouldReceive('select')->once()->with( + 'select count(*) as "aggregate" from (select tenant_id + ? as bucket from "' . $prefix . 'events" where "tenant_id" > ? and "active" = ? group by "tenant_id" having count(*) > ? order by tenant_id + ?) as "aggregate_table"', + [11, 12, 15, 13, 14], + false, + [], + )->andReturn([['aggregate' => 3]]); + $processor->shouldReceive('processSelect')->once()->andReturnUsing(function (Builder $countQuery, array $results): array { + $this->assertSame([11, 12, 15, 13, 14], $countQuery->getRawBindings()['from']); + $this->assertSame([], $countQuery->getRawBindings()['select']); + $this->assertSame([], $countQuery->getRawBindings()['where']); + $this->assertSame([], $countQuery->getRawBindings()['having']); + $this->assertSame([], $countQuery->getRawBindings()['order']); + + return $results; + }); + + $this->assertSame(3, $query->getCountForPagination()); + $this->assertSame($originalBindings, $query->getRawBindings()); + $this->assertSame($originalOrders, $query->orders); + $this->assertSame(5, $query->limit); + $this->assertSame(10, $query->offset); + $this->assertFalse($query->useWritePdo); + $this->assertCount(1, $query->beforeQueryCallbacks); + } + + /** + * Supply connections with and without table prefixes. + */ + public static function tablePrefixes(): iterable + { + yield 'unprefixed' => ['']; + yield 'prefixed' => ['audit_']; + } +} + +class PaginationWithOrderingBuilder extends Builder +{ + /** + * Retain ordering required by a driver's pagination subquery. + */ + protected function cloneForPaginationCount(): static + { + return $this->cloneWithout(['limit', 'offset']); + } +} diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 5b3548526..45292568b 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -12,6 +12,7 @@ use Hypervel\Contracts\Database\Query\ConditionExpression; use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\HasMany; @@ -6057,6 +6058,38 @@ public function testAddBindingWithArrayMergesBindings() $this->assertEquals(['foo', 'bar', 'baz'], $builder->getBindings()); } + public function testCustomBindingSlotsSurviveFactoriesAndPaginationCloning(): void + { + $connection = $this->getConnection(); + $builder = new DatabaseQueryBuilderWithCustomBindings($connection, new Grammar($connection), new Processor); + $builder->from('users')->where('active', true)->orderByRaw('priority = ?', [10])->limit(5)->offset(2); + $builder->addBinding('tenant', 'expressions'); + + $fresh = $builder->newQuery(); + $this->assertInstanceOf(DatabaseQueryBuilderWithCustomBindings::class, $fresh); + $this->assertSame([], $fresh->getRawBindings()['expressions']); + $this->assertSame(['fresh'], $fresh->addBinding('fresh', 'expressions')->getBindings()); + + $nested = $builder->forNestedWhere(); + $this->assertInstanceOf(DatabaseQueryBuilderWithCustomBindings::class, $nested); + $this->assertSame('users', $nested->from); + $this->assertSame([], $nested->getRawBindings()['expressions']); + $this->assertSame(['nested'], $nested->setBindings(['nested'], 'expressions')->getBindings()); + + $clone = (new ReflectionMethod($builder, 'cloneForPaginationCount'))->invoke($builder); + $this->assertInstanceOf(DatabaseQueryBuilderWithCustomBindings::class, $clone); + $this->assertSame(['tenant', true], $clone->getBindings()); + $this->assertSame([], $clone->orders); + $this->assertNull($clone->limit); + $this->assertNull($clone->offset); + + $clone->addBinding('count', 'expressions'); + $this->assertSame(['tenant', 'count'], $clone->getRawBindings()['expressions']); + $this->assertSame(['tenant', true, 10], $builder->getBindings()); + $this->assertSame(5, $builder->limit); + $this->assertSame(2, $builder->offset); + } + public function testAddBindingWithArrayMergesBindingsInCorrectOrder() { $builder = $this->getBuilder(); @@ -6642,11 +6675,8 @@ public function testCursorPaginate() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->orderBy('test'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6681,11 +6711,8 @@ public function testCursorPaginateMultipleOrderColumns() $columns = ['test', 'another']; $cursorName = 'cursor-name'; $cursor = new Cursor(['test' => 'bar', 'another' => 'foo']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->orderBy('test')->orderBy('another'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6719,11 +6746,8 @@ public function testCursorPaginateWithDefaultArguments() $perPage = 15; $cursorName = 'cursor'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->orderBy('test'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6790,11 +6814,8 @@ public function testCursorPaginateWithSpecificColumns() $columns = ['id', 'name']; $cursorName = 'cursor-name'; $cursor = new Cursor(['id' => 2]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->orderBy('id'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=3'; @@ -6829,11 +6850,8 @@ public function testCursorPaginateWithMixedOrders() $columns = ['foo', 'bar', 'baz']; $cursorName = 'cursor-name'; $cursor = new Cursor(['foo' => 1, 'bar' => 2, 'baz' => 3]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->orderBy('foo')->orderByDesc('bar')->orderBy('baz'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6867,11 +6885,8 @@ public function testCursorPaginateWithDynamicColumnInSelectRaw() $perPage = 15; $cursorName = 'cursor'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->select('*')->selectRaw('(CONCAT(firstname, \' \', lastname)) as test')->orderBy('test'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6909,11 +6924,8 @@ public function testCursorPaginateWithDynamicColumnWithCastInSelectRaw() $perPage = 15; $cursorName = 'cursor'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->select('*')->selectRaw('(CAST(CONCAT(firstname, \' \', lastname) as VARCHAR)) as test')->orderBy('test'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6951,11 +6963,8 @@ public function testCursorPaginateWithDynamicColumnInSelectSub() $perPage = 15; $cursorName = 'cursor'; $cursor = new Cursor(['test' => 'bar']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->from('foobar')->select('*')->selectSub('CONCAT(firstname, \' \', lastname)', 'test')->orderBy('test'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); $path = 'http://foo.bar?cursor=' . $cursor->encode(); @@ -6996,15 +7005,11 @@ public function testCursorPaginateWithUnionWheres() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); $builder->orderBy('created_at'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7044,16 +7049,12 @@ public function testCursorPaginateWithMultipleUnionsAndMultipleWheres() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')->where('extra', 'first')); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'podcast' as type")->from('podcasts')->where('extra', 'second')); $builder->orderBy('created_at'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7094,16 +7095,12 @@ public function testCursorPaginateWithUnionMultipleWheresMultipleOrders() $columns = ['id', 'created_at', 'type']; $cursorName = 'cursor-name'; $cursor = new Cursor(['id' => 1, 'created_at' => $ts, 'type' => 'news']); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at', 'type')->from('videos')->where('extra', 'first'); $builder->union($this->getBuilder()->select('id', 'created_at', 'type')->from('news')->where('extra', 'second')); $builder->union($this->getBuilder()->select('id', 'created_at', 'type')->from('podcasts')->where('extra', 'third')); $builder->orderBy('id')->orderByDesc('created_at')->orderBy('type'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7145,15 +7142,11 @@ public function testCursorPaginateWithUnionWheresWithRawOrderExpression() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'is_published', 'start_time as created_at')->selectRaw("'video' as type")->where('is_published', true)->from('videos'); $builder->union($this->getBuilder()->select('id', 'is_published', 'created_at')->selectRaw("'news' as type")->where('is_published', true)->from('news')); $builder->orderByRaw('case when (id = 3 and type="news" then 0 else 1 end)')->orderBy('created_at'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7193,15 +7186,11 @@ public function testCursorPaginateWithUnionWheresReverseOrder() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts], false); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); $builder->orderBy('created_at'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7241,15 +7230,11 @@ public function testCursorPaginateWithUnionWheresMultipleOrders() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts, 'id' => 1]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); $builder->orderByDesc('created_at')->orderBy('id'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -7289,16 +7274,12 @@ public function testCursorPaginateWithUnionWheresAndAliassedOrderColumns() $columns = ['test']; $cursorName = 'cursor-name'; $cursor = new Cursor(['created_at' => $ts]); - $builder = $this->getMockQueryBuilder(); + $builder = $this->getMockQueryBuilder(['get']); $builder->select('id', 'start_time as created_at')->selectRaw("'video' as type")->from('videos'); $builder->union($this->getBuilder()->select('id', 'created_at')->selectRaw("'news' as type")->from('news')); $builder->union($this->getBuilder()->select('id', 'init_at as created_at')->selectRaw("'podcast' as type")->from('podcasts')); $builder->orderBy('created_at'); - $builder->shouldReceive('newQuery')->andReturnUsing(function () use ($builder) { - return new Builder($builder->connection, $builder->grammar, $builder->processor); - }); - $path = 'http://foo.bar?cursor=' . $cursor->encode(); $results = collect([ @@ -8021,11 +8002,13 @@ protected function getPostgresBuilderWithProcessor(string $prefix = '') } /** - * @return \Illuminate\Database\Query\Builder|\Mockery\MockInterface + * Create a partially mocked query builder. + * + * @param list $methods */ - protected function getMockQueryBuilder() + protected function getMockQueryBuilder(array $methods = []): Builder&m\MockInterface { - return m::mock(Builder::class, [ + return m::mock(Builder::class . ($methods ? '[' . implode(',', $methods) . ']' : ''), [ $connection = $this->getConnection(), new Grammar($connection), m::mock(Processor::class), @@ -8033,6 +8016,19 @@ protected function getMockQueryBuilder() } } +class DatabaseQueryBuilderWithCustomBindings extends Builder +{ + /** + * Create a query builder with an additional binding slot. + */ + public function __construct(ConnectionInterface $connection, ?Grammar $grammar = null, ?Processor $processor = null) + { + parent::__construct($connection, $grammar, $processor); + + $this->bindings = ['expressions' => []] + $this->bindings; + } +} + class QueryableSubqueryParentModel extends Model { protected ?string $table = 'queryable_subquery_parents'; diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index a61597168..4721c495b 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -8,6 +8,8 @@ use Hypervel\Database\Connection; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; +use Hypervel\Database\Schema\ColumnDefinition; +use Hypervel\Database\Schema\ForeignIdColumnDefinition; use Hypervel\Database\Schema\ForeignKeyDefinition; use Hypervel\Database\Schema\Grammars\MySqlGrammar; use Hypervel\Database\Schema\IndexDefinition; @@ -41,6 +43,50 @@ public function testBuildDelegatesToTheConnectionOwnedBuilder(): void $blueprint->build(); } + public function testInheritedColumnHelpersUseTheCustomDefinitionFactory(): void + { + $blueprint = new DatabaseSchemaCustomColumnBlueprint($this->getConnection(), 'users'); + + $name = $blueprint->string('name')->nullable(); + $count = $blueprint->unsignedBigInteger('count'); + $deletedAt = $blueprint->softDeletes(precision: 6); + $timestamps = $blueprint->timestamps(6); + $columns = [$name, $count, $deletedAt, ...$timestamps]; + + $this->assertSame($columns, $blueprint->getColumns()); + + foreach ($columns as $column) { + $this->assertInstanceOf(DatabaseSchemaCustomColumnDefinition::class, $column); + } + + $this->assertSame('string', $name->get('type')); + $this->assertSame(Builder::$defaultStringLength, $name->get('length')); + $this->assertTrue($name->get('nullable')); + $this->assertTrue($count->get('unsigned')); + $this->assertSame(6, $deletedAt->get('precision')); + $this->assertTrue($deletedAt->get('nullable')); + $this->assertSame(['created_at', 'updated_at'], $timestamps->pluck('name')->all()); + $this->assertSame(ColumnDefinition::class, $this->getBlueprint()->string('name')::class); + } + + public function testCustomColumnDefinitionsCoexistWithForeignIdDefinitions(): void + { + $blueprint = new DatabaseSchemaCustomColumnBlueprint($this->getConnection(), 'posts'); + $title = $blueprint->string('title')->change(); + $authorId = $blueprint->foreignId('author_id'); + $authorId->constrained(); + + $this->assertInstanceOf(DatabaseSchemaCustomColumnDefinition::class, $title); + $this->assertSame(ForeignIdColumnDefinition::class, $authorId::class); + $this->assertSame([$title, $authorId], $blueprint->getColumns()); + $this->assertSame([1 => $authorId], $blueprint->getAddedColumns()); + $this->assertSame([ + 'alter table `posts` modify `title` varchar(255) not null', + 'alter table `posts` add `author_id` bigint unsigned not null', + 'alter table `posts` add constraint `posts_author_id_foreign` foreign key (`author_id`) references `authors` (`id`)', + ], $blueprint->toSql()); + } + public function testIndexDefaultNames() { $blueprint = $this->getBlueprint(table: 'users'); @@ -964,6 +1010,24 @@ protected function getBlueprint( } } +class DatabaseSchemaCustomColumnDefinition extends ColumnDefinition +{ +} + +/** + * @extends Blueprint + */ +class DatabaseSchemaCustomColumnBlueprint extends Blueprint +{ + /** + * Create a new column definition. + */ + protected function newColumnDefinition(array $attributes): DatabaseSchemaCustomColumnDefinition + { + return new DatabaseSchemaCustomColumnDefinition($attributes); + } +} + enum ApostropheBackedEnum: string { case ValueWithoutApostrophe = 'this will work'; diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index 8140074ed..b2289ac9d 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\PdoConnection; +use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; @@ -41,6 +42,27 @@ public function testDropDatabaseIfExists() $this->assertTrue($builder->dropDatabaseIfExists('foo')); } + public function testTruncateTablesChecksWriteRowsAndSkipsEmptyTables(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(m::mock(Grammar::class)); + + foreach (['public.populated' => true, 'public.empty' => false] as $name => $hasRows) { + $query = m::mock(QueryBuilder::class); + $connection->shouldReceive('table')->once()->with($name)->andReturn($query); + $query->shouldReceive('useWritePdo')->once()->andReturnSelf(); + $query->shouldReceive('exists')->once()->andReturn($hasRows); + + if ($hasRows) { + $query->shouldReceive('truncate')->once(); + } else { + $query->shouldNotReceive('truncate'); + } + } + + (new Builder($connection))->truncateTables(['public.populated', 'public.empty']); + } + public function testExecuteBlueprintCompilesOnceAndExecutesStatementsInOrder(): void { $connection = m::mock(Connection::class); diff --git a/tests/Database/PoolManagerTest.php b/tests/Database/PoolManagerTest.php index e0b48ae97..22e59bc47 100644 --- a/tests/Database/PoolManagerTest.php +++ b/tests/Database/PoolManagerTest.php @@ -608,6 +608,22 @@ public function testReadConnectionUsesSeparatePoolWhenReadConfigExists(): void $this->assertTrue($poolManager->has('default::read')); } + public function testReadConnectionUsesSeparatePoolWhenReadConfigComesFromUrl(): void + { + $container = $this->mockContainerWithPools([ + 'default' => $this->connectionConfig([ + 'url' => 'mysql://root:@null/db?read[host][]=replica.test&write[host][]=primary.test', + ]), + ]); + $manager = new PoolManager($container); + $pool = $manager->pool('default::read'); + + $this->assertSame('default::read', $pool->getName()); + $this->assertNotSame($manager->pool('default'), $pool); + $this->assertInstanceOf(PoolManagerTestPool::class, $pool); + $this->assertSame(['replica.test'], $pool->configForTest()['host']); + } + public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): void { $container = $this->mockContainerWithPools([ diff --git a/tests/Foundation/Testing/DatabaseTransactionsTest.php b/tests/Foundation/Testing/DatabaseTransactionsTest.php new file mode 100644 index 000000000..4a57e76be --- /dev/null +++ b/tests/Foundation/Testing/DatabaseTransactionsTest.php @@ -0,0 +1,86 @@ +shouldReceive('setTransactionManager')->once(); + $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher); + $connection->shouldReceive('unsetEventDispatcher')->once()->ordered(); + $connection->shouldReceive('beginTransaction')->once()->andThrow($failure)->ordered(); + $connection->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->ordered(); + $testCase = $this->createTestCase($connection); + + try { + $testCase->beginDatabaseTransactionWork(); + $this->fail('Expected the transaction begin failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + + public function testRollbackRestoresTheDispatcherWhenItFails(): void + { + $failure = new RuntimeException('Transaction rollback failed.'); + $dispatcher = m::mock(Dispatcher::class); + $connection = m::mock(Connection::class); + $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher); + $connection->shouldReceive('unsetEventDispatcher')->once()->ordered(); + $connection->shouldReceive('forgetRecordModificationState')->once()->ordered(); + $connection->shouldReceive('rollBack')->once()->andThrow($failure)->ordered(); + $connection->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->ordered(); + $testCase = $this->createTestCase($connection); + + try { + $testCase->rollbackDatabaseTransactionWork(); + $this->fail('Expected the transaction rollback failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + + /** + * Create a test case for the given connection. + */ + private function createTestCase(ConnectionInterface $connection): DatabaseTransactionsTestCase + { + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); + $app = new Container; + $app->instance('db', $database); + + return new DatabaseTransactionsTestCase($app); + } +} + +class DatabaseTransactionsTestCase +{ + use DatabaseTransactions { + beginDatabaseTransactionWork as public; + rollbackDatabaseTransactionWork as public; + } + + /** + * Create a test case without automatic database lifecycle hooks. + */ + public function __construct(public Container $app) + { + } +} diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index 825b89e33..4fa202343 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -10,7 +10,6 @@ use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; use Hypervel\Database\PdoConnection; -use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\PostgresBuilder; use Hypervel\Foundation\Testing\DatabaseMigrations; @@ -23,6 +22,7 @@ use LogicException; use Mockery as m; use PDO; +use RuntimeException; class DatabaseTruncationTest extends TestCase { @@ -195,6 +195,23 @@ public function testTruncateTablesOnPgsqlWithSearchPath() $this->assertEquals(['public.foo', 'public.bar', 'my_schema.foo', 'my_schema.baz'], $truncatedTables); } + public function testTruncationRestoresTheDispatcherWhenItFails(): void + { + $failure = new RuntimeException('Truncation failed.'); + $connection = $this->arrangeConnection($truncatedTables, [ + ['schema' => 'public', 'name' => 'foo', 'schema_qualified_name' => 'public.foo'], + ], failure: $failure); + + try { + $this->truncateTablesForConnection($connection, 'test'); + $this->fail('Expected the truncation failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(['public.foo'], $truncatedTables); + } + public function testRestoreSkipsDatabaseResolutionWhenNoInMemoryConnectionIsCached(): void { $this->restoreInMemoryDatabases(); @@ -446,7 +463,8 @@ private function arrangeConnection( array $allTables, string $prefix = '', ?string $builder = null, - ?array $schemas = [] + ?array $schemas = [], + ?RuntimeException $failure = null ): Connection { $actual = []; @@ -457,26 +475,35 @@ private function arrangeConnection( : array_filter($allTables, fn ($table) => in_array($table['schema'], $schemas)) ); $schema->shouldReceive('getCurrentSchemaListing')->once()->andReturn($schemas); + $withoutPrefix = false; + $schema->shouldReceive('truncateTables')->once()->andReturnUsing( + function (array $tables) use (&$actual, &$withoutPrefix, $failure): void { + $this->assertTrue($withoutPrefix); + $actual = $tables; + + if ($failure !== null) { + throw $failure; + } + } + ); $connection = m::mock(Connection::class); $connection->shouldReceive('getTablePrefix')->andReturn($prefix); $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher = m::mock(Dispatcher::class)); $connection->shouldReceive('unsetEventDispatcher')->once(); $connection->shouldReceive('setEventDispatcher')->once()->with($dispatcher); - $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($schema); - $connection->shouldReceive('withoutTablePrefix')->andReturnUsing(function ($callback) use ($connection) { - $callback($connection); - }); - $connection->shouldReceive('table') - ->andReturnUsing(function (string $tableName) use (&$actual) { - $actual[] = $tableName; - - $table = m::mock(QueryBuilder::class); - $table->shouldReceive('exists')->andReturnTrue(); - $table->shouldReceive('truncate'); - - return $table; - }); + $connection->shouldReceive('getSchemaBuilder')->twice()->andReturn($schema); + $connection->shouldReceive('withoutTablePrefix')->once()->andReturnUsing( + function ($callback) use ($connection, &$withoutPrefix): void { + $withoutPrefix = true; + + try { + $callback($connection); + } finally { + $withoutPrefix = false; + } + } + ); return $connection; } diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index f736b0a51..7281a6aca 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -603,6 +603,56 @@ public function testMigrateRefreshReplacesTheCachedInMemoryPdo(): void $this->assertSame(['default' => $freshPdo], RefreshDatabaseState::$inMemoryConnections); } + public function testBeginTransactionRestoresTheDispatcherWhenItFails(): void + { + $failure = new RuntimeException('Transaction begin failed.'); + $dispatcher = m::mock(Dispatcher::class); + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('setTransactionManager')->once(); + $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher); + $connection->shouldReceive('unsetEventDispatcher')->once()->ordered(); + $connection->shouldReceive('beginTransaction')->once()->andThrow($failure)->ordered(); + $connection->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->ordered(); + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); + $this->app->instance('db', $database); + + try { + $this->beginDatabaseTransactionWork(); + $this->fail('Expected the transaction begin failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + + public function testRollbackRestoresTheDispatcherAndKeepsMigrationBookkeepingWhenItFails(): void + { + RefreshDatabaseState::$migrated = true; + $failure = new RuntimeException('Transaction rollback failed.'); + $dispatcher = m::mock(Dispatcher::class); + $connection = m::mock(PdoConnection::class); + $connection->shouldReceive('getEventDispatcher')->once()->andReturn($dispatcher); + $connection->shouldReceive('unsetEventDispatcher')->once()->ordered(); + $connection->shouldReceive('inTransaction')->once()->andReturnFalse()->ordered(); + $connection->shouldReceive('forgetRecordModificationState')->once()->ordered(); + $connection->shouldReceive('rollBack')->once()->andThrow($failure)->ordered(); + $connection->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->ordered(); + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); + $this->app->instance('db', $database); + + try { + $this->rollbackDatabaseTransactionWork(); + $this->fail('Expected the transaction rollback failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertFalse(RefreshDatabaseState::$migrated); + } + protected function getMockedDatabase(): DatabaseManager { $connection = m::mock(ConnectionInterface::class); diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 5c0a6a7c1..ceadd2d74 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -1621,6 +1621,73 @@ public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle() } } + public function testReadExtensionRetainsCompleteConfigurationAndReadPoolOptionsThroughReconnect(): void + { + $readPoolOptions = [ + 'min_retained_connections' => 1, + 'max_connections' => 2, + 'connect_timeout' => 1.25, + 'heartbeat_interval' => null, + ]; + $config = [ + 'driver' => 'neutral', + 'database' => 'analytics', + 'host' => 'base.test', + 'read' => [ + ['host' => 'read-one.test', 'username' => 'reader-one', 'pool' => $readPoolOptions], + ['host' => 'read-two.test', 'username' => 'reader-two', 'pool' => $readPoolOptions], + ], + 'write' => ['host' => 'write.test'], + 'pool' => [ + 'min_retained_connections' => 1, + 'max_connections' => 5, + 'connect_timeout' => 10.0, + 'heartbeat_interval' => null, + ], + ]; + config(['database.connections.neutral_read_pool_test' => $config]); + + /** @var ConnectionFactory $factory */ + $factory = $this->app->make('db.factory'); + $receivedConfigurations = []; + $factory->extend('neutral', static function (array $config) use (&$receivedConfigurations): NeutralPoolConnection { + $receivedConfigurations[] = $config; + + return new NeutralPoolConnection(count($receivedConfigurations), $config['database'], $config['prefix'], $config); + }); + $pool = new DatabasePool($this->app, 'neutral_read_pool_test::read'); + $pooledConnection = null; + $expected = $config + [ + 'prefix' => '', + 'name' => 'neutral_read_pool_test', + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'read', + 'connect_timeout' => 1.25, + ]; + + try { + $this->assertSame(2, $pool->getOptions()->maxConnections); + $this->assertSame(1.25, $pool->getOptions()->connectTimeout); + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->borrow(); + $connection = $pooledConnection->getConnection(); + + $this->assertInstanceOf(NeutralPoolConnection::class, $connection); + $this->assertSame([$expected], $receivedConfigurations); + $this->assertSame($expected + ['mask_bindings_in_exception_messages' => false], $connection->getConfig()); + + $connection->dropResources(); + $connection->reconnectIfMissingConnection(); + + $this->assertSame($connection, $pooledConnection->getConnection()); + $this->assertSame(2, $connection->generation); + $this->assertSame([$expected, $expected], $receivedConfigurations); + } finally { + $pooledConnection?->release(); + $pool->close(); + } + } + public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void { $this->app->make('config')->set('database.connections.mysql_insert_id_pool_test', [ diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index 7e8a44a21..c0bb6cbf3 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -7,8 +7,10 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\SQLiteConnection; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use PDO; class DatabaseSchemaBuilderTest extends SqliteTestCase { @@ -66,6 +68,70 @@ public function testDropAllTablesWorksWithForeignKeys(): void $this->artisan('migrate:install'); } + public function testCreateMigrationRepositoryTablePreservesTheRelationalSchemaAndPrefix(): void + { + $connection = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', 'audit_'); + $builder = $connection->getSchemaBuilder(); + + $builder->createMigrationRepositoryTable('migrations'); + + $columns = $builder->getColumns('migrations'); + + $this->assertSame(['id', 'migration', 'batch'], array_column($columns, 'name')); + $this->assertTrue($columns[0]['auto_increment']); + $this->assertSame(['integer', 'varchar', 'integer'], array_column($columns, 'type_name')); + $this->assertSame([false, false, false], array_column($columns, 'nullable')); + $this->assertSame(['audit_migrations'], $builder->getTableListing(schemaQualified: false)); + + $connection->table('migrations')->insert(['migration' => 'create_users', 'batch' => 1]); + + $this->assertSame(1, $connection->table('migrations')->value('id')); + } + + public function testTruncateTablesClearsWriteRowsWhenTheReadDatabaseIsEmpty(): void + { + $write = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:', '', ['sticky' => false]); + $read = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'); + + foreach ([$write, $read] as $connection) { + $connection->getSchemaBuilder()->create('widgets', function (Blueprint $table): void { + $table->id(); + }); + } + + $write->table('widgets')->insert(['id' => 1]); + $write->setReadPdo($read->getPdo()); + + $this->assertFalse($write->table('widgets')->exists()); + $this->assertTrue($write->table('widgets')->useWritePdo()->exists()); + + $write->getSchemaBuilder()->truncateTables(['main.widgets']); + + $this->assertSame(0, $write->table('widgets')->useWritePdo()->count()); + } + + public function testTruncateTablesPreservesThePrefixAndSkipsEmptyTables(): void + { + $connection = DB::connection('sqlite-with-indexed-prefix'); + $schema = $connection->getSchemaBuilder(); + + foreach (['populated', 'empty'] as $table) { + $schema->create($table, function (Blueprint $blueprint): void { + $blueprint->id(); + }); + $connection->table($table)->insert(['id' => 1]); + } + + $connection->table('empty')->delete(); + + $schema->truncateTables(['populated', 'empty']); + + $this->assertSame(0, $connection->table('populated')->count()); + $this->assertSame(0, $connection->table('empty')->count()); + $this->assertSame(1, $connection->table('populated')->insertGetId(['id' => null])); + $this->assertSame(2, $connection->table('empty')->insertGetId(['id' => null])); + } + public function testHasColumnAndIndexWithPrefixIndexDisabled(): void { $connection = DB::connection('sqlite-with-prefix'); diff --git a/tests/Support/ConfigurationUrlParserTest.php b/tests/Support/ConfigurationUrlParserTest.php index d50d376f0..f515f7a92 100644 --- a/tests/Support/ConfigurationUrlParserTest.php +++ b/tests/Support/ConfigurationUrlParserTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Support; +use Generator; use Hypervel\Support\ConfigurationUrlParser; use Hypervel\Tests\TestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -416,6 +417,81 @@ public static function databaseUrls() ]; } + #[DataProvider('literalUrlComponents')] + public function testUrlComponentsPreserveLiteralValues(string $url, array $expected): void + { + $this->assertSame($expected, (new ConfigurationUrlParser)->parseConfiguration($url)); + } + + /** + * Provide literal URL components and typed query options. + */ + public static function literalUrlComponents(): Generator + { + foreach ([ + 'true' => 'true', + 'false' => 'false', + 'null' => 'null', + 'zero' => '0', + 'integer' => '123', + 'exponent' => '1e3', + 'wide integer' => '18446744073709551615', + 'quoted text' => '"quoted"', + 'array text' => '[1,2]', + 'object text' => '{"key":1}', + 'percent encoding' => 'reader:secret%3A/@+ ', + ] as $name => $credential) { + $encoded = rawurlencode($credential); + + yield $name => [ + "mysql://{$encoded}:{$encoded}@true:3306/analytics?sticky=true&timeout=5&options[persistent]=false", + [ + 'driver' => 'mysql', + 'database' => 'analytics', + 'host' => 'true', + 'port' => 3306, + 'username' => $credential, + 'password' => $credential, + 'sticky' => true, + 'timeout' => 5, + 'options' => ['persistent' => false], + ], + ]; + } + + yield 'missing credentials' => [ + 'mysql://localhost/analytics', + ['driver' => 'mysql', 'database' => 'analytics', 'host' => 'localhost'], + ]; + + yield 'empty credentials' => [ + 'mysql://:@localhost/analytics', + [ + 'driver' => 'mysql', + 'database' => 'analytics', + 'host' => 'localhost', + 'username' => '', + 'password' => '', + ], + ]; + + yield 'omitted host sentinel' => [ + 'mysql://null/analytics', + ['driver' => 'mysql', 'database' => 'analytics'], + ]; + + yield 'typed query override' => [ + 'mysql://reader:original@localhost/analytics?password=%22null%22', + [ + 'driver' => 'mysql', + 'database' => 'analytics', + 'host' => 'localhost', + 'username' => 'reader', + 'password' => 'null', + ], + ]; + } + public function testDriversAliases() { $this->assertEquals([ diff --git a/tests/Testbench/Databases/DatabaseTruncationSetupFailureTest.php b/tests/Testbench/Databases/DatabaseTruncationSetupFailureTest.php new file mode 100644 index 000000000..c7cdd1469 --- /dev/null +++ b/tests/Testbench/Databases/DatabaseTruncationSetupFailureTest.php @@ -0,0 +1,109 @@ +skipSetup = $skip; + $failure = null; + + ResetRefreshDatabaseState::run(); + DatabaseTruncationSetupFailureFixture::setUpBeforeClass(); + + try { + try { + try { + $fixture->start(); + } catch (Throwable $throwable) { + $failure = $throwable; + } finally { + $fixture->finish(); + } + + $this->assertInstanceOf($skip ? SkippedWithMessageException::class : RuntimeException::class, $failure); + $this->assertSame('Intentional setup interruption.', $failure->getMessage()); + // Per-method teardown still retains the database for truncation reuse. + $this->assertTrue(RefreshDatabaseState::$migrated); + $this->assertArrayHasKey('testing', RefreshDatabaseState::$inMemoryConnections); + } finally { + DatabaseTruncationSetupFailureFixture::tearDownAfterClass(); + } + + $this->assertFalse(RefreshDatabaseState::$migrated); + $this->assertSame([], RefreshDatabaseState::$inMemoryConnections); + } finally { + ResetRefreshDatabaseState::run(); + } + } + + public static function setupFailures(): array + { + return [ + 'skipped service setup' => [true], + 'failed setup' => [false], + ]; + } +} + +#[WithConfig('database.default', 'testing')] +class DatabaseTruncationSetupFailureFixture extends TestbenchTestCase +{ + use DatabaseTruncation; + use InterruptsDatabaseSetup; + + public function start(): void + { + $this->setUp(); + } + + public function finish(): void + { + $this->tearDown(); + } + + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom(workbench_path('database/migrations')); + } + + public function testPlaceholder(): void + { + $this->fail('Setup must abort before the test body.'); + } +} + +/** + * @phpstan-require-extends TestbenchTestCase + */ +trait InterruptsDatabaseSetup +{ + public bool $skipSetup = false; + + protected function setUpInterruptsDatabaseSetup(): void + { + if ($this->skipSetup) { + $this->markTestSkipped('Intentional setup interruption.'); + } + + throw new RuntimeException('Intentional setup interruption.'); + } +} diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index cc22c5d93..ebc90a5c6 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -35,8 +35,8 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->whereIn('id', [1])->with('relation')); assertType('Hypervel\Database\Eloquent\Builder', $query->orderBy('id')->with('relation')); assertType('Hypervel\Database\Eloquent\Builder', $query->limit(1)->with('relation')); - assertType('Hypervel\Database\Query\Builder', $query->dump()); - assertType('Hypervel\Database\Query\Builder', $query->dumpRawSql()); + assertType("Hypervel\\Database\\Query\\Builder", $query->dump()); + assertType("Hypervel\\Database\\Query\\Builder", $query->dumpRawSql()); assertType('stdClass|null', $query->toBase()->first()); assertType('stdClass|null', $query->getQuery()->first()); assertType('Hypervel\Database\Eloquent\Builder', $query->with('relation')); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 3f7dce442..ae0923380 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -38,8 +38,8 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->getResults()); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->fetchUsing(PDO::FETCH_ASSOC)->get()); assertType('Hypervel\Types\Relations\Post|null', $user->posts()->useWritePdo()->first()); - assertType('Hypervel\Database\Query\Builder', $user->posts()->dump()); - assertType('Hypervel\Database\Query\Builder', $user->posts()->dumpRawSql()); + assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dump()); + assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dumpRawSql()); assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->clone()); assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->applyScopes()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereIn('id', [1])); diff --git a/types/Database/Query/Builder.php b/types/Database/Query/Builder.php index d4205fdec..5b126d3a4 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -8,7 +8,11 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Query\Builder; +use Hypervel\Database\Query\Grammars\Grammar; +use Hypervel\Database\Query\JoinClause; +use Hypervel\Database\Query\Processors\Processor; use PDO; +use stdClass; use User; use function PHPStan\Testing\assertType; @@ -154,3 +158,99 @@ function testFetchUsingResetRemainsConservative(Builder $query): void assertType('Hypervel\Support\Collection<(int|string), mixed>', $query->get()); } + +function testBindingSlots(Builder $query, CustomBindingBuilder $custom): void +{ + $query->addBinding(1, 'where'); + $query->setBindings([1], 'select'); + $query->cloneWithoutBindings(['where', 'order']); + assertType('Hypervel\Database\Query\Builder', $query->newQuery()); + + $query->addBinding(1, 'wher'); // @phpstan-ignore argument.type (Misspelled default slots must reject.) + + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->addBinding(1, 'expressions')); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->setBindings([2], 'expressions')); + assertType("array<'expressions'|'from'|'groupBy'|'having'|'join'|'order'|'select'|'union'|'unionOrder'|'where', list>", $custom->getRawBindings()); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->cloneWithoutBindings(['expressions', 'where'])); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->clone()); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->newQuery()); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->forNestedWhere()); + assertType('list', $custom->forNestedWhere()->getRawBindings()['expressions']); + assertType('Hypervel\Types\Query\Builder\CustomBindingBuilder', $custom->nestedExpressionBinding(1)); + $custom->clone()->addBinding(3, 'expressions'); + $custom->newQuery()->addBinding(4, 'expressions'); + $custom->forNestedWhere()->addBinding(5, 'expressions'); + $custom->setBindings([1], 'expression'); // @phpstan-ignore argument.type (Misspelled extension slots must reject.) + $custom->cloneWithoutBindings(['expression']); // @phpstan-ignore argument.type (Cloning must validate the same slot type.) + + $custom->fetchUsing(PDO::FETCH_ASSOC)->addBinding(6, 'expressions'); + $custom->addBinding(7, 'expressions'); +} + +function testJoinBuilderFactories(JoinClause $join, CustomJoinClause $custom): void +{ + assertType('Hypervel\Database\Query\JoinClause', $join->newQuery()); + assertType('Hypervel\Database\Query\Builder', $custom->subQuery()); +} + +/** @extends Builder */ +class CustomBindingBuilder extends Builder +{ + /** + * Create a builder with an additional binding slot. + */ + public function __construct(ConnectionInterface $connection, ?Grammar $grammar = null, ?Processor $processor = null) + { + parent::__construct($connection, $grammar, $processor); + + $this->bindings['expressions'] = []; + } + + /** + * Add a binding to the expression clause. + */ + public function expressionBinding(mixed $value): static + { + return $this->addBinding($value, 'expressions'); + } + + /** + * Add an expression binding through a nested query. + */ + public function nestedExpressionBinding(mixed $value): static + { + return $this->mergeExpressionBindings($this->forNestedWhere()->expressionBinding($value)); + } + + /** + * Merge another expression clause's bindings. + */ + protected function mergeExpressionBindings(self $query): static + { + return $this->addBinding($query->getRawBindings()['expressions'], 'expressions'); + } + + /** + * Verify protected factories retain the custom binding slot. + */ + public function testProtectedFactoryTypes(): void + { + $this->cloneForPaginationCount()->expressionBinding(1); + $this->forSubQuery()->addBinding(2, 'expressions'); + } +} + +class CustomJoinClause extends JoinClause +{ + /** + * Expose the parent query returned for join subqueries. + */ + public function subQuery(): Builder + { + $query = $this->forSubQuery(); + + assertType("Hypervel\\Database\\Query\\Builder", $query); + + return $query; + } +} diff --git a/types/Database/Schema.php b/types/Database/Schema.php index eca60fffc..5f9c56bb7 100644 --- a/types/Database/Schema.php +++ b/types/Database/Schema.php @@ -5,9 +5,38 @@ namespace Hypervel\Types\Database\Schema; use Hypervel\Database\Schema\Blueprint; +use Hypervel\Database\Schema\ColumnDefinition; use function PHPStan\Testing\assertType; +/** + * Verify the default column definition type. + */ +function testColumnDefinitionsUseTheDefaultType(Blueprint $table): void +{ + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->string('name')); + assertType('Hypervel\Database\Schema\ColumnDefinition', $table->softDeletes()->nullable()); + assertType('Hypervel\Support\Collection', $table->timestamps()); +} + +/** + * Verify factory-created returns without narrowing heterogeneous column storage. + */ +function testCustomColumnDefinitionsUseTheFactoryType(CustomBlueprint $table): void +{ + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->string('name')->nullable()->label('Display name')); + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->unsignedBigInteger('count')); + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->softDeletes()); + assertType('Hypervel\Types\Database\Schema\CustomColumnDefinition', $table->addColumn('string', 'title')); + assertType('Hypervel\Support\Collection', $table->timestamps()); + assertType('Hypervel\Support\Collection', $table->datetimes()); + assertType('Hypervel\Database\Schema\ForeignIdColumnDefinition', $table->foreignId('author_id')); + assertType('Hypervel\Database\Schema\ForeignIdColumnDefinition', $table->foreignUuid('owner_id')); + assertType('Hypervel\Database\Schema\ForeignKeyDefinition', $table->foreignId('team_id')->constrained()); + assertType('list', $table->getColumns()); + assertType('array', $table->getAddedColumns()); +} + function testIndexDefinitionsUseConcreteTypes(Blueprint $table): void { assertType('Hypervel\Database\Schema\IndexDefinition', $table->primary('id')); @@ -22,3 +51,30 @@ function testIndexDefinitionsUseConcreteTypes(Blueprint $table): void $table->index('archived_at')->whereNotNull('archived_at'), ); } + +class CustomColumnDefinition extends ColumnDefinition +{ + /** + * Set the column's display label. + */ + public function label(string $label): static + { + return $this->set('label', $label); + } +} + +/** + * @extends Blueprint + */ +class CustomBlueprint extends Blueprint +{ + /** + * Create a new column definition. + * + * @param array $attributes + */ + protected function newColumnDefinition(array $attributes): CustomColumnDefinition + { + return new CustomColumnDefinition($attributes); + } +}