From c3097743c67422563b1e1e22f69346a97350bb9f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:48 +0000 Subject: [PATCH 01/18] Centralize connection routing and streaming execution Move read/write routing policy into the protocol-neutral connection so native and HTTP drivers can honor the same transaction, forced-write, and sticky-read behavior as PDO drivers. Keep resource selection in the concrete driver and retain existing PDO cursor timing. Add an opt-in streaming execution boundary that defers execution until iteration, reports success only after exhaustion, and preserves cleanup and cancellation. Restore the exact retrieved role across consumer yields, use the existing retry policy only before a value is yielded, and share query-exception construction without changing public execution signatures. Cover deferred hooks, retry and failure paths, abandonment, nested query roles, exception overrides, routing, and pool reset. Full framework composer fix passed; the final connection test rerun passed with 122 tests and 553 assertions. --- src/database/src/Connection.php | 134 ++++- src/database/src/PdoConnection.php | 8 +- tests/Database/DatabaseConnectionTest.php | 597 ++++++++++++++++++++++ 3 files changed, 716 insertions(+), 23 deletions(-) diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 167c6c5ee..c40386711 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -595,6 +595,83 @@ 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 + */ + 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 $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 $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. * @@ -618,27 +695,35 @@ 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(), - ); + /** + * 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(), + ); - $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; } /** @@ -1184,6 +1269,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/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/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index db96ee9e8..a2d4e1074 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,14 @@ use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\Grammars\Grammar as SchemaGrammar; use Hypervel\Database\SQLiteConnection; +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 +881,468 @@ 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()); + } + + 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 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 +2247,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 +2666,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( From ce0000094aa66dd92a77b7c22209f266f7d48654 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:48 +0000 Subject: [PATCH 02/18] Preserve database builder extension types Carry custom binding-slot types through query builder factories and cloning while preserving existing runtime binding APIs. Use native static returns where factories already preserve the concrete builder; keep subquery returns broad enough for a join to create a parent query builder. Centralize ordinary Blueprint column creation behind a typed factory. Preserve base-typed heterogeneous storage and specialized foreign-ID definitions, allowing custom column modifiers without pretending every stored definition has the same subtype. Extend runtime tests and PHPStan fixtures for nested queries, pagination clones, custom slots, inherited column helpers, and mixed definition storage. Full framework composer fix and the final source/type analysis passed. --- src/database/src/Query/Builder.php | 42 ++--- src/database/src/Schema/Blueprint.php | 145 ++++++++++++++++-- tests/Database/DatabaseQueryBuilderTest.php | 136 ++++++++-------- .../Database/DatabaseSchemaBlueprintTest.php | 64 ++++++++ types/Database/Eloquent/Builder.php | 4 +- types/Database/Eloquent/Relations.php | 4 +- types/Database/Query/Builder.php | 101 ++++++++++++ types/Database/Schema.php | 56 +++++++ 8 files changed, 439 insertions(+), 113 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index d4f5bc737..4dccaf7d4 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' => [], @@ -1697,7 +1688,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(); @@ -3180,7 +3171,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']); @@ -3938,13 +3929,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 { @@ -4014,17 +4008,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 { @@ -4035,7 +4019,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 */ @@ -4053,7 +4037,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 */ @@ -4181,7 +4165,7 @@ public function useWritePdo(): static * * @return $this * - * @phpstan-this-out self + * @phpstan-this-out self */ public function fetchUsing(mixed ...$fetchUsing): static { @@ -4237,6 +4221,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/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 42feec03b..2ef6b8e62 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 (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 (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 (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 (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 (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 (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 (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 characters). + * + * @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 characters / ~64 KB). + * + * @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 characters / ~16 MB). + * + * @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 characters / ~4 GB). + * + * @return TColumnDefinition */ public function longText(string $column): ColumnDefinition { @@ -816,6 +845,8 @@ public function longText(string $column): ColumnDefinition /** * Create a new integer (4-byte) column on the table. * Range: -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 (1-byte) column on the table. * Range: -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 (2-byte) column on the table. * Range: -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 (3-byte) column on the table. * Range: -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 (8-byte) column on the table. * Range: -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 (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 (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 (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 (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 (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/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 4decaa72c..0ca809ee5 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -11,6 +11,7 @@ use DateTime; use Hypervel\Contracts\Database\Query\ConditionExpression; 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; @@ -5846,6 +5847,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(); @@ -6431,11 +6464,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(); @@ -6470,11 +6500,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(); @@ -6508,11 +6535,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(); @@ -6579,11 +6603,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'; @@ -6618,11 +6639,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(); @@ -6656,11 +6674,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(); @@ -6698,11 +6713,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(); @@ -6740,11 +6752,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(); @@ -6785,15 +6794,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([ @@ -6833,16 +6838,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([ @@ -6883,16 +6884,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([ @@ -6934,15 +6931,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([ @@ -6982,15 +6975,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([ @@ -7030,15 +7019,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([ @@ -7078,16 +7063,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([ @@ -7810,11 +7791,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), @@ -7822,6 +7805,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/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 565795766..101ff006f 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -33,8 +33,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 4f0fa267b..d0efa78c4 100644 --- a/types/Database/Query/Builder.php +++ b/types/Database/Query/Builder.php @@ -4,9 +4,14 @@ namespace Hypervel\Types\Query\Builder; +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; @@ -128,3 +133,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); + } +} From ef4e0d1216e1e888bb7907934d19a3513bc9f8b3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:49 +0000 Subject: [PATCH 03/18] Delegate database testing schema operations to drivers Let schema builders own migration-repository creation and bulk testing truncation while retaining the existing repository and Foundation testing lifecycle. Keep the default relational migration table and normalize numeric-string batch aggregates to the declared integer return type. Apply table filters and prefix handling before bulk delegation. Route default row-existence checks to the writer so stale replicas cannot leave primary rows behind. Restore the exact event dispatcher in finally blocks when transaction setup, rollback, discovery, or truncation fails. Add schema, migration, failure-identity, and SQLite read/write regression coverage, update the Schema facade and native testing documentation, and retain existing seeding and transaction behavior. Full framework composer fix passed, including parallel, Testbench, and dogfood suites. --- .../DatabaseMigrationRepository.php | 11 +-- src/database/src/Schema/Builder.php | 32 +++++++ src/docs/database-testing.md | 2 + .../src/Testing/DatabaseTransactions.php | 25 +++--- .../src/Testing/DatabaseTruncation.php | 43 +++++----- .../src/Testing/RefreshDatabase.php | 31 ++++--- src/support/src/Facades/Schema.php | 2 + .../DatabaseMigrationRepositoryTest.php | 25 ++++-- tests/Database/DatabaseSchemaBuilderTest.php | 22 +++++ .../Testing/DatabaseTransactionsTest.php | 86 +++++++++++++++++++ .../Testing/DatabaseTruncationTest.php | 59 +++++++++---- .../Testing/RefreshDatabaseTest.php | 50 +++++++++++ .../Sqlite/DatabaseSchemaBuilderTest.php | 66 ++++++++++++++ 13 files changed, 379 insertions(+), 75 deletions(-) create mode 100644 tests/Foundation/Testing/DatabaseTransactionsTest.php 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/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/docs/database-testing.md b/src/docs/database-testing.md index a94fd64d6..067b8f34f 100644 --- a/src/docs/database-testing.md +++ b/src/docs/database-testing.md @@ -63,6 +63,8 @@ Hypervel application tests run inside a coroutine by default. When a transaction If you would like to totally reset the database, you may use the `Hypervel\Foundation\Testing\DatabaseMigrations` or `Hypervel\Foundation\Testing\DatabaseTruncation` traits instead. However, both of these options are significantly slower than the `RefreshDatabase` trait. +Transaction-based traits require a connection with real transaction support. `DatabaseTruncation` uses the connection's schema builder to reset the selected tables, allowing custom database drivers to provide their own reset behavior without replacing the native testing traits. Driver authors should follow the [schema-builder extension contracts](/docs/{{version}}/database#extending-database-connections). + ### Combining Database Reset Traits 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/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/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/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/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/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index 212e73ed2..d17024477 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'); From 7ef1a21449c84c09e72562f0752f9e278be00793 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:49 +0000 Subject: [PATCH 04/18] Preserve literal configuration URL components Preserve primary URL components as literal strings after one percent-decoding pass instead of JSON-decoding credentials and host names. Keep the native integer port and the existing null-host convention used by SQLite and role-only URLs. Continue converting query options to native configuration types, preserving that separate public contract. This prevents numeric, boolean-looking, and JSON-looking credentials from changing before database and other service factories receive them. Add strict parser cases for literal and encoded credentials, missing versus empty values, hosts, ports, and typed query options, plus a config-first database resolver regression. Full framework composer fix passed, covering shared parser consumers. --- src/support/src/ConfigurationUrlParser.php | 10 ++- .../DatabaseConnectionFactoryTest.php | 22 ++++++ tests/Support/ConfigurationUrlParserTest.php | 76 +++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) 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/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index 664d71ea1..51b4d8928 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -506,6 +506,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/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([ From 9cd85b712361741427cf7800ef21d5240c7a6522 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:49 +0000 Subject: [PATCH 05/18] Retain complete endpoint configuration for custom drivers Retain complete read/write endpoint records when resolving a custom config-first driver through an explicit read alias. Reuse the existing name-before-driver resolver lookup instead of introducing another registry. Keep the selected read projection local to pool options and SQLite classification. PDO drivers still receive the same projected record, while custom drivers receive complete configuration and the role marker through creation and reconnect. Parse URL configuration before deciding whether a derived read pool exists. Cover direct and pooled custom-driver reconnects, read-side pool overrides, normalized timeouts, and URL-only read records while preserving existing PDO and SQLite guards. Full framework composer fix passed. --- .../src/Connectors/ConnectionFactory.php | 17 +++-- src/database/src/DatabaseManager.php | 4 +- src/database/src/Pool/DbPool.php | 29 +++++--- src/database/src/Pool/PoolFactory.php | 2 + tests/Database/DatabaseManagerTest.php | 38 +++++++++++ tests/Database/PoolFactoryTest.php | 16 +++++ .../Database/PooledConnectionTest.php | 67 +++++++++++++++++++ 7 files changed, 160 insertions(+), 13 deletions(-) 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/DatabaseManager.php b/src/database/src/DatabaseManager.php index ba398d89f..9f9cd67f6 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/Pool/DbPool.php b/src/database/src/Pool/DbPool.php index 4ed493587..81f969620 100644 --- a/src/database/src/Pool/DbPool.php +++ b/src/database/src/Pool/DbPool.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Pool\ConnectionInterface; use Hypervel\Coordinator\Timer; +use Hypervel\Database\Connection; use Hypervel\Database\ConnectionName; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\SQLiteDatabase; @@ -40,6 +41,9 @@ class DbPool extends Pool */ protected ?PDO $sharedInMemorySqlitePdo = null; + /** + * Create a database connection pool. + */ public function __construct(Container $container, string $name) { $connectionName = ConnectionName::parse($name); @@ -56,24 +60,33 @@ 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; // Extract pool options $poolOptions = Arr::except( - Arr::get($this->config, 'pool', []), + Arr::get($poolConfig, 'pool', []), ['testing_enabled'], ); $minimum = $poolOptions['min_connections'] ?? 1; $maximum = $poolOptions['max_connections'] ?? 10; - if ($this->isInMemorySqlite() + if ($this->isInMemorySqlite($poolConfig) && is_int($minimum) && is_int($maximum) && $minimum >= 0 @@ -92,7 +105,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(); } @@ -140,13 +153,13 @@ protected function createSharedInMemorySqlitePdo(): PDO /** * 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/PoolFactory.php b/src/database/src/Pool/PoolFactory.php index 94701059b..9a6ed4f23 100644 --- a/src/database/src/Pool/PoolFactory.php +++ b/src/database/src/Pool/PoolFactory.php @@ -80,6 +80,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/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 4b6d14e73..65bca1a97 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, $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/PoolFactoryTest.php b/tests/Database/PoolFactoryTest.php index cad6bc393..2aa3e8000 100644 --- a/tests/Database/PoolFactoryTest.php +++ b/tests/Database/PoolFactoryTest.php @@ -302,6 +302,22 @@ public function testReadConnectionUsesSeparatePoolWhenReadConfigExists(): void $this->assertTrue($factory->hasPool('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', + ]), + ]); + $factory = new PoolFactory($container); + $pool = $factory->getPool('default::read'); + + $this->assertSame('default::read', $pool->getName()); + $this->assertNotSame($factory->getPool('default'), $pool); + $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); + $this->assertSame(['replica.test'], $pool->configForTest()['host']); + } + public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): void { $container = $this->mockContainerWithPools([ diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 263df8d55..7afd7f457 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -1591,6 +1591,73 @@ public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle() } } + public function testReadExtensionRetainsCompleteConfigurationAndReadPoolOptionsThroughReconnect(): void + { + $readPoolOptions = [ + 'min_connections' => 1, + 'max_connections' => 2, + 'connect_timeout' => 1.25, + 'heartbeat' => -1, + ]; + $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_connections' => 1, + 'max_connections' => 5, + 'connect_timeout' => 10.0, + 'heartbeat' => -1, + ], + ]; + 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 DbPool($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->getOption()->getMaxConnections()); + $this->assertSame(1.25, $pool->getOption()->getConnectTimeout()); + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $connection = $pooledConnection->getConnection(); + + $this->assertInstanceOf(NeutralPoolConnection::class, $connection); + $this->assertSame([$expected], $receivedConfigurations); + $this->assertSame($expected, $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', [ From 7bc8fef35666f9a059d0018a9a922535f2fbc0a9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:49 +0000 Subject: [PATCH 06/18] Support custom database command-line clients Add an extension-only database CLI manager and immutable launch configuration so custom drivers can use the existing db command without opening a database connection. Keep built-in command helpers and subclass overrides active, and launch through one common process path. Resolve selected role URLs before final host-list normalization, handle empty lists through the existing diagnostic, and preserve zero-valued credentials. Retain the nullable environment helper contract while supplying a normalized environment to the process. Test extension precedence, built-in helper results, role and URL selection, hostless extensions, and actual process arguments through isolated launch tests. Full framework composer fix passed; the final isolated launch rerun passed with 2 tests and 22 assertions. --- src/database/src/Console/DbCommand.php | 54 +++- src/database/src/DatabaseCliConfiguration.php | 21 ++ src/database/src/DatabaseCliManager.php | 40 +++ tests/Database/DatabaseCliManagerTest.php | 80 +++++ .../Database/DatabaseDbCommandLaunchTest.php | 145 +++++++++ tests/Database/DatabaseDbCommandTest.php | 290 +++++++++++++++++- 6 files changed, 603 insertions(+), 27 deletions(-) create mode 100644 src/database/src/DatabaseCliConfiguration.php create mode 100644 src/database/src/DatabaseCliManager.php create mode 100644 tests/Database/DatabaseCliManagerTest.php create mode 100644 tests/Database/DatabaseDbCommandLaunchTest.php 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/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/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; - } -} From 2450c1d9676619ffb54a5816ebeca0f6164764ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:49 +0000 Subject: [PATCH 07/18] Document database driver extension contracts Document protocol-neutral routing, streaming completion and retry boundaries, custom exception construction, migration and truncation schema hooks, and typed query and Blueprint extension contracts. Explain complete endpoint configuration and explicit read-alias ownership separately from pool-option projection, and show boot-time registration of custom command-line clients. State that streamed duration includes consumer work between yields and contributes to cumulative duration thresholds. Keep these additive capabilities in their canonical feature documentation. Checked the examples and contracts against the reviewed implementation; full framework checks passed. --- src/docs/database.md | 55 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/src/docs/database.md b/src/docs/database.md index 2259c4c55..3c6015109 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -14,6 +14,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) @@ -201,7 +202,7 @@ The `max_idle_time` option controls how long an idle connection may remain in th 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. @@ -329,12 +330,30 @@ 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. + +`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. + +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 @@ -717,7 +736,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 From 458a0f6819566ed3f5793e965343090f9e66a2ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:50 +0000 Subject: [PATCH 08/18] Keep Laravel porting guidance focused on compatibility Keep the porting guide focused on existing Laravel usage that requires adaptation or a compatibility check, rather than cataloguing opt-in framework additions. Remove the duplicated conditional-provider section; its canonical explanation remains in the provider documentation. Replace repeated Redis tag-mode details with the storage-compatibility warning and a link to the cache documentation. Document the concrete return contract required by custom query builder factory overrides and link to the database extension guide. Verified that the canonical provider section and Redis tag-mode anchor remain present. --- src/docs/porting-from-laravel.md | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 01a894f96..6be49b29b 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) @@ -308,23 +307,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 @@ -564,6 +546,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). @@ -584,7 +568,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. From c25abfa856a338eeaf0f8f1e0d2888ca262c940d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 04:04:50 +0000 Subject: [PATCH 09/18] Require Laravel-style configuration section headings Record the owner-approved requirement to group configuration settings under Laravel-style section comment blocks with concise user-facing explanations. Place the rule alongside the existing configuration conventions so newly written and ported configuration files follow the same familiar structure. This instruction-only change is separate from framework implementation and porting-guide updates. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 4c624d7bc..04ee68ac2 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. From d0d6a3a595ce7592123bb64554e529ff0a6ba923 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:06:30 +0000 Subject: [PATCH 10/18] Preserve closed-stream termination in database execution Add StreamClosedException for drivers whose active response stream is explicitly closed while iteration is suspended. Pass it through both runStreaming exception boundaries unchanged, keeping deliberate termination distinct from a failed query and from ordinary exhaustion. Do not increment the connection error count, retry the query, emit query events, or record successful execution for this signal. Leave buffered execution and PDO cursor behavior unchanged. Verify exception identity and resource cleanup before and after the first yielded value, with no query log or duration accounting. Document the additive driver contract alongside the streaming extension API. --- src/database/src/Connection.php | 5 +- src/database/src/StreamClosedException.php | 11 +++++ src/docs/database.md | 2 + tests/Database/DatabaseConnectionTest.php | 53 ++++++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/database/src/StreamClosedException.php diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index c40386711..9348fbc7f 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -606,6 +606,7 @@ protected function run(string $query, array $bindings, Closure $callback): mixed * * @throws CanceledException * @throws QueryException + * @throws StreamClosedException */ protected function runStreaming(string $query, array $bindings, Closure $callback): Generator { @@ -631,7 +632,7 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac $this->latestReadWriteTypeRetrieved = $readWriteType; } } - } catch (CanceledException $exception) { + } catch (CanceledException|StreamClosedException $exception) { throw $exception; } catch (Exception $exception) { ++$this->errorCount; @@ -650,7 +651,7 @@ protected function runStreaming(string $query, array $bindings, Closure $callbac yield from $this->handleQueryException($exception, $query, $bindings, $execute); } - } catch (CanceledException $exception) { + } catch (CanceledException|StreamClosedException $exception) { throw $exception; } catch (Throwable $exception) { $events = $this->events; 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 @@ +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 { From 7ead2701b8c793db3f0c79445880a33e73a614d9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:28:59 +0000 Subject: [PATCH 11/18] Generalize query embedding validation Rename the existing protected timeout-specific guard to ensureCanEmbedQuery so query builder extensions can validate statement-level options at the established attachment boundaries. Keep the timeout check, exception message, and all four call sites unchanged in behavior. Validation remains attachment-time only, with no recursive query traversal or additional compilation work. Document the extension contract and parent-call requirement. Add focused tests for override dispatch, rejection before outer-query mutation, and retained-child behavior across parsed, scalar, exists, and union subqueries. Validation: 440 query builder tests with 1706 assertions; full source and fixture static analysis; focused formatting checks. --- src/database/src/Query/Builder.php | 12 +- src/docs/database.md | 2 + .../DatabaseQueryBuilderEmbeddingTest.php | 107 ++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 tests/Database/DatabaseQueryBuilderEmbeddingTest.php diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 4dccaf7d4..ab91ae7f9 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -380,7 +380,7 @@ protected function parseSub(mixed $query): array } if ($query instanceof self) { - $this->ensureNoTimeoutOnEmbeddedQuery($query); + $this->ensureCanEmbedQuery($query); $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); @@ -1735,7 +1735,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', @@ -1816,7 +1816,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'; @@ -2719,7 +2719,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'); @@ -4186,11 +4186,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( diff --git a/src/docs/database.md b/src/docs/database.md index 9fe14cf71..8b0620975 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -348,6 +348,8 @@ If an active stream is explicitly closed, its next advancement must throw `Hyper 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. 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; + } + } +} From f8d273c7f8cf87cc8be08b28e8ff1ad237aebbec Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 14:54:32 +0000 Subject: [PATCH 12/18] Preserve query predicate operands and iterable bounds Accept mixed having operands consistently with ordinary where predicates, retaining the existing operator resolution and driver binding preparation. Exclude inline expressions from value-between bindings and normalize array operands through the existing scalar-value hook so one placeholder receives one binding. Preserve expression and driver-owned objects through day and month predicates instead of coercing them to the integer one. Materialize iterable range bounds once after DatePeriod resolution so generators and keyed collections can be compiled repeatedly without retaining an exhausted or non-indexable source. Add focused regressions for overloads, nested predicates, expression binding counts, scalar coercion, object identity, date formatting, iterable consumption, and real SQLite execution. Verified the 461-test query-builder corpus, full production and fixture static analysis, formatting, and whitespace checks. --- src/database/src/Query/Builder.php | 23 +- .../DatabaseQueryBuilderBindingTest.php | 200 ++++++++++++++++++ 2 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 tests/Database/DatabaseQueryBuilderBindingTest.php diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index ab91ae7f9..c4f536b62 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -1312,6 +1312,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'); @@ -1402,10 +1404,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; } @@ -1554,7 +1559,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); } @@ -1599,7 +1605,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); } @@ -2209,8 +2216,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'; @@ -2261,8 +2268,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, @@ -2348,6 +2355,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'); 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); + } +} From d14fdc045e7a07547f2dbc1a4f8c1d96f2bee22c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:15:57 +0000 Subject: [PATCH 13/18] Preserve nested join parents and value operands Retain the root query builder class when constructing a join inside another join. Grouped ON predicates and closure subqueries can then reconstruct a parent using the builder constructor, while preserving the immediate connection, grammar, processor, and existing factory methods. Accept mixed value operands in joinWhere, leftJoinWhere, rightJoinWhere, and straightJoinWhere, matching their existing delegation to value-based predicates. Keep ordinary column-comparison join signatures and all argument names and defaults unchanged. Add focused regressions for nested grouped conditions, closure subqueries, root subclass and dependency preservation, exact binding order, and boolean, integer, null, and expression operands across the four helpers. The full focused query-builder corpus, source and type-fixture analysis, and formatting checks pass. --- src/database/src/Query/Builder.php | 8 +- src/database/src/Query/JoinClause.php | 3 +- .../Database/DatabaseQueryBuilderJoinTest.php | 139 ++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/Database/DatabaseQueryBuilderJoinTest.php diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index c4f536b62..2f65cc39b 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -572,7 +572,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); } @@ -634,7 +634,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'); } @@ -660,7 +660,7 @@ public function rightJoin(ExpressionContract|string $table, Closure|string $firs /** * 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'); } @@ -716,7 +716,7 @@ public function straightJoin(ExpressionContract|string $table, Closure|string $f /** * 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'); } diff --git a/src/database/src/Query/JoinClause.php b/src/database/src/Query/JoinClause.php index 7e703bab0..32869d29e 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/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); + } +} From b9a0ea2041be9403c93ef6dcfe1b620cc7770aa5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:12:22 +0000 Subject: [PATCH 14/18] Preserve single-row intent when inserting non-incrementing models Pass model attributes as an explicit one-row batch through the existing Eloquent insert dispatch. Drivers that retain array-valued attributes must not have those values mistaken for separate rows or their inner keys compiled as column names. Keep query-builder row and batch interpretation unchanged, including named and sparse batches. Preserve the incrementing insert-and-ID path, empty-attribute handling, binary preparation, unique IDs, timestamps, and model events without introducing driver detection or model-context state. Add real builder and grammar regression coverage for array-first, associative-array-only, empty-array, and scalar attributes, with exact SQL and binding assertions and unchanged model state. Retain the existing lifecycle and custom builder dispatch assertions, and verify empty non-incrementing models do not issue an insert. Verification: the complete model test file, formatting, source and type-fixture static analysis, full parallel framework tests, Testbench contract tests, package-mode tests, and diff checks pass. Peer review signed off both whole files. --- src/database/src/Eloquent/Model.php | 3 +- tests/Database/DatabaseEloquentModelTest.php | 50 +++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index dd0a85197..6553285d6 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1657,7 +1657,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 diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 9f8721dd9..abbaa592f 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -1100,7 +1100,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 +1119,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(); From 95e5afb27d5728370e5151390b261e2d592aa627 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:12:02 +0000 Subject: [PATCH 15/18] Fix derived pagination count routing and bindings Preserve the inner query's forced-write route on the executed count statement. Transfer it after SQL compilation so before-query callbacks that select the writer remain effective, without changing the original query or connection routing policy. Attach the compiled subquery and its complete binding list through fromRaw. These bindings belong to the outer FROM clause; retaining their original clause slots allowed aggregation to discard bindings for ordering preserved by a driver's pagination clone. Keep the existing unprefixed aggregate_table alias, timeout transfer, ordinary count branch, and public mergeBindings API unchanged. Add split in-memory SQLite routing regressions and a neutral ordering-preserving builder fixture. Cover callback routing and binding changes, exact binding order and ownership, table prefixes, and original-builder state. Verified the database suite, source and type-fixture analysis, and formatting. --- src/database/src/Query/Builder.php | 9 +- .../DatabaseQueryBuilderPaginationTest.php | 116 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 tests/Database/DatabaseQueryBuilderPaginationTest.php diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 27550a632..8e34a4251 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3171,9 +3171,14 @@ protected function runPaginationCountQuery(array $columns = ['*']): array $clone->select($this->from . '.*'); } + $sql = $clone->toSql(); + + // Compilation runs before-query callbacks, which may force the write connection. + $countQuery->useWritePdo = $clone->useWritePdo; + + // 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(); } diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php new file mode 100644 index 000000000..2ff3b74c6 --- /dev/null +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -0,0 +1,116 @@ +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]; + } + + #[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']); + } +} From 98cad5e3db4418bb8ad288f6a55c215b3908ae73 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:24:07 +0000 Subject: [PATCH 16/18] Prepare pagination counts after before-query callbacks Apply pending before-query callbacks on a local clone at the public pagination-count boundary, before choosing the count shape, pruning page clauses, or transferring statement options. Previously callback-added grouping could return the first group's count, callback projections could leave excess bindings, and callback timeouts could decorate only an inner query. Keep preparation inside the existing fetch-mode scope so callback-supplied fetch modes cannot change the count result shape. Preserve the original page builder and its callbacks, run callbacks once per count, and avoid an extra clone when none are pending. Transfer the derived count's writer route alongside its timeout now that callbacks have already completed, removing the obsolete compilation-order comment. Add focused SQLite and grammar regressions for grouping, projection and pagination pruning, statement timeout and routing, callback ownership, exception identity, and fetch-scope restoration. Existing database, one-of-many, and query integration coverage remains unchanged. Verified formatting, static analysis, and the affected suites. --- src/database/src/Query/Builder.php | 18 ++-- .../DatabaseQueryBuilderPaginationTest.php | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 8e34a4251..64f24e134 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3134,9 +3134,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 @@ -3165,6 +3173,7 @@ 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)) { @@ -3173,9 +3182,6 @@ protected function runPaginationCountQuery(array $columns = ['*']): array $sql = $clone->toSql(); - // Compilation runs before-query callbacks, which may force the write connection. - $countQuery->useWritePdo = $clone->useWritePdo; - // Inner bindings belong to the derived table, not outer clauses that aggregation may clear. return $countQuery ->fromRaw('(' . $sql . ') as ' . $this->grammar->wrap('aggregate_table'), $clone->getBindings()) diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php index 2ff3b74c6..cc20129d4 100644 --- a/tests/Database/DatabaseQueryBuilderPaginationTest.php +++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php @@ -7,12 +7,14 @@ use Hypervel\Database\Connection; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Grammars\Grammar; +use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\SQLiteConnection; use Hypervel\Tests\TestCase; use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class DatabaseQueryBuilderPaginationTest extends TestCase { @@ -52,6 +54,103 @@ public static function readWriteRouting(): iterable 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 { From 190688fe2f0645518838c4a746647d9de0a482cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:02:34 +0000 Subject: [PATCH 17/18] fix(testbench): retain class cleanup when setup aborts Register the existing in-memory migration-state cleanup feature as soon as the migration concern initializes. Deferring registration until after application setup allowed a later service skip or setup exception to bypass registration entirely. DatabaseTruncation intentionally retains its migrated PDO between test methods. Without the class cleanup registration, an aborted setup could therefore leave a previous class schema available to an unrelated test class using the same connection name. Keep the existing in-memory detection, per-method retention, and file-backed behavior unchanged. No new lifecycle hooks, state flags, or unconditional database resets are introduced. Add regression coverage for both skipped and failed setup, proving that per-method teardown retains the database but class teardown releases the migration state. Both cases fail before the fix and pass afterward. Reproduce the original interaction on the unmodified baseline and verify the correction through the full framework and Testbench checks and peer review. --- .../src/Concerns/InteractsWithMigrations.php | 5 +- .../DatabaseTruncationSetupFailureTest.php | 109 ++++++++++++++++++ 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 tests/Testbench/Databases/DatabaseTruncationSetupFailureTest.php 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/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.'); + } +} From 9a8419c7c40ff02c2601ddbb3d6e792132180356 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:09:02 +0000 Subject: [PATCH 18/18] Preserve single-row attributes when saving models with conflict handling Pass the model attributes as an explicit row list to insertOrIgnoreReturning. The query builder otherwise treats an array-valued first attribute as a batch, causing mixed attributes to fail and all-array attributes to compile as different rows. Keep conflict targets, returning columns, binary preparation, model events, and key assignment unchanged. The correction applies to both incrementing and non-incrementing models without changing the public query-builder insert API. Update the existing call expectations and add regression coverage through the real model and query builders for both key modes, all-array attributes including an empty value, and ordinary scalar attributes. Verify SQL, bindings, conflict targets, returned keys, and model lifecycle flags. Model/query tests, SQLite integration tests, formatting, and source and type analysis pass. --- src/database/src/Eloquent/Model.php | 3 +- tests/Database/DatabaseEloquentModelTest.php | 41 ++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 2e50b0b77..2607b8a4d 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1701,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/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index c0c00e8cf..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; @@ -1191,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() @@ -1224,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() @@ -1254,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() @@ -1288,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() @@ -1308,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);