From 755211c7c1cfa8e87fdc7cd5b56d02d5efb933a3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:56:45 +0000 Subject: [PATCH 01/18] feat(context): support non-copyable values Introduce a marker contract for coroutine-owned resources that must not cross context-copy boundaries. Apply replication and omission atomically across coroutine and non-coroutine copies, preserving destination values when a source entry is omitted. Cover full and selective copies, precedence over replication, null values, and failure atomicity. --- src/context/src/CoroutineContext.php | 50 +++++---- src/context/src/NonCopyableContext.php | 9 ++ tests/Context/ContextCoroutineTest.php | 80 ++++++++++++-- tests/Context/ContextTest.php | 141 ++++++++++++++++++++++++- tests/Coroutine/ParallelTest.php | 20 ++++ tests/Coroutine/WaiterTest.php | 21 ++++ 6 files changed, 294 insertions(+), 27 deletions(-) create mode 100644 src/context/src/NonCopyableContext.php diff --git a/src/context/src/CoroutineContext.php b/src/context/src/CoroutineContext.php index 876856676f..dc7199a15d 100644 --- a/src/context/src/CoroutineContext.php +++ b/src/context/src/CoroutineContext.php @@ -117,7 +117,8 @@ public static function copyFrom(int $fromCoroutineId, array $keys = []): void /** * Capture context values as an array. * - * Replicable values are copied in the calling coroutine at capture time. + * Replicable values are copied and non-copyable values are omitted in the + * calling coroutine at capture time. * * @return array */ @@ -133,13 +134,7 @@ public static function captureFrom(array $keys = [], ?int $fromCoroutineId = nul ? array_intersect_key($from->getArrayCopy(), array_flip($keys)) : $from->getArrayCopy(); - foreach ($map as $key => $value) { - if ($value instanceof ReplicableContext) { - $map[$key] = $value->replicate(); - } - } - - return $map; + return self::prepareForCopy($map); } /** @@ -225,10 +220,9 @@ public static function copyFromNonCoroutine(array $keys = [], ?int $coroutineId $map = static::$nonCoroutineContext; } + $map = self::prepareForCopy($map); + foreach ($map as $key => $value) { - if ($value instanceof ReplicableContext) { - $value = $value->replicate(); - } $context[$key] = $value; } } @@ -245,17 +239,35 @@ public static function copyToNonCoroutine(array $keys = [], ?int $coroutineId = return; } - if ($keys) { - foreach ($keys as $key) { - if (isset($context[$key])) { - static::$nonCoroutineContext[$key] = $context[$key]; - } + $map = $keys + ? array_intersect_key($context->getArrayCopy(), array_flip($keys)) + : $context->getArrayCopy(); + + $map = self::prepareForCopy($map); + + foreach ($map as $key => $value) { + static::$nonCoroutineContext[(string) $key] = $value; + } + } + + /** + * Prepare context values for copying. + */ + private static function prepareForCopy(array $values): array + { + foreach ($values as $key => $value) { + if ($value instanceof NonCopyableContext) { + unset($values[$key]); + + continue; } - } else { - foreach ($context as $key => $value) { - static::$nonCoroutineContext[$key] = $value; + + if ($value instanceof ReplicableContext) { + $values[$key] = $value->replicate(); } } + + return $values; } /** diff --git a/src/context/src/NonCopyableContext.php b/src/context/src/NonCopyableContext.php new file mode 100644 index 0000000000..eb2a1be942 --- /dev/null +++ b/src/context/src/NonCopyableContext.php @@ -0,0 +1,9 @@ +assertSame( + ['capture.copyable' => 'value'], + CoroutineContext::captureFrom(), + ); + $this->assertSame( + [], + CoroutineContext::captureFrom(['capture.non-copyable']), + ); + } + + public function testNonCopyableMarkerTakesPrecedenceOverReplication(): void + { + $value = new class implements NonCopyableContext, ReplicableContext { + public bool $replicated = false; + + public function replicate(): static + { + $this->replicated = true; + + return clone $this; + } + }; + + CoroutineContext::set('capture.both-markers', $value); + + $this->assertSame([], CoroutineContext::captureFrom()); + $this->assertFalse($value->replicated); + } + public function testCaptureFromExplicitCoroutine(): void { $sourceReady = new Channel(1); @@ -73,7 +110,7 @@ public function testCaptureFromExplicitCoroutine(): void } } - public function testCopy() + public function testCopy(): void { CoroutineContext::set('test.store.id', $uid = uniqid()); $id = Coroutine::id(); @@ -85,7 +122,7 @@ function () use ($id, $uid) { ]); } - public function testCopyAfterSet() + public function testCopyAfterSet(): void { CoroutineContext::set('test.store.id', $uid = uniqid()); $id = Coroutine::id(); @@ -101,7 +138,7 @@ function () use ($id, $uid) { ]); } - public function testContextChangeAfterCopy() + public function testContextChangeAfterCopy(): void { $obj = new stdClass; $obj->id = $uid = uniqid(); @@ -123,7 +160,7 @@ function () use ($id, $uid, $tid) { $this->assertSame($tid, CoroutineContext::get('test.store.id')->id); } - public function testContextFromNull() + public function testContextFromNull(): void { $res = CoroutineContext::get('id', $default = 'Hello World!', -1); $this->assertSame($default, $res); @@ -144,7 +181,7 @@ function () { ]); } - public function testRequestContextWithCoroutineId() + public function testRequestContextWithCoroutineId(): void { $request = m::mock(Request::class); RequestContext::set($request); @@ -154,7 +191,7 @@ public function testRequestContextWithCoroutineId() }); } - public function testContextOverrideWithCoroutineId() + public function testContextOverrideWithCoroutineId(): void { $id = Coroutine::id(); $value = uniqid(); @@ -173,7 +210,7 @@ function ($v) use ($value) { $this->assertSame('123', CoroutineContext::get('override.id.coroutine_id')); } - public function testContextGetOrSetWithCoroutineId() + public function testContextGetOrSetWithCoroutineId(): void { $id = Coroutine::id(); $value = uniqid(); @@ -212,4 +249,33 @@ public function testFailedReplicationDoesNotPartiallyModifyTheDestination(): voi $this->assertSame('value', CoroutineContext::get('untouched')); $this->assertFalse(CoroutineContext::has('throwing')); } + + public function testCopyFromPreservesDestinationValueForOmittedSourceKey(): void + { + $sourceReady = new Channel(1); + $releaseSource = new Channel(1); + + Coroutine::create(static function () use ($sourceReady, $releaseSource): void { + CoroutineContext::set('copyable', 'source'); + CoroutineContext::set('owned-resource', new class implements NonCopyableContext { + }); + $sourceReady->push(Coroutine::id()); + $releaseSource->pop(); + }); + + $destinationResource = new stdClass; + CoroutineContext::set('copyable', 'destination'); + CoroutineContext::set('owned-resource', $destinationResource); + CoroutineContext::set('untouched', 'value'); + + try { + CoroutineContext::copyFrom($sourceReady->pop(1.0)); + } finally { + $releaseSource->push(true); + } + + $this->assertSame('source', CoroutineContext::get('copyable')); + $this->assertSame($destinationResource, CoroutineContext::get('owned-resource')); + $this->assertSame('value', CoroutineContext::get('untouched')); + } } diff --git a/tests/Context/ContextTest.php b/tests/Context/ContextTest.php index 0e648c8bb5..0eba2798d2 100644 --- a/tests/Context/ContextTest.php +++ b/tests/Context/ContextTest.php @@ -6,13 +6,18 @@ use ArrayObject; use Hypervel\Context\CoroutineContext; +use Hypervel\Context\NonCopyableContext; +use Hypervel\Context\ReplicableContext; use Hypervel\Context\RequestContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Coroutine as EngineCoroutine; use Hypervel\Engine\Exceptions\CoroutineDestroyedException; use Hypervel\Http\Request; +use Hypervel\Tests\Context\Fixtures\ThrowingReplicableContext; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; +use stdClass; use Swoole\Event; use function Hypervel\Coroutine\run; @@ -33,7 +38,7 @@ public function testSetMany(): void foreach ($values as $key => $expectedValue) { $this->assertTrue(CoroutineContext::has($key)); - $this->assertEquals($expectedValue, CoroutineContext::get($key)); + $this->assertSame($expectedValue, CoroutineContext::get($key)); } } @@ -116,6 +121,140 @@ public function testCopyFromNonCoroutineWithSelectiveKeysPreservesExisting(): vo ], $copied); } + public function testCopyFromNonCoroutineOmitsNonCopyableValuesForAllAndSelectedCopies(): void + { + CoroutineContext::set('copyable', 'source'); + CoroutineContext::set('owned-resource', new class implements NonCopyableContext { + }); + $destinationResource = new stdClass; + $results = []; + + run(static function () use ($destinationResource, &$results): void { + Coroutine::create(static function () use ($destinationResource, &$results): void { + CoroutineContext::set('owned-resource', $destinationResource); + CoroutineContext::set('untouched', 'value'); + CoroutineContext::copyFromNonCoroutine(); + + $results['all'] = [ + CoroutineContext::get('copyable'), + CoroutineContext::get('owned-resource'), + CoroutineContext::get('untouched'), + ]; + }); + + Coroutine::create(static function () use ($destinationResource, &$results): void { + CoroutineContext::set('owned-resource', $destinationResource); + CoroutineContext::copyFromNonCoroutine(['owned-resource']); + + $results['selected'] = CoroutineContext::get('owned-resource'); + }); + }); + + $this->assertSame(['source', $destinationResource, 'value'], $results['all']); + $this->assertSame($destinationResource, $results['selected']); + } + + public function testFailedCopyFromNonCoroutineDoesNotPartiallyModifyTheDestination(): void + { + CoroutineContext::set('stable', 'source'); + CoroutineContext::set('throwing', new ThrowingReplicableContext); + $results = []; + + run(static function () use (&$results): void { + Coroutine::create(static function () use (&$results): void { + CoroutineContext::set('stable', 'destination'); + CoroutineContext::set('untouched', 'value'); + + try { + CoroutineContext::copyFromNonCoroutine(); + } catch (RuntimeException $exception) { + $results['exception'] = $exception; + } + + $results['stable'] = CoroutineContext::get('stable'); + $results['untouched'] = CoroutineContext::get('untouched'); + $results['throwing'] = CoroutineContext::has('throwing'); + }); + }); + + $this->assertSame('Unable to replicate context.', $results['exception']->getMessage()); + $this->assertSame('destination', $results['stable']); + $this->assertSame('value', $results['untouched']); + $this->assertFalse($results['throwing']); + } + + public function testSelectedNullIsCopiedFromNonCoroutineContext(): void + { + CoroutineContext::set('nullable', null); + $containsKey = false; + + run(static function () use (&$containsKey): void { + Coroutine::create(static function () use (&$containsKey): void { + CoroutineContext::copyFromNonCoroutine(['nullable']); + $containsKey = array_key_exists('nullable', CoroutineContext::getContainer()->getArrayCopy()); + }); + }); + + $this->assertTrue($containsKey); + } + + public function testCopyToNonCoroutineTransformsAllAndSelectedValues(): void + { + $destinationResource = new stdClass; + $replicable = new class implements ReplicableContext { + public string $value = 'replicated'; + + public function replicate(): static + { + return clone $this; + } + }; + + CoroutineContext::set('owned-resource', $destinationResource); + + run(static function () use ($replicable): void { + CoroutineContext::set('copyable', 'source'); + CoroutineContext::set('owned-resource', new class implements NonCopyableContext { + }); + CoroutineContext::set('replicable', $replicable); + CoroutineContext::set('nullable', null); + CoroutineContext::copyToNonCoroutine(); + CoroutineContext::copyToNonCoroutine(['owned-resource', 'nullable']); + }); + + $container = CoroutineContext::getContainer(); + + $this->assertSame('source', $container['copyable']); + $this->assertSame($destinationResource, $container['owned-resource']); + $this->assertNotSame($replicable, $container['replicable']); + $this->assertSame('replicated', $container['replicable']->value); + $this->assertArrayHasKey('nullable', $container); + $this->assertNull($container['nullable']); + } + + public function testFailedCopyToNonCoroutineDoesNotPartiallyModifyTheDestination(): void + { + CoroutineContext::set('stable', 'destination'); + CoroutineContext::set('untouched', 'value'); + $exception = null; + + run(static function () use (&$exception): void { + CoroutineContext::set('stable', 'source'); + CoroutineContext::set('throwing', new ThrowingReplicableContext); + + try { + CoroutineContext::copyToNonCoroutine(); + } catch (RuntimeException $runtimeException) { + $exception = $runtimeException; + } + }); + + $this->assertSame('Unable to replicate context.', $exception?->getMessage()); + $this->assertSame('destination', CoroutineContext::get('stable')); + $this->assertSame('value', CoroutineContext::get('untouched')); + $this->assertFalse(CoroutineContext::has('throwing')); + } + public function testFlush(): void { CoroutineContext::set('key1', 'value1'); diff --git a/tests/Coroutine/ParallelTest.php b/tests/Coroutine/ParallelTest.php index 5c919d604c..f2f4e218b1 100644 --- a/tests/Coroutine/ParallelTest.php +++ b/tests/Coroutine/ParallelTest.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Context\CoroutineContext; +use Hypervel\Context\NonCopyableContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ParallelExecutionException; use Hypervel\Coroutine\Parallel; @@ -582,6 +583,25 @@ public function testCopyContextWorksWithConcurrencyLimit() } } + public function testCopiedContextOmitsNonCopyableValues(): void + { + $resource = new class implements NonCopyableContext { + }; + + CoroutineContext::set('resource', $resource); + CoroutineContext::set('request_id', 'abc'); + + $results = parallel([ + static fn (): array => [ + CoroutineContext::get('resource'), + CoroutineContext::get('request_id'), + ], + ], copyContext: true); + + $this->assertSame([[null, 'abc']], $results); + $this->assertSame($resource, CoroutineContext::get('resource')); + } + public function testParallelHelperPassesCopyContextThrough() { CoroutineContext::set('via_helper', 'value'); diff --git a/tests/Coroutine/WaiterTest.php b/tests/Coroutine/WaiterTest.php index cd34033417..067620dfae 100644 --- a/tests/Coroutine/WaiterTest.php +++ b/tests/Coroutine/WaiterTest.php @@ -6,6 +6,7 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; +use Hypervel\Context\NonCopyableContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\Exceptions\ChildTerminationTimeoutException; use Hypervel\Coroutine\Exceptions\WaitTimeoutException; @@ -98,6 +99,26 @@ public function testWaitCanCopySelectedContextKeys(): void $this->assertSame(['value_a', null], $result); } + public function testCopiedContextOmitsSelectedNonCopyableValues(): void + { + $resource = new class implements NonCopyableContext { + }; + + CoroutineContext::set('resource', $resource); + CoroutineContext::set('request_id', 'abc'); + + $result = wait( + static fn (): array => [ + CoroutineContext::get('resource'), + CoroutineContext::get('request_id'), + ], + copyContext: ['resource', 'request_id'], + ); + + $this->assertSame([null, 'abc'], $result); + $this->assertSame($resource, CoroutineContext::get('resource')); + } + public function testContextReplicationFailureIsReportedInsteadOfTimingOut(): void { CoroutineContext::set('throwing', new ThrowingReplicableContext); From 31473be3a7d3ef8cfbb7bc08c9456f8a77284b27 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:56:55 +0000 Subject: [PATCH 02/18] fix(redis): isolate copied connection ownership Mark borrowed Redis connections as non-copyable so child coroutines cannot inherit a parent's pinned pool checkout. Verify copied siblings acquire distinct connections, detached children borrow only after the parent releases, and constrained pools remain healthy after each owner completes. --- src/redis/src/RedisConnection.php | 3 +- .../Redis/RedisProxyIntegrationTest.php | 137 +++++++++++++++ tests/Redis/RedisProxyTest.php | 163 ++++++++++++++---- 3 files changed, 266 insertions(+), 37 deletions(-) diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index cfce7f09a7..63daffad91 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -6,6 +6,7 @@ use BadMethodCallException; use Generator; +use Hypervel\Context\NonCopyableContext; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Log\StdoutLoggerInterface; @@ -325,7 +326,7 @@ * @method false|int|Redis|RedisCluster zintercard(array $keys, int $limit = -1) * @method array|false|Redis|RedisCluster zunion(array $keys, array|null $weights = null, array|null $options = null) */ -abstract class RedisConnection extends BaseConnection +abstract class RedisConnection extends BaseConnection implements NonCopyableContext { use Macroable { __call as macroCall; diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index 92ebc07430..d0a22cb87b 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -4,6 +4,8 @@ namespace Hypervel\Tests\Integration\Redis; +use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Redis\Events\CommandExecuted; @@ -326,6 +328,141 @@ public function testRedisPipelineConcurrentExecs(): void $this->assertSame([['C', 'D'], true], $second->pop()); } + public function testCopiedSiblingContextsUseDistinctPinnedRedisConnections(): void + { + $connectionName = $this->createRedisConnectionWithOptions( + name: 'test_copied_sibling_connections', + options: ['prefix' => ''], + maxConnections: 3, + ); + $redis = Redis::connection($connectionName); + $redis->multi(); + $contextKey = RedisProxy::CONNECTION_CONTEXT_PREFIX . $connectionName; + $parentConnection = CoroutineContext::get($contextKey); + $childrenReady = new Channel(2); + $releaseChildren = new Channel(2); + + $childCoroutineIds = [ + go(static function () use ($redis, $contextKey, $childrenReady, $releaseChildren): void { + $redis->multi(); + $childrenReady->push(CoroutineContext::get($contextKey)); + $releaseChildren->pop(); + $redis->discard(); + }, copyContext: true), + go(static function () use ($redis, $contextKey, $childrenReady, $releaseChildren): void { + $redis->multi(); + $childrenReady->push(CoroutineContext::get($contextKey)); + $releaseChildren->pop(); + $redis->discard(); + }, copyContext: true), + ]; + + $firstChildConnection = $childrenReady->pop(1.0); + $secondChildConnection = $childrenReady->pop(1.0); + + try { + $this->assertInstanceOf(RedisConnection::class, $parentConnection); + $this->assertInstanceOf(RedisConnection::class, $firstChildConnection); + $this->assertInstanceOf(RedisConnection::class, $secondChildConnection); + $this->assertNotSame($parentConnection, $firstChildConnection); + $this->assertNotSame($parentConnection, $secondChildConnection); + $this->assertNotSame($firstChildConnection, $secondChildConnection); + } finally { + $releaseChildren->push(true); + $releaseChildren->push(true); + Coroutine::join($childCoroutineIds, 1.0); + $redis->discard(); + $redis->releaseContextConnection(); + } + + foreach ($childCoroutineIds as $childCoroutineId) { + $this->assertFalse(Coroutine::exists($childCoroutineId)); + } + + $this->assertTrue($redis->set('copied:siblings:after', 'healthy')); + $this->assertSame('healthy', $redis->get('copied:siblings:after')); + } + + public function testDetachedCopiedChildOwnsItsSingleSlotRedisCheckout(): void + { + $connectionName = $this->createRedisConnectionWithOptions( + name: 'test_detached_copied_child', + options: ['prefix' => ''], + maxConnections: 1, + ); + $redis = Redis::connection($connectionName); + $redis->set('copied:detached:value', 'available'); + $allowChildCheckout = new Channel(1); + $childBorrowed = new Channel(1); + $releaseChild = new Channel(1); + $childCoroutineId = new Channel(1); + + $parentCoroutineId = go(static function () use ( + $redis, + $allowChildCheckout, + $childBorrowed, + $releaseChild, + $childCoroutineId, + ): void { + $redis->multi(); + $childCoroutineId->push(go(static function () use ( + $redis, + $allowChildCheckout, + $childBorrowed, + $releaseChild, + ): void { + $allowChildCheckout->pop(); + $redis->multi(); + $childBorrowed->push(true); + $releaseChild->pop(); + $redis->discard(); + }, copyContext: true)); + }); + + $contenderFinished = new Channel(1); + $detachedChildCoroutineId = null; + $contenderCoroutineId = null; + $parentStillRunning = true; + $contenderResultWhileChildHeld = null; + + try { + $detachedChildCoroutineId = $childCoroutineId->pop(1.0); + $this->assertIsInt($detachedChildCoroutineId); + Coroutine::join([$parentCoroutineId], 1.0); + $parentStillRunning = Coroutine::exists($parentCoroutineId); + + $allowChildCheckout->push(true); + $this->assertTrue($childBorrowed->pop(1.0)); + + $contenderCoroutineId = go(static function () use ($redis, $contenderFinished): void { + $contenderFinished->push($redis->get('copied:detached:value')); + }); + + $contenderResultWhileChildHeld = $contenderFinished->pop(0.05); + $releaseChild->push(true); + + if ($contenderResultWhileChildHeld === false) { + $this->assertSame('available', $contenderFinished->pop(1.0)); + } + } finally { + $allowChildCheckout->push(true, 0.01); + $releaseChild->push(true, 0.01); + + Coroutine::join(array_values(array_filter([ + $parentCoroutineId, + $detachedChildCoroutineId, + $contenderCoroutineId, + ], is_int(...))), 1.0); + } + + $this->assertFalse($parentStillRunning); + $this->assertIsInt($detachedChildCoroutineId); + $this->assertIsInt($contenderCoroutineId); + $this->assertFalse(Coroutine::exists($detachedChildCoroutineId)); + $this->assertFalse(Coroutine::exists($contenderCoroutineId)); + $this->assertFalse($contenderResultWhileChildHeld); + } + public function testPipelineCallbackAndSelect(): void { $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index c800ed7bbf..806e27d8f5 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -292,33 +292,124 @@ public function testExistingTerminalReleaseOwnsALaterRawPin(): void $this->assertSame(0, $redis->contextAbsentReleaseCalls); } - public function testCopiedContextRegistersAChildOwnedTerminalRelease(): void + public function testCopiedSiblingContextsBorrowDistinctPinnedConnectionsAndOwnTheirReleases(): void { - $transaction = m::mock(PhpRedis::class); - $transaction->expects('exec')->andReturn([]); - $rawTransaction = m::mock(PhpRedis::class); + $parentTransaction = m::mock(PhpRedis::class); + $firstChildTransaction = m::mock(PhpRedis::class); + $secondChildTransaction = m::mock(PhpRedis::class); $parentConnection = $this->mockConnection(); - $parentConnection->expects('multi')->andReturn($transaction); + $parentConnection->expects('multi')->andReturn($parentTransaction); $parentConnection->expects('release'); + $firstChildConnection = $this->mockConnection(); + $firstChildConnection->expects('multi')->andReturn($firstChildTransaction); + $firstChildConnection->expects('release'); + $secondChildConnection = $this->mockConnection(); + $secondChildConnection->expects('multi')->andReturn($secondChildTransaction); + $secondChildConnection->expects('release'); + + $redis = $this->createCountingRedis( + $parentConnection, + $firstChildConnection, + $secondChildConnection, + ); + $redis->multi(); + + $childrenReady = new Channel(2); + $releaseChildren = new Channel(2); + $childCoroutineIds = [ + go(static function () use ($redis, $childrenReady, $releaseChildren): void { + $redis->multi(); + $childrenReady->push(CoroutineContext::get( + RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', + )); + $releaseChildren->pop(); + }, copyContext: true), + go(static function () use ($redis, $childrenReady, $releaseChildren): void { + $redis->multi(); + $childrenReady->push(CoroutineContext::get( + RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', + )); + $releaseChildren->pop(); + }, copyContext: true), + ]; + + try { + $firstBorrowedConnection = $childrenReady->pop(1.0); + $secondBorrowedConnection = $childrenReady->pop(1.0); + + $this->assertSame($parentConnection, CoroutineContext::get( + RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', + )); + $this->assertNotSame($parentConnection, $firstBorrowedConnection); + $this->assertNotSame($parentConnection, $secondBorrowedConnection); + $this->assertNotSame($firstBorrowedConnection, $secondBorrowedConnection); + } finally { + $releaseChildren->push(true); + $releaseChildren->push(true); + Coroutine::join($childCoroutineIds, 1.0); + } + + foreach ($childCoroutineIds as $childCoroutineId) { + $this->assertFalse(Coroutine::exists($childCoroutineId)); + } + } + + public function testDetachedCopiedChildBorrowsAfterItsParentReleases(): void + { + $parentTransaction = m::mock(PhpRedis::class); + $childTransaction = m::mock(PhpRedis::class); + $parentReleased = new Channel(1); + $childReleased = new Channel(1); + + $parentConnection = $this->mockConnection(); + $parentConnection->expects('multi')->andReturn($parentTransaction); + $parentConnection->expects('release')->andReturnUsing(static function () use ($parentReleased): void { + $parentReleased->push(true); + }); $childConnection = $this->mockConnection(); - $childConnection->expects('multi')->andReturn($rawTransaction); - $childConnection->expects('release'); + $childConnection->expects('multi')->andReturn($childTransaction); + $childConnection->expects('release')->andReturnUsing(static function () use ($childReleased): void { + $childReleased->push(true); + }); $redis = $this->createCountingRedis($parentConnection, $childConnection); - $redis->transaction(static function (): void { + $allowChild = new Channel(1); + $childBorrowed = new Channel(1); + $childCoroutineId = new Channel(1); + + $parentCoroutineId = go(static function () use ($redis, $allowChild, $childBorrowed, $childCoroutineId): void { + $redis->multi(); + $childCoroutineId->push(go(static function () use ($redis, $allowChild, $childBorrowed): void { + $allowChild->pop(); + $redis->multi(); + $childBorrowed->push(CoroutineContext::get( + RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', + )); + }, copyContext: true)); }); - $completed = new Channel(1); - go(static function () use ($redis, $completed): void { - Coroutine::defer(static function () use ($completed): void { - $completed->push(true); - }); + $detachedChildCoroutineId = null; - $redis->multi(); - }, copyContext: true); + try { + $detachedChildCoroutineId = $childCoroutineId->pop(1.0); + $this->assertIsInt($detachedChildCoroutineId); + $this->assertTrue($parentReleased->pop(1.0)); - $this->assertTrue($completed->pop(1.0)); + $allowChild->push(true); + + $this->assertSame($childConnection, $childBorrowed->pop(1.0)); + $this->assertTrue($childReleased->pop(1.0)); + } finally { + $allowChild->push(true, 0.01); + Coroutine::join(array_values(array_filter([ + $parentCoroutineId, + $detachedChildCoroutineId, + ], is_int(...))), 1.0); + } + + $this->assertIsInt($detachedChildCoroutineId); + $this->assertFalse(Coroutine::exists($detachedChildCoroutineId)); } public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void @@ -467,8 +558,8 @@ public function testExceptionWithContextConnectionDoesNotReleaseConnection(): vo try { $redis->get('key'); $this->fail('Expected exception was not thrown'); - } catch (Exception $e) { - $this->assertEquals('Redis error', $e->getMessage()); + } catch (Exception $exception) { + $this->assertSame('Redis error', $exception->getMessage()); } } @@ -485,8 +576,8 @@ public function testExceptionWithSameConnectionCommandReleasesConnectionInsteadO try { $redis->multi(); $this->fail('Expected exception was not thrown'); - } catch (Exception $e) { - $this->assertEquals('Multi failed', $e->getMessage()); + } catch (Exception $exception) { + $this->assertSame('Multi failed', $exception->getMessage()); } // Connection should NOT be stored in context on error @@ -878,8 +969,8 @@ public function testWithConnectionExecutesCallbackAndReleasesConnection(): void $redis = $this->createRedis($connection); - $result = $redis->withConnection(function (RedisConnection $conn) use ($connection) { - $this->assertSame($connection, $conn); + $result = $redis->withConnection(function (RedisConnection $redisConnection) use ($connection) { + $this->assertSame($connection, $redisConnection); return 'callback-result'; }); @@ -898,8 +989,8 @@ public function testWithConnectionReusesExistingContextConnection(): void $redis = $this->createRedis($connection); - $result = $redis->withConnection(function (RedisConnection $conn) use ($connection) { - $this->assertSame($connection, $conn); + $result = $redis->withConnection(function (RedisConnection $redisConnection) use ($connection) { + $this->assertSame($connection, $redisConnection); return 'reused-connection'; }); @@ -920,7 +1011,7 @@ public function testWithConnectionReleasesOnException(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Callback failed'); - $redis->withConnection(function (RedisConnection $conn) { + $redis->withConnection(function (RedisConnection $redisConnection) { throw new RuntimeException('Callback failed'); }); } @@ -936,12 +1027,12 @@ public function testWithConnectionDoesNotReleaseContextConnectionOnException(): $redis = $this->createRedis($connection); try { - $redis->withConnection(function (RedisConnection $conn) { + $redis->withConnection(function (RedisConnection $redisConnection) { throw new RuntimeException('Callback failed'); }); $this->fail('Expected exception was not thrown'); - } catch (RuntimeException $e) { - $this->assertSame('Callback failed', $e->getMessage()); + } catch (RuntimeException $exception) { + $this->assertSame('Callback failed', $exception->getMessage()); } // Connection should still be in context @@ -961,7 +1052,7 @@ public function testWithConnectionDefaultsToTransformTrue(): void $redis = $this->createRedis($connection); - $redis->withConnection(function (RedisConnection $conn) { + $redis->withConnection(function (RedisConnection $redisConnection) { return 'result'; }); } @@ -979,7 +1070,7 @@ public function testWithConnectionRespectsTransformFalse(): void $redis = $this->createRedis($connection); - $redis->withConnection(function (RedisConnection $conn) { + $redis->withConnection(function (RedisConnection $redisConnection) { return 'result'; }, transform: false); } @@ -997,7 +1088,7 @@ public function testWithConnectionRespectsTransformTrueExplicit(): void $redis = $this->createRedis($connection); - $redis->withConnection(function (RedisConnection $conn) { + $redis->withConnection(function (RedisConnection $redisConnection) { return 'result'; }, transform: true); } @@ -1454,7 +1545,7 @@ public function testShuffleNodesMaintainsNodeCount(): void $this->assertSame(3, count($nodes)); } - public function testFlushByPatternDelegatestoConnection() + public function testFlushByPatternDelegatesToConnection(): void { $connection = $this->mockConnection(); $connection->shouldReceive('flushByPattern') @@ -1470,7 +1561,7 @@ public function testFlushByPatternDelegatestoConnection() $this->assertSame(42, $result); } - public function testIsClusterReturnsFalseForStandardConfig() + public function testIsClusterReturnsFalseForStandardConfig(): void { $pool = m::mock(RedisPool::class); $pool->shouldReceive('getConfig')->andReturn([ @@ -1487,7 +1578,7 @@ public function testIsClusterReturnsFalseForStandardConfig() $this->assertFalse($redis->isCluster()); } - public function testIsClusterReturnsTrueForClusterConfig() + public function testIsClusterReturnsTrueForClusterConfig(): void { $pool = m::mock(RedisPool::class); $pool->shouldReceive('getConfig')->andReturn([ @@ -1620,8 +1711,8 @@ private function createMockRedisConnection( // Forward the command call to the mock PHP Redis $mockRedisConnection->shouldReceive($command) - ->andReturnUsing(function (...$args) use ($mockPhpRedis, $command) { - return $mockPhpRedis->{$command}(...$args); + ->andReturnUsing(function (...$arguments) use ($mockPhpRedis, $command) { + return $mockPhpRedis->{$command}(...$arguments); }); return $mockRedisConnection; From e9fb7574d3730970f982521046cd4cb58667118a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:05 +0000 Subject: [PATCH 03/18] refactor(database): make connections driver neutral Move PDO state and behavior into a dedicated PdoConnection subclass while keeping Connection as the transport-neutral query, transaction, event, and grammar abstraction. Keep the concrete SQL connections on the PDO subclass so Laravel-compatible PDO APIs remain available where they are meaningful. Replace direct PDO assumptions with driver-owned resource lifecycle, escaping, transaction invalidation, server-version, and last-insert-id contracts. Adapt factories, pooling, reconnects, testing support, queue feature detection, Telescope binding rendering, and facade annotations to those seams. Preserve lazy read/write selection and pooled ownership semantics, validate replacement resources before swapping them, and cover resource cleanup, nested deadlocks, coroutine isolation, reconnects, session setup, query processing, diagnostics, and PDO API parity with focused unit and integration tests. --- .../src/Concerns/ManagesTransactions.php | 87 +- src/database/src/Connection.php | 726 +++-------- src/database/src/ConnectionInterface.php | 25 +- .../src/Connectors/ConnectionFactory.php | 89 +- .../src/Connectors/MySqlConnector.php | 19 +- .../src/Connectors/PostgresConnector.php | 3 +- .../src/Connectors/SQLiteConnector.php | 13 +- src/database/src/DatabaseManager.php | 56 +- src/database/src/Events/QueryExecuted.php | 2 +- src/database/src/Events/StatementPrepared.php | 6 +- src/database/src/MySqlConnection.php | 34 +- src/database/src/PdoConnection.php | 870 +++++++++++++ src/database/src/Pool/DbPool.php | 24 +- src/database/src/Pool/PooledConnection.php | 129 +- src/database/src/PostgresConnection.php | 6 +- src/database/src/Query/Builder.php | 4 +- .../src/Query/Processors/MySqlProcessor.php | 1 - .../src/Query/Processors/Processor.php | 2 +- src/database/src/QueryException.php | 2 +- src/database/src/SQLiteConnection.php | 2 +- src/database/src/Schema/Builder.php | 11 +- src/database/src/Schema/SqliteSchemaState.php | 6 + src/database/src/SessionConfigurator.php | 6 +- .../Concerns/InteractsWithDatabase.php | 2 +- .../Testing/DatabaseConnectionResolver.php | 2 +- .../src/Testing/DatabaseTruncation.php | 18 +- .../src/Testing/RefreshDatabase.php | 28 +- src/queue/src/DatabaseQueue.php | 10 +- src/support/src/Facades/DB.php | 32 +- src/telescope/src/Watchers/QueryWatcher.php | 35 +- .../src/PHPUnit/AfterEachTestSubscriber.php | 2 +- .../Coroutine/CoroutineCreateFailureTest.php | 4 +- .../DatabaseConnectionFactoryTest.php | 218 +++- tests/Database/DatabaseConnectionTest.php | 936 ++++++-------- tests/Database/DatabaseConnectorTest.php | 118 +- ...EloquentBelongsToManyCreateOrFirstTest.php | 5 +- ...tabaseEloquentBuilderCreateOrFirstTest.php | 5 +- .../Database/DatabaseEloquentBuilderTest.php | 3 +- ...tabaseEloquentHasManyCreateOrFirstTest.php | 5 +- ...loquentHasManyThroughCreateOrFirstTest.php | 5 +- tests/Database/DatabaseManagerTest.php | 189 ++- tests/Database/DatabaseMySqlBuilderTest.php | 15 +- tests/Database/DatabasePdoConnectionTest.php | 1087 +++++++++++++++++ tests/Database/DatabaseProcessorTest.php | 32 +- tests/Database/DatabaseSQLiteBuilderTest.php | 76 +- tests/Database/DatabaseSchemaBuilderTest.php | 13 +- .../DatabaseSessionConfiguratorTest.php | 127 +- .../DatabaseSqliteSchemaStateTest.php | 15 + tests/Database/PoolFactoryTest.php | 42 +- tests/Database/QueryDurationThresholdTest.php | 27 +- .../Concerns/InteractsWithDatabaseTest.php | 2 +- .../DatabaseConnectionResolverTest.php | 4 +- .../Testing/DatabaseTruncationTest.php | 53 +- .../Testing/RefreshDatabaseTest.php | 66 +- .../ConnectionCoroutineSafetyTest.php | 134 +- .../Database/PooledConnectionTest.php | 307 ++++- .../Postgres/SessionConfiguratorTest.php | 7 +- .../Database/SessionConfiguratorTest.php | 7 +- .../Database/Sqlite/DbPoolHeartbeatTest.php | 47 +- .../Sqlite/EloquentModelConnectionsTest.php | 4 +- .../Sqlite/InMemorySqliteSharedPdoTest.php | 3 +- tests/Queue/QueueDatabaseQueueUnitTest.php | 64 + tests/Sentry/CoroutineSafetyTest.php | 9 +- .../Features/DatabaseIntegrationTest.php | 9 +- tests/Sentry/Tracing/EventHandlerTest.php | 6 +- tests/Telescope/Watchers/QueryWatcherTest.php | 145 ++- .../PHPUnit/AfterEachTestSubscriberTest.php | 55 + 67 files changed, 4296 insertions(+), 1800 deletions(-) create mode 100755 src/database/src/PdoConnection.php create mode 100755 tests/Database/DatabasePdoConnectionTest.php diff --git a/src/database/src/Concerns/ManagesTransactions.php b/src/database/src/Concerns/ManagesTransactions.php index 2118e0f2a3..9cac0af911 100644 --- a/src/database/src/Concerns/ManagesTransactions.php +++ b/src/database/src/Concerns/ManagesTransactions.php @@ -7,7 +7,6 @@ use Closure; use Hypervel\Database\DeadlockException; use LogicException; -use PDO; use RuntimeException; use Throwable; @@ -113,7 +112,7 @@ protected function handleTransactionException(Throwable $e, int $currentAttempt, // let the developer handle it in another way. We will decrement too. if ($this->causedByConcurrencyError($e) && $this->transactions > 1) { - $this->invalidateSessionState($this->resolvePdo()); + $this->invalidateCurrentSessionState(); --$this->transactions; @@ -209,18 +208,6 @@ protected function createTransaction(): void } } - /** - * Create a save point within the database. - * - * @throws Throwable - */ - protected function createSavepoint(): void - { - $this->resolvePdo()->exec( - $this->queryGrammar->compileSavepoint('trans' . ($this->transactions + 1)) - ); - } - /** * Handle an exception from a transaction beginning. * @@ -277,27 +264,6 @@ public function commit(): void } } - /** - * Commit the active physical transaction. - */ - protected function performCommit(): void - { - $pdo = $this->resolvePdo(); - - try { - $pdo->commit(); - } catch (Throwable $exception) { - $this->invalidateSessionState($pdo); - - if (! $this->causedByLostConnection($exception) - && ! $this->causedByConcurrencyError($exception)) { - $this->markSessionStateUnknown($pdo); - } - - throw $exception; - } - } - /** * Handle an exception encountered when committing a transaction. * @@ -307,7 +273,7 @@ protected function handleCommitTransactionException(Throwable $e, int $currentAt { if ($this->causedByLostConnection($e)) { try { - $this->terminateTransactionState(); + $this->forgetLostConnection(); } catch (Throwable) { // Preserve the physical commit failure. } @@ -353,18 +319,10 @@ public function rollBack(?int $toLevel = null): void // Next, we will actually perform this rollback within this database and fire the // rollback event. We will also set the current transaction level to the given // level that was passed into this method so it will be right from here out. - $pdo = $this->resolvePdo(); - try { - $this->performRollBack($toLevel, $pdo); + $this->performRollBack($toLevel); } catch (Throwable $exception) { - if (! $this->causedByLostConnection($exception)) { - $this->markSessionStateUnknown($pdo); - } - $this->handleRollBackException($exception); - } finally { - $this->invalidateSessionState($pdo); } $this->transactions = $toLevel; @@ -390,24 +348,6 @@ public function rollBack(?int $toLevel = null): void } } - /** - * Perform a rollback within the database. - * - * @throws Throwable - */ - protected function performRollBack(int $toLevel, PDO $pdo): void - { - if ($toLevel === 0) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - } elseif ($this->queryGrammar->supportsSavepoints()) { - $pdo->exec( - $this->queryGrammar->compileSavepointRollBack('trans' . ($toLevel + 1)) - ); - } - } - /** * Handle an exception from a rollback. * @@ -417,7 +357,7 @@ protected function handleRollBackException(Throwable $e): void { if ($this->causedByLostConnection($e)) { try { - $this->terminateTransactionState(); + $this->forgetLostConnection(); } catch (Throwable) { // Preserve the physical rollback failure. } @@ -427,23 +367,14 @@ protected function handleRollBackException(Throwable $e): void } /** - * Detach transaction records and physical connection references. + * Forget a connection after a lost transaction operation. */ - protected function terminateTransactionState(): void + protected function forgetLostConnection(): void { - $this->transactions = 0; - $exception = null; - try { - $this->transactionsManager?->rollback($this->getName(), 0); - } catch (Throwable $throwable) { - $exception = $throwable; - } - - $this->setPdo(null)->setReadPdo(null); - - if ($exception !== null) { - throw $exception; + $this->resetTransactionState(); + } finally { + $this->forgetDriverResources(); } } diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 50014d19d2..b4a0ad3d87 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -9,9 +9,9 @@ use DateTimeInterface; use Exception; use Generator; +use Hypervel\Context\NonCopyableContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Events\QueryExecuted; -use Hypervel\Database\Events\StatementPrepared; use Hypervel\Database\Events\TransactionBeginning; use Hypervel\Database\Events\TransactionCommitted; use Hypervel\Database\Events\TransactionCommitting; @@ -26,17 +26,13 @@ use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Traits\Macroable; use LogicException; -use PDO; -use PDOStatement; use RuntimeException; -use stdClass; use Throwable; use UnitEnum; -use WeakMap; use function Hypervel\Support\enum_value; -class Connection implements ConnectionInterface +abstract class Connection implements ConnectionInterface, NonCopyableContext { use DetectsConcurrencyErrors; use DetectsLostConnections; @@ -45,33 +41,29 @@ class Connection implements ConnectionInterface use Macroable; /** - * The active PDO connection. - * - * @var null|(Closure(): PDO)|PDO + * The database connection configuration options for reading. */ - protected PDO|Closure|null $pdo; + protected array $readConnectionConfig = []; /** - * The active PDO connection used for reads. - * - * @var null|(Closure(): PDO)|PDO + * The name of the connected database. */ - protected PDO|Closure|null $readPdo = null; + protected string $database; /** - * The database connection configuration options for reading. + * The configured database name. */ - protected array $readPdoConfig = []; + protected string $configuredDatabase; /** - * The name of the connected database. + * The table prefix for the connection. */ - protected string $database; + protected string $tablePrefix = ''; /** - * The table prefix for the connection. + * The configured table prefix. */ - protected string $tablePrefix = ''; + protected string $configuredTablePrefix = ''; /** * The database connection configuration options. @@ -105,11 +97,6 @@ class Connection implements ConnectionInterface */ protected ?Dispatcher $events = null; - /** - * The default fetch mode of the connection. - */ - protected int $fetchMode = PDO::FETCH_OBJ; - /** * The number of active transactions. */ @@ -133,7 +120,7 @@ class Connection implements ConnectionInterface protected bool $recordsModified = false; /** - * Indicates if the connection should use the "write" PDO connection. + * Indicates if the connection should use the write connection when reading. */ protected bool $readOnWriteConnection = false; @@ -145,11 +132,11 @@ class Connection implements ConnectionInterface protected ?string $readWriteType = null; /** - * The last retrieved PDO read / write type. + * The last retrieved read / write type. * * @var null|'read'|'write' */ - protected ?string $latestPdoTypeRetrieved = null; + protected ?string $latestReadWriteTypeRetrieved = null; /** * All of the queries run against the connection. @@ -201,20 +188,6 @@ class Connection implements ConnectionInterface */ protected int $errorCount = 0; - /** - * The registered database session configurators. - * - * @var list - */ - protected static array $sessionConfigurators = []; - - /** - * The state known for each live physical database session. - * - * @var null|WeakMap - */ - protected static ?WeakMap $physicalSessionStates = null; - /** * The connection resolvers. * @@ -225,16 +198,16 @@ class Connection implements ConnectionInterface /** * Create a new database connection instance. */ - public function __construct(PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) + public function __construct(string $database = '', string $tablePrefix = '', array $config = []) { - $this->pdo = $pdo; - // First we will setup the default properties. We keep track of the DB // name we are connected to since it is needed when some reflective // type commands are run such as checking whether a table exists. $this->database = $database; + $this->configuredDatabase = $database; $this->tablePrefix = $tablePrefix; + $this->configuredTablePrefix = $tablePrefix; $this->config = $config; @@ -385,54 +358,17 @@ public function selectFromWriteConnection(string $query, array $bindings = []): /** * Run a select statement against the database. */ - public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array - { - return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { - if ($this->pretending()) { - return []; - } - - // For select statements, we'll simply execute the query and return an array - // of the database result set. Each element in the array will be a single - // row from the database table, and will either be an array or objects. - $statement = $this->prepared( - $this->getPdoForSelect($useReadPdo)->prepare($query) - ); - - $this->bindValues($statement, $this->prepareBindings($bindings)); - - $statement->execute(); - - return $statement->fetchAll(...$fetchUsing); - }); - } + abstract public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array; /** * Run a select statement against the database and return all of the result sets. */ public function selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array { - return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { - if ($this->pretending()) { - return []; - } - - $statement = $this->prepared( - $this->getPdoForSelect($useReadPdo)->prepare($query) - ); - - $this->bindValues($statement, $this->prepareBindings($bindings)); - - $statement->execute(); - - $sets = []; - - do { - $sets[] = $statement->fetchAll(...$fetchUsing); - } while ($statement->nextRowset()); - - return $sets; - }); + throw new LogicException(sprintf( + 'Database driver [%s] does not support multiple result sets.', + $this->getDriverName(), + )); } /** @@ -440,83 +376,25 @@ public function selectResultSets(string $query, array $bindings = [], bool $useR * * @return Generator */ - public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator - { - $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { - if ($this->pretending()) { - return null; - } - - // First we will create a statement for the query. Then, we will set the fetch - // mode and prepare the bindings for the query. Once that's done we will be - // ready to execute the query against the database and return the cursor. - $statement = $this->prepared($this->getPdoForSelect($useReadPdo) - ->prepare($query)); - - $this->bindValues( - $statement, - $this->prepareBindings($bindings) - ); - - // Next, we'll execute the query against the database and return the statement - // so we can return the cursor. The cursor will use a PHP generator to give - // back one row at a time without using a bunch of memory to render them. - $statement->execute(); - - return $statement; - }); - - if ($statement === null) { - return; - } - - if ($fetchUsing !== []) { - // fetchAll() supplies default column and class arguments that setFetchMode() - // demands explicitly, so a mode-only call keeps the same meaning when streamed. - if (count($fetchUsing) === 1) { - $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE); - - if ($mode === PDO::FETCH_COLUMN) { - $fetchUsing[] = 0; - } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) { - $fetchUsing[] = stdClass::class; - } - } - - $statement->setFetchMode(...$fetchUsing); - } - - foreach ($statement as $record) { - yield $record; - } - } + abstract public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator; /** - * Configure the PDO prepared statement. - */ - protected function prepared(PDOStatement $statement): PDOStatement - { - $statement->setFetchMode($this->fetchMode); - - $this->event(StatementPrepared::class, fn () => new StatementPrepared($this, $statement)); - - return $statement; - } - - /** - * Get the PDO connection to use for a select query. + * Run an insert statement against the database. */ - protected function getPdoForSelect(bool $useReadPdo = true): PDO + public function insert(string $query, array $bindings = []): bool { - return $useReadPdo ? $this->getReadPdo() : $this->getPdo(); + return $this->statement($query, $bindings); } /** - * Run an insert statement against the database. + * Get the last insert ID. */ - public function insert(string $query, array $bindings = []): bool + public function getLastInsertId(?string $sequence = null): int|string { - return $this->statement($query, $bindings); + throw new LogicException(sprintf( + 'Database driver [%s] does not support retrieving last insert IDs.', + $this->getDriverName(), + )); } /** @@ -538,67 +416,17 @@ public function delete(string $query, array $bindings = []): int /** * Execute an SQL statement and return the boolean result. */ - public function statement(string $query, array $bindings = []): bool - { - return $this->run($query, $bindings, function ($query, $bindings) { - if ($this->pretending()) { - return true; - } - - $statement = $this->getPdo()->prepare($query); - - $this->bindValues($statement, $this->prepareBindings($bindings)); - - $this->recordsHaveBeenModified(); - - return $statement->execute(); - }); - } + abstract public function statement(string $query, array $bindings = []): bool; /** * Run an SQL statement and get the number of rows affected. */ - public function affectingStatement(string $query, array $bindings = []): int - { - return $this->run($query, $bindings, function ($query, $bindings) { - if ($this->pretending()) { - return 0; - } - - // For update or delete statements, we want to get the number of rows affected - // by the statement and return that back to the developer. We'll first need - // to execute the statement and then we'll use PDO to fetch the affected. - $statement = $this->getPdo()->prepare($query); - - $this->bindValues($statement, $this->prepareBindings($bindings)); - - $statement->execute(); - - $this->recordsHaveBeenModified( - ($count = $statement->rowCount()) > 0 - ); - - return $count; - }); - } + abstract public function affectingStatement(string $query, array $bindings = []): int; /** - * Run a raw, unprepared query against the PDO connection. + * Run a raw, unprepared query against the connection. */ - public function unprepared(string $query): bool - { - return $this->run($query, [], function ($query) { - if ($this->pretending()) { - return true; - } - - $this->recordsHaveBeenModified( - $change = $this->getPdo()->exec($query) !== false - ); - - return $change; - }); - } + abstract public function unprepared(string $query): bool; /** * Get the number of open connections for the database. @@ -683,24 +511,6 @@ protected function withFreshQueryLog(Closure $callback): array } } - /** - * Bind values to their parameters in the given statement. - */ - public function bindValues(PDOStatement $statement, array $bindings): void - { - foreach ($bindings as $key => $value) { - $statement->bindValue( - is_string($key) ? $key : $key + 1, - $value, - match (true) { - is_int($value) => PDO::PARAM_INT, - is_resource($value) => PDO::PARAM_LOB, - default => PDO::PARAM_STR - }, - ); - } - } - /** * Prepare the query bindings for execution. */ @@ -771,7 +581,7 @@ protected function run(string $query, array $bindings, Closure $callback): mixed protected function runQueryCallback(string $query, array $bindings, Closure $callback): mixed { // To execute the statement, we'll simply call the callback, which will actually - // run the SQL against the PDO connection. Then we can calculate the time it + // run the SQL against the database connection. Then we can calculate the time it // took to execute and log the query SQL, bindings and time in our memory. try { return $callback($query, $bindings); @@ -969,38 +779,50 @@ public function reconnect(): mixed } /** - * Reconnect to the database if a PDO connection is missing. + * Reconnect to the database if the driver resources are missing. */ public function reconnectIfMissingConnection(): void { - if (is_null($this->pdo)) { + if (! $this->hasDriverResources()) { $this->reconnect(); } } /** - * Disconnect from the underlying PDO connection. + * Refresh the driver resources from a fresh connection. + * + * @internal + */ + final public function refreshFrom(Connection $fresh): void + { + if ($fresh::class !== static::class || $fresh->getName() !== $this->getName()) { + throw new LogicException(sprintf( + 'Cannot refresh connection [%s] of type [%s] from connection [%s] of type [%s].', + $this->getName() ?? '', + static::class, + $fresh->getName() ?? '', + $fresh::class, + )); + } + + $this->replaceDriverResources($fresh); + } + + /** + * Disconnect from the underlying driver resources. */ public function disconnect(): void { - $pdo = $this->getRawPdo(); $exception = null; try { - if ($pdo instanceof PDO && $pdo->inTransaction()) { - $pdo->rollBack(); - $this->invalidateSessionState($pdo); - } + $this->disconnectDriverResources(); } catch (Throwable $throwable) { - $this->markSessionStateUnknown($pdo); - - if (! $this->causedByLostConnection($throwable)) { - $exception = $throwable; - } + $exception = $throwable; } try { - $this->terminateTransactionState(); + $this->resetTransactionState(); } catch (Throwable $throwable) { $exception ??= $throwable; } @@ -1010,6 +832,62 @@ public function disconnect(): void } } + /** + * Reset the logical transaction state. + */ + protected function resetTransactionState(): void + { + $this->transactions = 0; + + $this->transactionsManager?->rollback($this->getName(), 0); + } + + /** + * Determine whether the connection has driver resources. + */ + abstract protected function hasDriverResources(): bool; + + /** + * Disconnect the driver resources. + * + * Implementations must forget the current resources through + * forgetDriverResources() in a finally block so cleanup failure cannot + * leave stale resources attached. + */ + abstract protected function disconnectDriverResources(): void; + + /** + * Forget the driver resources without performing physical cleanup. + */ + abstract protected function forgetDriverResources(): void; + + /** + * Refresh the driver resources from a fresh connection. + * + * Validate and capture a complete replacement set of driver resources and + * resource-associated metadata, including the configured database and table + * prefix baselines. Adopt it in a finally block around teardown. The original + * teardown throwable must propagate unchanged. + */ + abstract protected function replaceDriverResources(Connection $fresh): void; + + /** + * Determine whether the connection is responsive. + * + * @internal + */ + abstract public function ping(): bool; + + /** + * Determine whether the connection may be reused. + * + * @internal + */ + public function isReusable(): bool + { + return true; + } + /** * Register a hook to be run just before a database transaction is started. */ @@ -1067,8 +945,9 @@ public function endForeignKeyConstraintSuppression(): void /** * Reset all wrapper state for pool release. * + * Mutable connection metadata is restored to its configured values. * Trustworthy physical session state is preserved and synchronized against - * the next coroutine's desired state when the PDO is handed out again. + * the next coroutine's desired state when the connection is handed out again. */ public function resetForPool(): void { @@ -1089,7 +968,10 @@ public function resetForPool(): void $this->totalQueryDuration = 0.0; $this->queryDurationHandlers = []; - // Reset connection routing + // Reset connection metadata and routing + $this->database = $this->configuredDatabase; + $this->tablePrefix = $this->configuredTablePrefix; + $this->latestReadWriteTypeRetrieved = null; $this->readOnWriteConnection = false; // Reset pretend mode (defensive - normally reset by finally block) @@ -1203,10 +1085,7 @@ public function escape(mixed $value, bool $binary = false): string /** * Escape a string value for safe SQL embedding. */ - protected function escapeString(string $value): string - { - return $this->getReadPdo()->quote($value); - } + abstract protected function escapeString(string $value): string; /** * Escape a boolean value for safe SQL embedding. @@ -1263,7 +1142,7 @@ public function forgetRecordModificationState(): void } /** - * Indicate that the connection should use the write PDO connection for reads. + * Indicate that the connection should use the write connection for reads. */ public function useWriteConnectionWhenReading(bool $value = true): static { @@ -1273,288 +1152,32 @@ public function useWriteConnectionWhenReading(bool $value = true): static } /** - * Get the current synchronized PDO connection. - */ - public function getPdo(): PDO - { - $this->latestPdoTypeRetrieved = 'write'; - $pdo = $this->resolvePdo(); - - return static::$sessionConfigurators === [] - ? $pdo - : $this->synchronizeSession($pdo, read: false); - } - - /** - * Get the current PDO parameter without resolving, reconnecting, or synchronizing session state. + * Invalidate the state remembered for the current physical session. */ - public function getRawPdo(): PDO|Closure|null + protected function invalidateCurrentSessionState(): void { - return $this->pdo; } /** - * Get the current synchronized PDO connection used for reading. - */ - public function getReadPdo(): PDO - { - if ($this->transactions > 0) { - return $this->getPdo(); - } - - if ($this->readOnWriteConnection - || ($this->recordsModified && $this->getConfig('sticky'))) { - return $this->getPdo(); - } - - $this->latestPdoTypeRetrieved = 'read'; - $pdo = $this->resolveReadPdo(); - - return static::$sessionConfigurators === [] - ? $pdo - : $this->synchronizeSession($pdo, read: true); - } - - /** - * Get the current read PDO parameter without resolving, reconnecting, or synchronizing session state. - */ - public function getRawReadPdo(): PDO|Closure|null - { - return $this->readPdo; - } - - /** - * Resolve the current write PDO without synchronizing session state. - */ - protected function resolvePdo(): PDO - { - if ($this->pdo instanceof Closure) { - return $this->pdo = call_user_func($this->pdo); - } - - return $this->pdo; - } - - /** - * Resolve the current read PDO without synchronizing session state. - */ - protected function resolveReadPdo(): PDO - { - if ($this->readPdo instanceof Closure) { - return $this->readPdo = call_user_func($this->readPdo); - } - - if ($this->readPdo instanceof PDO) { - return $this->readPdo; - } - - $this->latestPdoTypeRetrieved = 'write'; - - return $this->resolvePdo(); - } - - /** - * Synchronize the desired state for a physical database session. - */ - protected function synchronizeSession(PDO $pdo, bool $read): PDO - { - $sessionState = static::physicalSessionState($pdo); - - if ($sessionState->configuring) { - $this->markSessionStateUnknown($pdo); - - throw new RuntimeException('Reentrant database session configuration is not allowed.'); - } - - if ($sessionState->unknown) { - $sessionState->configuring = true; - - try { - $pdo = $this->replaceUnknownSession($read); - } finally { - $sessionState->configuring = false; - } - - $sessionState = static::physicalSessionState($pdo); - - if ($sessionState->configuring) { - $this->markSessionStateUnknown($pdo); - - throw new RuntimeException('Reentrant database session configuration is not allowed.'); - } - } - - $sessionState->configuring = true; - - try { - foreach (static::$sessionConfigurators as $index => $configurator) { - $desiredState = $configurator->state($this); - - if ($desiredState === null - || ($sessionState->appliedStates[$index] ?? null) === $desiredState) { - continue; - } - - try { - $configurator->apply($pdo, $desiredState, $this); - - if ($sessionState->unknown) { - throw new RuntimeException('Database session state became unknown during configuration.'); - } - } catch (Throwable $exception) { - $sessionState->appliedStates = []; - $sessionState->unknown = true; - - throw $exception; - } - - $sessionState->appliedStates[$index] = $desiredState; - } - - if ($sessionState->unknown) { - throw new RuntimeException('Database session state became unknown during configuration.'); - } - } finally { - $sessionState->configuring = false; - } - - return $pdo; - } - - /** - * Replace a physical session whose state can no longer be trusted. - */ - protected function replaceUnknownSession(bool $read): PDO - { - if ($this->transactions > 0) { - throw new RuntimeException('Database session state is unknown within an active transaction.'); - } - - $this->reconnect(); - - $replacement = $read - ? $this->resolveReadPdo() - : $this->resolvePdo(); - - if (static::sessionStateIsUnknown($replacement)) { - throw new RuntimeException('Database session state remains unknown after reconnecting.'); - } - - return $replacement; - } - - /** - * Get the state holder for a physical database session. - */ - protected static function physicalSessionState(PDO $pdo): PhysicalSessionState - { - $states = static::$physicalSessionStates ??= new WeakMap; - - return $states[$pdo] ??= new PhysicalSessionState; - } - - /** - * Determine whether a physical database session has unknown state. - */ - protected static function sessionStateIsUnknown(PDO $pdo): bool - { - return static::$physicalSessionStates !== null - && isset(static::$physicalSessionStates[$pdo]) - && static::$physicalSessionStates[$pdo]->unknown; - } - - /** - * Invalidate the states remembered for a physical database session. - */ - protected function invalidateSessionState(PDO $pdo): void - { - if (static::$physicalSessionStates !== null - && isset(static::$physicalSessionStates[$pdo])) { - static::$physicalSessionStates[$pdo]->appliedStates = []; - } - } - - /** - * Mark a physical database session's state as unknown. - */ - protected function markSessionStateUnknown(PDO $pdo): void - { - $sessionState = static::physicalSessionState($pdo); - $sessionState->appliedStates = []; - $sessionState->unknown = true; - } - - /** - * Mark the current write session's state as unknown. + * Mark the current physical session state as unknown. * * @internal */ public function markCurrentSessionStateUnknown(): void { - $pdo = $this->getRawPdo(); - - if (! $pdo instanceof PDO) { - // Cleanup must not resolve a lazy connection merely to invalidate a session that does not yet exist. - return; - } - - $this->markSessionStateUnknown($pdo); } /** - * Determine whether an open PDO has unknown session state. + * Execute an internal physical-session statement. * * @internal */ - public function hasUnknownSessionState(): bool + public function executeSessionStatement(string $sql): void { - if (static::$physicalSessionStates === null) { - return false; - } - - $writePdo = $this->getRawPdo(); - - if ($writePdo instanceof PDO - && static::sessionStateIsUnknown($writePdo)) { - return true; - } - - $readPdo = $this->getRawReadPdo(); - - return $readPdo instanceof PDO - && static::sessionStateIsUnknown($readPdo); - } - - /** - * Set the PDO connection. - */ - public function setPdo(PDO|Closure|null $pdo): static - { - $this->transactions = 0; - - $this->pdo = $pdo; - - return $this; - } - - /** - * Set the PDO connection used for reading. - */ - public function setReadPdo(PDO|Closure|null $pdo): static - { - $this->readPdo = $pdo; - - return $this; - } - - /** - * Set the read PDO connection configuration. - */ - public function setReadPdoConfig(array $config): static - { - $this->readPdoConfig = $config; - - return $this; + throw new LogicException(sprintf( + 'Database driver [%s] does not support physical session statements.', + $this->getDriverName(), + )); } /** @@ -1577,6 +1200,8 @@ public function getName(): ?string /** * Get an option from the configuration options. + * + * @return ($option is null ? array : mixed) */ public function getConfig(?string $option = null): mixed { @@ -1588,8 +1213,8 @@ public function getConfig(?string $option = null): mixed */ protected function getConnectionDetails(): array { - $config = $this->latestReadWriteTypeUsed() === 'read' && $this->readPdoConfig !== [] - ? $this->readPdoConfig + $config = $this->latestReadWriteTypeUsed() === 'read' && $this->readConnectionConfig !== [] + ? $this->readConnectionConfig : $this->config; return [ @@ -1603,7 +1228,7 @@ protected function getConnectionDetails(): array } /** - * Get the PDO driver name. + * Get the database driver name. */ public function getDriverName(): string { @@ -1703,9 +1328,53 @@ public function unsetEventDispatcher(): void */ protected function executeBeginTransactionStatement(): void { - $this->getPdo()->beginTransaction(); + $this->throwUnsupportedTransactionException(); + } + + /** + * Create a save point within the database. + * + * @throws Throwable + */ + protected function createSavepoint(): void + { + $this->throwUnsupportedTransactionException(); + } + + /** + * Commit the active physical transaction. + */ + protected function performCommit(): void + { + $this->throwUnsupportedTransactionException(); + } + + /** + * Perform a rollback within the database. + * + * @throws Throwable + */ + protected function performRollBack(int $toLevel): void + { + $this->throwUnsupportedTransactionException(); + } + + /** + * Throw an exception for an unsupported transaction operation. + */ + private function throwUnsupportedTransactionException(): never + { + throw new LogicException(sprintf( + 'Database driver [%s] does not support transactions.', + $this->getDriverName(), + )); } + /** + * Determine whether the connection has an active physical transaction. + */ + abstract public function inTransaction(): bool; + /** * Set the transaction manager instance on the connection. */ @@ -1824,7 +1493,7 @@ public function setDatabaseName(string $database): static */ protected function latestReadWriteTypeUsed(): ?string { - return $this->readWriteType ?? $this->latestPdoTypeRetrieved; + return $this->readWriteType ?? $this->latestReadWriteTypeRetrieved; } /** @@ -1869,22 +1538,7 @@ public function withoutTablePrefix(Closure $callback): mixed /** * Get the server version for the connection. */ - public function getServerVersion(): string - { - return $this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - } - - /** - * Register a database session configurator. - * - * Boot-only. The configurator persists in a static property for the worker - * lifetime and runs on every subsequent synchronized PDO hand-out across all - * coroutines. - */ - public static function configureSessionUsing(SessionConfigurator $configurator): void - { - static::$sessionConfigurators[] = $configurator; - } + abstract public function getServerVersion(): string; /** * Register a connection resolver. @@ -1911,8 +1565,6 @@ public static function getResolver(string $driver): ?Closure */ public static function flushState(): void { - static::$sessionConfigurators = []; - static::$physicalSessionStates = null; static::$resolvers = []; static::flushMacros(); } diff --git a/src/database/src/ConnectionInterface.php b/src/database/src/ConnectionInterface.php index c7377ea7ba..c1aeab8a7f 100644 --- a/src/database/src/ConnectionInterface.php +++ b/src/database/src/ConnectionInterface.php @@ -11,7 +11,6 @@ use Hypervel\Database\Query\Grammars\Grammar as QueryGrammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\Schema\Builder as SchemaBuilder; -use PDO; use Throwable; use UnitEnum; @@ -27,6 +26,11 @@ public function table(Closure|Builder|UnitEnum|string $table, ?string $as = null */ public function raw(mixed $value): Expression; + /** + * Escape a value for safe SQL embedding. + */ + public function escape(mixed $value, bool $binary = false): string; + /** * Run a select statement and return a single result. */ @@ -56,6 +60,11 @@ public function cursor(string $query, array $bindings = [], bool $useReadPdo = t */ public function insert(string $query, array $bindings = []): bool; + /** + * Get the last insert ID. + */ + public function getLastInsertId(?string $sequence = null): int|string; + /** * Run an update statement against the database. */ @@ -77,7 +86,7 @@ public function statement(string $query, array $bindings = []): bool; public function affectingStatement(string $query, array $bindings = []): int; /** - * Run a raw, unprepared query against the PDO connection. + * Run a raw, unprepared query against the connection. */ public function unprepared(string $query): bool; @@ -120,6 +129,11 @@ public function rollBack(?int $toLevel = null): void; */ public function transactionLevel(): int; + /** + * Determine whether the connection has an active physical transaction. + */ + public function inTransaction(): bool; + /** * Execute the given callback in "dry run" mode. */ @@ -175,11 +189,6 @@ public function getQueryGrammar(): QueryGrammar; */ public function getPostProcessor(): Processor; - /** - * Get the current PDO connection. - */ - public function getPdo(): PDO; - /** * Get the table prefix for the connection. */ @@ -206,7 +215,7 @@ public function selectFromWriteConnection(string $query, array $bindings = []): public function recordsHaveBeenModified(bool $value = true): void; /** - * Disconnect from the underlying PDO connection. + * Disconnect from the underlying driver resources. */ public function disconnect(): void; } diff --git a/src/database/src/Connectors/ConnectionFactory.php b/src/database/src/Connectors/ConnectionFactory.php index 36d2559547..66a9a26cbc 100755 --- a/src/database/src/Connectors/ConnectionFactory.php +++ b/src/database/src/Connectors/ConnectionFactory.php @@ -11,10 +11,12 @@ use Hypervel\Database\ConnectionName; use Hypervel\Database\MariaDbConnection; use Hypervel\Database\MySqlConnection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\PostgresConnection; use Hypervel\Database\SQLiteConnection; use Hypervel\Support\Arr; use InvalidArgumentException; +use LogicException; use PDO; use PDOException; @@ -36,7 +38,7 @@ public function __construct( } /** - * Establish a PDO connection based on the configuration. + * Establish a database connection based on the configuration. */ public function make(array $config, ?string $name = null): Connection { @@ -45,24 +47,25 @@ public function make(array $config, ?string $name = null): Connection // First we will check by the connection name to see if an extension has been // registered specifically for that connection. If it has we will call the // Closure and pass it the config allowing it to resolve the connection. - if ($name !== null && isset($this->extensions[$name])) { - return call_user_func($this->extensions[$name], $config, $name); - } - // 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); - if ($driver !== null && isset($this->extensions[$driver])) { - return call_user_func($this->extensions[$driver], $config, $name); - } + if ($resolver !== null) { + $connection = call_user_func($resolver, $config, $name); - if (isset($config['read'])) { - return $this->createReadWriteConnection($config); + if (! $connection instanceof Connection) { + throw new InvalidArgumentException('Database connection extensions must return a Connection instance.'); + } + + return $connection; } - return $this->createSingleConnection($config); + return $this->createPdoConnectionFromConfig($config); } /** @@ -95,12 +98,12 @@ public function forgetExtension(string $name): void * must share the same PDO instance to see the same data. Without this, * each pooled connection would get its own empty in-memory database. * - * Returns Connection (not SQLiteConnection) to respect custom resolvers - * that may return a different Connection subclass. + * Laravel-compatible PDO resolvers may return a custom PdoConnection subclass. */ - public function makeSqliteFromSharedPdo(PDO $pdo, array $config, ?string $name = null): Connection + public function makeSqliteFromSharedPdo(PDO $pdo, array $config, ?string $name = null): PdoConnection { $config = $this->parseConfig($config, $name); + $this->ensureNoSharedInMemorySqliteExtension($config); // Use write config if read/write is configured, matching normal factory behavior $connectionConfig = isset($config['read']) @@ -117,6 +120,21 @@ public function makeSqliteFromSharedPdo(PDO $pdo, array $config, ?string $name = ); } + /** + * Create the initial pooled in-memory SQLite connection. + */ + public function makeSharedInMemorySqliteConnection(array $config, ?string $name = null): PdoConnection + { + $config = $this->parseConfig($config, $name); + $this->ensureNoSharedInMemorySqliteExtension($config); + + $connectionConfig = isset($config['read']) + ? $this->getWriteConfig($config) + : $config; + + return $this->createPdoConnectionFromConfig($connectionConfig); + } + /** * Parse and prepare the database configuration. */ @@ -152,9 +170,21 @@ public function configForRead(array $config): array } /** - * Create a single database connection instance. + * Create a PDO-backed connection from its configuration. + */ + protected function createPdoConnectionFromConfig(array $config): PdoConnection + { + if (isset($config['read'])) { + return $this->createReadWriteConnection($config); + } + + return $this->createSingleConnection($config); + } + + /** + * Create a single PDO-backed connection instance. */ - protected function createSingleConnection(array $config): Connection + protected function createSingleConnection(array $config): PdoConnection { $pdo = $this->createPdoResolver($config); @@ -170,7 +200,7 @@ protected function createSingleConnection(array $config): Connection /** * Create a read / write database connection instance. */ - protected function createReadWriteConnection(array $config): Connection + protected function createReadWriteConnection(array $config): PdoConnection { $connection = $this->createSingleConnection($this->getWriteConfig($config)); @@ -312,10 +342,16 @@ public function createConnector(array $config): ConnectorInterface * * @throws InvalidArgumentException */ - protected function createConnection(string $driver, PDO|Closure $connection, string $database, string $prefix = '', array $config = []): Connection + protected function createConnection(string $driver, PDO|Closure $connection, string $database, string $prefix = '', array $config = []): PdoConnection { if ($resolver = Connection::getResolver($driver)) { - return $resolver($connection, $database, $prefix, $config); + $resolvedConnection = $resolver($connection, $database, $prefix, $config); + + if (! $resolvedConnection instanceof PdoConnection) { + throw new InvalidArgumentException('PDO connection resolvers must return a PdoConnection instance.'); + } + + return $resolvedConnection; } return match ($driver) { @@ -326,4 +362,19 @@ protected function createConnection(string $driver, PDO|Closure $connection, str default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."), }; } + + /** + * Ensure pooled in-memory SQLite uses a PDO connection resolver. + */ + private function ensureNoSharedInMemorySqliteExtension(array $config): void + { + $name = $config['name'] ?? null; + + if (($name !== null && isset($this->extensions[$name])) + || isset($this->extensions['sqlite'])) { + throw new LogicException( + "Pooled in-memory SQLite connections cannot use config-first extensions. Use Connection::resolverFor('sqlite', ...) to register a PDO connection subclass." + ); + } + } } diff --git a/src/database/src/Connectors/MySqlConnector.php b/src/database/src/Connectors/MySqlConnector.php index 399db0d8fa..e63254f6a3 100755 --- a/src/database/src/Connectors/MySqlConnector.php +++ b/src/database/src/Connectors/MySqlConnector.php @@ -28,7 +28,9 @@ public function connect(array $config): PDO if (! empty($config['database']) && (! isset($config['use_db_after_connecting']) || $config['use_db_after_connecting'])) { - $connection->exec("use `{$config['database']}`;"); + $database = str_replace('`', '``', $config['database']); + + $connection->exec("use `{$database}`;"); } $this->configureConnection($connection, $config); @@ -36,6 +38,21 @@ public function connect(array $config): PDO return $connection; } + /** + * Get the PDO options based on the configuration. + */ + public function getOptions(array $config): array + { + $options = parent::getOptions($config); + + if (isset($config['connect_timeout']) + && ! array_key_exists(PDO::ATTR_TIMEOUT, $config['options'] ?? [])) { + $options[PDO::ATTR_TIMEOUT] = (int) ceil($config['connect_timeout']); + } + + return $options; + } + /** * Create a DSN string from a configuration. * diff --git a/src/database/src/Connectors/PostgresConnector.php b/src/database/src/Connectors/PostgresConnector.php index 3444cd52d7..661381dcc7 100755 --- a/src/database/src/Connectors/PostgresConnector.php +++ b/src/database/src/Connectors/PostgresConnector.php @@ -68,7 +68,8 @@ protected function getDsn(array $config): string } if (isset($connect_timeout)) { - $dsn .= ";connect_timeout={$connect_timeout}"; + $connectTimeout = (int) ceil($connect_timeout); + $dsn .= ";connect_timeout={$connectTimeout}"; } if (isset($charset)) { diff --git a/src/database/src/Connectors/SQLiteConnector.php b/src/database/src/Connectors/SQLiteConnector.php index 636ccc3329..309b771150 100755 --- a/src/database/src/Connectors/SQLiteConnector.php +++ b/src/database/src/Connectors/SQLiteConnector.php @@ -4,6 +4,8 @@ namespace Hypervel\Database\Connectors; +use Hypervel\Container\Container; +use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\SQLiteDatabase; use Hypervel\Database\SQLiteDatabaseDoesNotExistException; use InvalidArgumentException; @@ -51,8 +53,15 @@ protected function parseDatabasePath(string $path): string return $path; } - $path = realpath($path) - ?: (function_exists('base_path') ? realpath(base_path($path)) : false); + $path = realpath($path); + + // A standalone Capsule has no application root to resolve relative paths against. + if ($path === false + && function_exists('base_path') + && (defined('BASE_PATH') || Container::getInstance()->has(Application::class)) + ) { + $path = realpath(base_path($database)); + } // Here we'll verify that the SQLite database exists before going any further // as the developer probably wants to know if the database exists and this diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index bffa136bfa..b0524022ce 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -29,7 +29,7 @@ use function Hypervel\Support\enum_value; /** - * @mixin \Hypervel\Database\Connection + * @mixin \Hypervel\Database\PdoConnection */ class DatabaseManager implements ConnectionResolverInterface { @@ -79,17 +79,7 @@ public function __construct( protected ContainerContract $app, protected ConnectionFactory $factory ) { - $this->reconnector = function (Connection $connection) { - $name = $connection->getName(); - - if ($name !== null && $connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY) === ConnectionName::READ) { - $name .= '::' . ConnectionName::READ; - } - - $connection->setPdo( - $this->reconnect($name)->getRawPdo() - ); - }; + $this->reconnector = fn (Connection $connection) => $this->refreshConnection($connection); } /** @@ -176,6 +166,10 @@ protected function makeConnection(ConnectionName|string $name): Connection $connectionName = is_string($name) ? ConnectionName::parse($name) : $name; $config = $this->configuration($connectionName); + if ($connectionName->role !== null) { + $config[Connection::READ_WRITE_TYPE_CONFIG_KEY] = $connectionName->role; + } + return $this->factory->make($config, $connectionName->base); } @@ -296,7 +290,7 @@ public function purge(UnitEnum|string|null $name = null): void /** * Disconnect from the given database. * - * In pooled mode, this nulls the PDOs on the current coroutine's connection + * In pooled mode, this disconnects the current coroutine's driver resources * (if one exists), forcing a reconnect on the next query. Does not clear * context or affect the pool - the connection is still released at coroutine end. * @@ -329,9 +323,9 @@ public function disconnect(UnitEnum|string|null $name = null): void /** * Reconnect to the given database. * - * In pooled mode, if this coroutine already has a connection, reconnects - * its PDOs and returns it. In non-pooled mode, refreshes the existing - * connection's PDOs in-place. Otherwise gets a fresh connection. + * In pooled mode, if this coroutine already has a connection, refreshes + * its driver resources and returns it. In non-pooled mode, refreshes the + * existing connection in place. Otherwise gets a fresh connection. */ public function reconnect(UnitEnum|string|null $name = null): Connection { @@ -343,23 +337,20 @@ public function reconnect(UnitEnum|string|null $name = null): Connection ? $this->getDefaultConnection() : $name; - $this->disconnect($name); - // Pooled mode: if we already have a connection in this coroutine, reconnect it $contextKey = $this->getConnectionContextKey($name); $connection = CoroutineContext::get($contextKey); if ($connection instanceof Connection) { $connection->reconnect(); - $this->dispatchConnectionEstablishedEvent($connection); return $connection; } - // Non-pooled mode: refresh PDOs on existing connection in-place + // Non-pooled mode: refresh the existing connection in place. if (isset($this->connections[$name])) { - return tap($this->refreshPdoConnections($name), function ($connection) { - $this->dispatchConnectionEstablishedEvent($connection); - }); + $this->connections[$name]->reconnect(); + + return $this->connections[$name]; } // No existing connection — get a fresh one @@ -399,17 +390,26 @@ public function usingConnection(UnitEnum|string $name, callable $callback): mixe } /** - * Refresh the PDO connections on a given connection. + * Refresh the driver resources on the invoking connection. */ - protected function refreshPdoConnections(string $name): Connection + protected function refreshConnection(Connection $connection): Connection { + $name = $connection->getName() + ?? throw new RuntimeException('Cannot reconnect an unnamed database connection.'); + $role = $connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY); + + if ($role === ConnectionName::READ || $role === ConnectionName::WRITE) { + $name .= '::' . $role; + } + $fresh = $this->configure( $this->makeConnection($name) ); - return $this->connections[$name] - ->setPdo($fresh->getRawPdo()) - ->setReadPdo($fresh->getRawReadPdo()); + $connection->refreshFrom($fresh); + $this->dispatchConnectionEstablishedEvent($connection); + + return $connection; } /** diff --git a/src/database/src/Events/QueryExecuted.php b/src/database/src/Events/QueryExecuted.php index b106e8abf2..3ac3eb9f4b 100644 --- a/src/database/src/Events/QueryExecuted.php +++ b/src/database/src/Events/QueryExecuted.php @@ -34,7 +34,7 @@ class QueryExecuted public string $connectionName; /** - * The PDO read / write type for the executed query. + * The connection role used for the executed query. * * @var null|'read'|'write' */ diff --git a/src/database/src/Events/StatementPrepared.php b/src/database/src/Events/StatementPrepared.php index 2525620267..95db703774 100644 --- a/src/database/src/Events/StatementPrepared.php +++ b/src/database/src/Events/StatementPrepared.php @@ -4,7 +4,7 @@ namespace Hypervel\Database\Events; -use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use PDOStatement; class StatementPrepared @@ -12,11 +12,11 @@ class StatementPrepared /** * Create a new event instance. * - * @param Connection $connection the database connection instance + * @param PdoConnection $connection the database connection instance * @param PDOStatement $statement the PDO statement */ public function __construct( - public Connection $connection, + public PdoConnection $connection, public PDOStatement $statement, ) { } diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index eb8dc2cbea..abd33f1c14 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -4,6 +4,7 @@ namespace Hypervel\Database; +use Closure; use Exception; use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\Query\Processors\MySqlProcessor; @@ -14,8 +15,9 @@ use Hypervel\Support\Str; use Override; use PDO; +use RuntimeException; -class MySqlConnection extends Connection +class MySqlConnection extends PdoConnection { /** * The last inserted ID generated by the server. @@ -49,7 +51,8 @@ public function insert(string $query, array $bindings = [], ?string $sequence = $result = $statement->execute(); - $this->lastInsertId = $pdo->lastInsertId($sequence); + // Read the ID from the same session that executed the insert. + $this->lastInsertId = $this->getLastInsertIdFrom($pdo, $sequence); return $result; }); @@ -92,11 +95,36 @@ protected function parseUniqueConstraintViolation(Exception $exception): array /** * Get the connection's last insert ID. */ - public function getLastInsertId(): string|int|null + public function getLastInsertId(?string $sequence = null): int|string { + // The sequence is intentionally ignored because the ID was captured from the insert session. + if ($this->lastInsertId === null) { + throw new RuntimeException('No last insert ID has been captured for this connection.'); + } + return $this->lastInsertId; } + /** + * Set the PDO connection. + */ + public function setPdo(PDO|Closure|null $pdo): static + { + $this->lastInsertId = null; + + return parent::setPdo($pdo); + } + + /** + * Reset all wrapper state for pool release. + */ + public function resetForPool(): void + { + parent::resetForPool(); + + $this->lastInsertId = null; + } + /** * Determine if the connected database is a MariaDB database. */ diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php new file mode 100755 index 0000000000..7d71d13361 --- /dev/null +++ b/src/database/src/PdoConnection.php @@ -0,0 +1,870 @@ + + */ + protected static array $sessionConfigurators = []; + + /** + * The state known for each live physical database session. + * + * @var null|WeakMap + */ + protected static ?WeakMap $physicalSessionStates = null; + + /** + * Create a new PDO database connection instance. + * + * @param (Closure(): PDO)|PDO $pdo + */ + public function __construct(PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) + { + $this->pdo = $pdo; + + parent::__construct($database, $tablePrefix, $config); + } + + /** + * Run a select statement against the database. + */ + public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array + { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { + if ($this->pretending()) { + return []; + } + + // For select statements, we'll simply execute the query and return an array + // of the database result set. Each element in the array will be a single + // row from the database table, and will either be an array or objects. + $statement = $this->prepared( + $this->getPdoForSelect($useReadPdo)->prepare($query) + ); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + return $statement->fetchAll(...$fetchUsing); + }); + } + + /** + * Run a select statement against the database and return all of the result sets. + */ + public function selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array + { + return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) { + if ($this->pretending()) { + return []; + } + + $statement = $this->prepared( + $this->getPdoForSelect($useReadPdo)->prepare($query) + ); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + $sets = []; + + do { + $sets[] = $statement->fetchAll(...$fetchUsing); + } while ($statement->nextRowset()); + + return $sets; + }); + } + + /** + * Run a select statement against the database and return a generator. + * + * @return Generator + */ + public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator + { + $statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) { + if ($this->pretending()) { + return null; + } + + // First we will create a statement for the query. Then, we will set the fetch + // mode and prepare the bindings for the query. Once that's done we will be + // ready to execute the query against the database and return the cursor. + $statement = $this->prepared($this->getPdoForSelect($useReadPdo) + ->prepare($query)); + + $this->bindValues( + $statement, + $this->prepareBindings($bindings) + ); + + // Next, we'll execute the query against the database and return the statement + // so we can return the cursor. The cursor will use a PHP generator to give + // back one row at a time without using a bunch of memory to render them. + $statement->execute(); + + return $statement; + }); + + if ($statement === null) { + return; + } + + if ($fetchUsing !== []) { + // fetchAll() supplies default column and class arguments that setFetchMode() + // demands explicitly, so a mode-only call keeps the same meaning when streamed. + if (count($fetchUsing) === 1) { + $mode = $fetchUsing[0] & ~(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE | PDO::FETCH_CLASSTYPE | PDO::FETCH_PROPS_LATE); + + if ($mode === PDO::FETCH_COLUMN) { + $fetchUsing[] = 0; + } elseif ($mode === PDO::FETCH_CLASS && ($fetchUsing[0] & PDO::FETCH_CLASSTYPE) === 0) { + $fetchUsing[] = stdClass::class; + } + } + + $statement->setFetchMode(...$fetchUsing); + } + + foreach ($statement as $record) { + yield $record; + } + } + + /** + * Configure the PDO prepared statement. + */ + protected function prepared(PDOStatement $statement): PDOStatement + { + $statement->setFetchMode($this->fetchMode); + + $this->event(StatementPrepared::class, fn () => new StatementPrepared($this, $statement)); + + return $statement; + } + + /** + * Get the PDO connection to use for a select query. + */ + protected function getPdoForSelect(bool $useReadPdo = true): PDO + { + return $useReadPdo ? $this->getReadPdo() : $this->getPdo(); + } + + /** + * Execute an SQL statement and return the boolean result. + */ + public function statement(string $query, array $bindings = []): bool + { + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return true; + } + + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + return $statement->execute(); + }); + } + + /** + * Run an SQL statement and get the number of rows affected. + */ + public function affectingStatement(string $query, array $bindings = []): int + { + return $this->run($query, $bindings, function ($query, $bindings) { + if ($this->pretending()) { + return 0; + } + + // For update or delete statements, we want to get the number of rows affected + // by the statement and return that back to the developer. We'll first need + // to execute the statement and then we'll use PDO to fetch the affected. + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $statement->execute(); + + $this->recordsHaveBeenModified( + ($count = $statement->rowCount()) > 0 + ); + + return $count; + }); + } + + /** + * Run a raw, unprepared query against the PDO connection. + */ + public function unprepared(string $query): bool + { + return $this->run($query, [], function ($query) { + if ($this->pretending()) { + return true; + } + + $this->recordsHaveBeenModified( + $change = $this->getPdo()->exec($query) !== false + ); + + return $change; + }); + } + + /** + * Bind values to their parameters in the given statement. + */ + public function bindValues(PDOStatement $statement, array $bindings): void + { + foreach ($bindings as $key => $value) { + $statement->bindValue( + is_string($key) ? $key : $key + 1, + $value, + match (true) { + is_int($value) => PDO::PARAM_INT, + is_resource($value) => PDO::PARAM_LOB, + default => PDO::PARAM_STR + }, + ); + } + } + + /** + * Escape a string value for safe SQL embedding. + */ + protected function escapeString(string $value): string + { + $pdo = $this->latestReadWriteTypeUsed() === 'write' + ? $this->getPdo() + : $this->getReadPdo(); + + $escaped = $pdo->quote($value); + + if ($escaped === false) { + throw new RuntimeException('The database connection could not escape the given value.'); + } + + return $escaped; + } + + /** + * Get the last inserted ID. + */ + public function getLastInsertId(?string $sequence = null): int|string + { + return $this->getLastInsertIdFrom($this->getPdo(), $sequence); + } + + /** + * Get the last inserted ID from the given PDO connection. + */ + protected function getLastInsertIdFrom(PDO $pdo, ?string $sequence = null): int|string + { + $lastInsertId = $pdo->lastInsertId($sequence); + + if ($lastInsertId === false) { + throw new RuntimeException('The database driver could not retrieve the last insert ID.'); + } + + return $lastInsertId; + } + + /** + * Get the current synchronized PDO connection. + */ + public function getPdo(): PDO + { + $this->latestReadWriteTypeRetrieved = 'write'; + $pdo = $this->resolvePdo(); + + return static::$sessionConfigurators === [] + ? $pdo + : $this->synchronizeSession($pdo, read: false); + } + + /** + * Get the current PDO parameter without resolving, reconnecting, or synchronizing session state. + */ + public function getRawPdo(): PDO|Closure|null + { + return $this->pdo; + } + + /** + * Get the current synchronized PDO connection used for reading. + */ + public function getReadPdo(): PDO + { + if ($this->transactions > 0) { + return $this->getPdo(); + } + + if ($this->readOnWriteConnection + || ($this->recordsModified && $this->getConfig('sticky'))) { + return $this->getPdo(); + } + + $this->latestReadWriteTypeRetrieved = 'read'; + $pdo = $this->resolveReadPdo(); + + return static::$sessionConfigurators === [] + ? $pdo + : $this->synchronizeSession($pdo, read: true); + } + + /** + * Get the current read PDO parameter without resolving, reconnecting, or synchronizing session state. + */ + public function getRawReadPdo(): PDO|Closure|null + { + return $this->readPdo; + } + + /** + * Resolve the current write PDO without synchronizing session state. + */ + protected function resolvePdo(): PDO + { + if ($this->pdo instanceof Closure) { + return $this->pdo = call_user_func($this->pdo); + } + + return $this->pdo; + } + + /** + * Resolve the current read PDO without synchronizing session state. + */ + protected function resolveReadPdo(): PDO + { + if ($this->readPdo instanceof Closure) { + return $this->readPdo = call_user_func($this->readPdo); + } + + if ($this->readPdo instanceof PDO) { + return $this->readPdo; + } + + $this->latestReadWriteTypeRetrieved = 'write'; + + return $this->resolvePdo(); + } + + /** + * Synchronize the desired state for a physical database session. + */ + protected function synchronizeSession(PDO $pdo, bool $read): PDO + { + $sessionState = static::physicalSessionState($pdo); + + if ($sessionState->configuring) { + $this->markSessionStateUnknown($pdo); + + throw new RuntimeException('Reentrant database session configuration is not allowed.'); + } + + if ($sessionState->unknown) { + $sessionState->configuring = true; + + try { + $pdo = $this->replaceUnknownSession($read); + } finally { + $sessionState->configuring = false; + } + + $sessionState = static::physicalSessionState($pdo); + + if ($sessionState->configuring) { + $this->markSessionStateUnknown($pdo); + + throw new RuntimeException('Reentrant database session configuration is not allowed.'); + } + } + + $sessionState->configuring = true; + + try { + foreach (static::$sessionConfigurators as $index => $configurator) { + $desiredState = $configurator->state($this); + + if ($desiredState === null + || ($sessionState->appliedStates[$index] ?? null) === $desiredState) { + continue; + } + + try { + $configurator->apply($pdo, $desiredState, $this); + + if ($sessionState->unknown) { + throw new RuntimeException('Database session state became unknown during configuration.'); + } + } catch (Throwable $exception) { + $sessionState->appliedStates = []; + $sessionState->unknown = true; + + throw $exception; + } + + $sessionState->appliedStates[$index] = $desiredState; + } + + if ($sessionState->unknown) { + throw new RuntimeException('Database session state became unknown during configuration.'); + } + } finally { + $sessionState->configuring = false; + } + + return $pdo; + } + + /** + * Replace a physical session whose state can no longer be trusted. + */ + protected function replaceUnknownSession(bool $read): PDO + { + if ($this->transactions > 0) { + throw new RuntimeException('Database session state is unknown within an active transaction.'); + } + + $this->reconnect(); + + $replacement = $read + ? $this->resolveReadPdo() + : $this->resolvePdo(); + + if (static::sessionStateIsUnknown($replacement)) { + throw new RuntimeException('Database session state remains unknown after reconnecting.'); + } + + return $replacement; + } + + /** + * Get the state holder for a physical database session. + */ + protected static function physicalSessionState(PDO $pdo): PhysicalSessionState + { + $states = static::$physicalSessionStates ??= new WeakMap; + + return $states[$pdo] ??= new PhysicalSessionState; + } + + /** + * Determine whether a physical database session has unknown state. + */ + protected static function sessionStateIsUnknown(PDO $pdo): bool + { + return static::$physicalSessionStates !== null + && isset(static::$physicalSessionStates[$pdo]) + && static::$physicalSessionStates[$pdo]->unknown; + } + + /** + * Invalidate the states remembered for a physical database session. + */ + protected function invalidateSessionState(PDO $pdo): void + { + if (static::$physicalSessionStates !== null + && isset(static::$physicalSessionStates[$pdo])) { + static::$physicalSessionStates[$pdo]->appliedStates = []; + } + } + + /** + * Mark a physical database session's state as unknown. + */ + protected function markSessionStateUnknown(PDO $pdo): void + { + $sessionState = static::physicalSessionState($pdo); + $sessionState->appliedStates = []; + $sessionState->unknown = true; + } + + /** + * Invalidate the state remembered for the current physical session. + */ + protected function invalidateCurrentSessionState(): void + { + $this->invalidateSessionState($this->resolvePdo()); + } + + /** + * Mark the current write session's state as unknown. + * + * @internal + */ + public function markCurrentSessionStateUnknown(): void + { + $pdo = $this->getRawPdo(); + + if (! $pdo instanceof PDO) { + // Cleanup must not resolve a lazy connection merely to invalidate a session that does not yet exist. + return; + } + + $this->markSessionStateUnknown($pdo); + } + + /** + * Execute an internal physical-session statement. + * + * @internal + */ + public function executeSessionStatement(string $sql): void + { + $pdo = $this->getPdo(); + + try { + if ($pdo->exec($sql) === false) { + throw new RuntimeException("Failed to execute schema statement [{$sql}]."); + } + } catch (Throwable $exception) { + $this->markSessionStateUnknown($pdo); + + throw $exception; + } + } + + /** + * Determine whether an open PDO has unknown session state. + */ + private function hasUnknownSessionState(): bool + { + if (static::$physicalSessionStates === null) { + return false; + } + + $writePdo = $this->getRawPdo(); + + if ($writePdo instanceof PDO + && static::sessionStateIsUnknown($writePdo)) { + return true; + } + + $readPdo = $this->getRawReadPdo(); + + return $readPdo instanceof PDO + && static::sessionStateIsUnknown($readPdo); + } + + /** + * Determine whether the connection may be reused. + * + * @internal + */ + public function isReusable(): bool + { + return ! $this->hasUnknownSessionState(); + } + + /** + * Determine whether the connection has driver resources. + */ + protected function hasDriverResources(): bool + { + return $this->pdo instanceof PDO || $this->pdo instanceof Closure; + } + + /** + * Set the PDO connection. + */ + public function setPdo(PDO|Closure|null $pdo): static + { + $this->transactions = 0; + + $this->pdo = $pdo; + + return $this; + } + + /** + * Set the PDO connection used for reading. + */ + public function setReadPdo(PDO|Closure|null $pdo): static + { + $this->readPdo = $pdo; + + return $this; + } + + /** + * Set the read PDO connection configuration. + */ + public function setReadPdoConfig(array $config): static + { + $this->readConnectionConfig = $config; + + return $this; + } + + /** + * Forget the driver resources without performing physical cleanup. + */ + protected function forgetDriverResources(): void + { + $this->setPdo(null)->setReadPdo(null); + } + + /** + * Disconnect the driver resources. + */ + protected function disconnectDriverResources(): void + { + $pdo = $this->getRawPdo(); + $exception = null; + + try { + if ($pdo instanceof PDO && $pdo->inTransaction()) { + $pdo->rollBack(); + $this->invalidateSessionState($pdo); + } + } catch (Throwable $throwable) { + $this->markSessionStateUnknown($pdo); + + if (! $this->causedByLostConnection($throwable)) { + $exception = $throwable; + } + } finally { + $this->forgetDriverResources(); + } + + if ($exception !== null) { + throw $exception; + } + } + + /** + * Refresh the PDO resources from a fresh connection. + */ + protected function replaceDriverResources(Connection $fresh): void + { + /** @var self $fresh */ + $fresh->getPdo(); + $fresh->getReadPdo(); + + $pdo = $fresh->getRawPdo(); + $readPdo = $fresh->getRawReadPdo(); + $database = $fresh->database; + $configuredDatabase = $fresh->configuredDatabase; + $tablePrefix = $fresh->tablePrefix; + $configuredTablePrefix = $fresh->configuredTablePrefix; + $config = $fresh->config; + $readConnectionConfig = $fresh->readConnectionConfig; + $readWriteType = $fresh->readWriteType; + + // Keep the current generation intact until both replacement handles + // are ready so a failed refresh cannot leave a partial connection. + try { + $this->disconnect(); + } finally { + // Disconnect always forgets the old handles, even when cleanup throws. + $this->setPdo($pdo)->setReadPdo($readPdo); + $this->database = $database; + $this->configuredDatabase = $configuredDatabase; + $this->tablePrefix = $tablePrefix; + $this->configuredTablePrefix = $configuredTablePrefix; + $this->config = $config; + $this->readConnectionConfig = $readConnectionConfig; + $this->readWriteType = $readWriteType; + $this->latestReadWriteTypeRetrieved = null; + } + } + + /** + * Determine whether the already-open PDO resources are responsive. + * + * @internal + */ + public function ping(): bool + { + // Known session configuration is memoized by physical PDO across clean + // releases. Pool maintenance must remain session-state-neutral. + $writePdo = $this->getRawPdo(); + $readPdo = $this->getRawReadPdo(); + $pdos = []; + + if ($writePdo instanceof PDO) { + $pdos[] = $writePdo; + } + + if ($readPdo instanceof PDO && $readPdo !== $writePdo) { + $pdos[] = $readPdo; + } + + try { + foreach ($pdos as $pdo) { + $statement = $pdo->query('SELECT 1'); + + if ($statement === false) { + return false; + } + + $statement->closeCursor(); + } + + return true; + } catch (Throwable $exception) { + if ($exception instanceof CanceledException) { + throw $exception; + } + + return false; + } + } + + /** + * Determine whether the connection has an active physical transaction. + */ + public function inTransaction(): bool + { + return $this->pdo instanceof PDO && $this->pdo->inTransaction(); + } + + /** + * Run the statement to start a new transaction. + */ + protected function executeBeginTransactionStatement(): void + { + $this->getPdo()->beginTransaction(); + } + + /** + * Create a save point within the database. + * + * @throws Throwable + */ + protected function createSavepoint(): void + { + $this->resolvePdo()->exec( + $this->queryGrammar->compileSavepoint('trans' . ($this->transactions + 1)) + ); + } + + /** + * Commit the active physical transaction. + */ + protected function performCommit(): void + { + $pdo = $this->resolvePdo(); + + try { + $pdo->commit(); + } catch (Throwable $exception) { + $this->invalidateSessionState($pdo); + + if (! $this->causedByLostConnection($exception) + && ! $this->causedByConcurrencyError($exception)) { + $this->markSessionStateUnknown($pdo); + } + + throw $exception; + } + } + + /** + * Perform a rollback within the database. + * + * @throws Throwable + */ + protected function performRollBack(int $toLevel): void + { + $pdo = $this->resolvePdo(); + + try { + if ($toLevel === 0) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + } elseif ($this->queryGrammar->supportsSavepoints()) { + $pdo->exec( + $this->queryGrammar->compileSavepointRollBack('trans' . ($toLevel + 1)) + ); + } + } catch (Throwable $exception) { + if (! $this->causedByLostConnection($exception)) { + $this->markSessionStateUnknown($pdo); + } + + throw $exception; + } finally { + $this->invalidateSessionState($pdo); + } + } + + /** + * Get the server version for the connection. + */ + public function getServerVersion(): string + { + return $this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + } + + /** + * Register a database session configurator. + * + * Boot-only. The configurator persists in a static property for the worker + * lifetime and runs on every subsequent synchronized PDO hand-out across all + * coroutines. + */ + public static function configureSessionUsing(SessionConfigurator $configurator): void + { + static::$sessionConfigurators[] = $configurator; + } + + /** + * Flush all static state. + */ + public static function flushState(): void + { + parent::flushState(); + + static::$sessionConfigurators = []; + static::$physicalSessionStates = null; + } +} diff --git a/src/database/src/Pool/DbPool.php b/src/database/src/Pool/DbPool.php index 811719a1d4..4ed4935877 100644 --- a/src/database/src/Pool/DbPool.php +++ b/src/database/src/Pool/DbPool.php @@ -116,37 +116,23 @@ protected function createConnection(): ConnectionInterface } /** - * Apply the pool connection deadline through the native driver setting. + * Expose the pool connection deadline to the database driver. */ private function configureConnectTimeout(): void { - $connectTimeout = (int) ceil($this->option->getConnectTimeout()); - $driver = $this->config['driver'] ?? null; - - if (in_array($driver, ['mysql', 'mariadb'], true)) { - /** @var array $options */ - $options = $this->config['options'] ?? []; - - if (! array_key_exists(PDO::ATTR_TIMEOUT, $options)) { - $options[PDO::ATTR_TIMEOUT] = $connectTimeout; - $this->config['options'] = $options; - } - } elseif ($driver === 'pgsql' && ! array_key_exists('connect_timeout', $this->config)) { - $this->config['connect_timeout'] = $connectTimeout; - } + $this->config['connect_timeout'] ??= $this->option->getConnectTimeout(); } /** * Create the shared PDO for in-memory SQLite via the factory. * - * Uses the normal factory pipeline to get all config parsing, driver - * extensions, and connection setup. We then extract the PDO and let - * the Connection object be garbage collected. + * Uses the normal PDO resolver pipeline so initial construction and + * refresh produce the same connection subclass. */ protected function createSharedInMemorySqlitePdo(): PDO { $factory = $this->container->make('db.factory'); - $connection = $factory->make($this->config, $this->name); + $connection = $factory->makeSharedInMemorySqliteConnection($this->config, $this->name); return $connection->getPdo(); } diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index e46356e577..adfd8215ac 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -16,7 +16,6 @@ use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Pool\Events\ReleaseConnection; use Hypervel\Pool\PoolOption; -use PDO; use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\CanceledException; @@ -118,11 +117,11 @@ public function reconnect(): bool $this->config['name'] ?? null ); } else { - // Normal path: factory creates fresh connection with new PDO + // Normal path: factory creates a fresh connection with new driver resources. $this->connection = $this->factory->make($this->config, $this->config['name'] ?? null); } - if ($this->connection->hasUnknownSessionState()) { + if (! $this->connection->isReusable()) { $this->markInvalid(); if ($sharedPdo !== null) { @@ -131,7 +130,7 @@ public function reconnect(): bool ); } - throw new RuntimeException('Database session state remains unknown after reconnecting.'); + throw new RuntimeException('Database connection is not reusable after reconnecting.'); } // Configure event dispatcher for query events @@ -212,7 +211,7 @@ public function isIdleExpired(?float $now = null): bool } /** - * Ping already-open PDO connections. + * Ping the underlying database connection. */ public function ping(float $timeout): bool { @@ -220,22 +219,20 @@ public function ping(float $timeout): bool return false; } - // Known session configuration is memoized by physical PDO across clean - // releases. Pool maintenance must remain session-state-neutral. - $pdos = $this->getOpenPdos(); - - if ($pdos === []) { - return true; - } - $result = new Channel(1); + $connection = $this->connection; try { - $started = go(static function () use ($pdos, $result): void { + $started = go(static function () use ($connection, $result): void { try { - $result->push(self::pingPdos($pdos), 0.0); + $healthy = $connection->ping(); } catch (CanceledException) { + return; + } catch (Throwable) { + $healthy = false; } + + $result->push($healthy, 0.0); }); } catch (CoroutineCreateException) { return false; @@ -305,8 +302,8 @@ public function release(): void // Mark as stale so it will be recreated $this->markInvalid(); } finally { - if ($this->connection?->hasUnknownSessionState()) { - $this->logger->warning('Database session state is unknown, marking connection as stale.'); + if ($this->connection !== null && ! $this->connection->isReusable()) { + $this->logger->warning('Database connection is not reusable, marking it as stale.'); $this->markInvalid(); } @@ -384,60 +381,6 @@ protected function markValid(): void $this->invalid = false; } - /** - * Get already-open PDO instances. - * - * @return PDO[] - */ - protected function getOpenPdos(): array - { - if (! $this->connection instanceof Connection) { - return []; - } - - $writePdo = $this->connection->getRawPdo(); - $readPdo = $this->connection->getRawReadPdo(); - $pdos = []; - - if ($writePdo instanceof PDO) { - $pdos[] = $writePdo; - } - - if ($readPdo instanceof PDO && $readPdo !== $writePdo) { - $pdos[] = $readPdo; - } - - return $pdos; - } - - /** - * Ping PDO instances. - * - * @param PDO[] $pdos - */ - protected static function pingPdos(array $pdos): bool - { - try { - foreach ($pdos as $pdo) { - $statement = $pdo->query('SELECT 1'); - - if ($statement === false) { - return false; - } - - $statement->closeCursor(); - } - - return true; - } catch (Throwable $exception) { - if ($exception instanceof CanceledException) { - throw $exception; - } - - return false; - } - } - /** * Stamp the current connection generation. */ @@ -451,39 +394,33 @@ private function stampGeneration(float $now): void } /** - * Refresh the PDO connections. + * Refresh the database connection resources. */ protected function refresh(Connection $connection): void { $sharedPdo = $this->pool->getSharedInMemorySqlitePdo(); - if ($sharedPdo !== null) { - // For shared in-memory SQLite, rebind to the same PDO. - // Creating a fresh PDO would give us a new empty database. - // Disconnect first to roll back connection state and resolve manager records. - try { - $connection->disconnect(); - } finally { - $connection->setPdo($sharedPdo); - $connection->setReadPdo($sharedPdo); - } - } else { - try { + try { + if ($sharedPdo !== null) { + // For shared in-memory SQLite, rebind to the same PDO. + // Creating a fresh PDO would give us a new empty database. + $fresh = $this->factory->makeSqliteFromSharedPdo( + $sharedPdo, + $this->config, + $this->config['name'] ?? null + ); + } else { $fresh = $this->factory->make($this->config, $this->config['name'] ?? null); - $writePdo = $fresh->getPdo(); - $readPdo = $fresh->getReadPdo(); - - // Keep the current generation intact until both replacement handles - // are ready so a failed refresh cannot leave a partial connection. - $connection->disconnect(); - $connection->setPdo($writePdo); - $connection->setReadPdo($readPdo); - } catch (Throwable $exception) { - $this->markInvalid(); - - throw $exception; } + $connection->refreshFrom($fresh); + } catch (Throwable $exception) { + $this->markInvalid(); + + throw $exception; + } + + if ($sharedPdo === null) { $this->logger->warning('Database connection refreshed.'); } diff --git a/src/database/src/PostgresConnection.php b/src/database/src/PostgresConnection.php index 14cc95afee..16b9256f6f 100755 --- a/src/database/src/PostgresConnection.php +++ b/src/database/src/PostgresConnection.php @@ -15,7 +15,7 @@ use Override; use PDO; -class PostgresConnection extends Connection +class PostgresConnection extends PdoConnection { /** * Get a human-readable name for the given connection driver. @@ -82,8 +82,8 @@ public function prepareBindings(array $bindings): array protected function isUsingEmulatedPrepares(): bool { $config = $this->latestReadWriteTypeUsed() === 'read' - && $this->readPdoConfig !== [] - ? $this->readPdoConfig + && $this->readConnectionConfig !== [] + ? $this->readConnectionConfig : $this->config; return (bool) ($config['options'][PDO::ATTR_EMULATE_PREPARES] ?? false); diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 530c4dc47c..11194c5c46 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -235,7 +235,7 @@ class Builder implements BuilderContract ]; /** - * Whether to use write pdo for the select. + * Whether to use the write connection for the select. */ public bool $useWritePdo = false; @@ -4158,7 +4158,7 @@ public function getGrammar(): Grammar } /** - * Use the "write" PDO connection when executing the query. + * Use the write connection when executing the query. */ public function useWritePdo(): static { diff --git a/src/database/src/Query/Processors/MySqlProcessor.php b/src/database/src/Query/Processors/MySqlProcessor.php index 18259e83f1..924416533c 100644 --- a/src/database/src/Query/Processors/MySqlProcessor.php +++ b/src/database/src/Query/Processors/MySqlProcessor.php @@ -21,7 +21,6 @@ public function processInsertGetId(Builder $query, string $sql, array $values, ? // @phpstan-ignore arguments.count (MySqlConnection::insert() accepts $sequence param) $query->getConnection()->insert($sql, $values, $sequence); - // @phpstan-ignore method.notFound (MySqlProcessor is only used with MySqlConnection) $id = $query->getConnection()->getLastInsertId(); return is_numeric($id) ? (int) $id : $id; diff --git a/src/database/src/Query/Processors/Processor.php b/src/database/src/Query/Processors/Processor.php index dda2feec83..d6823bad3f 100755 --- a/src/database/src/Query/Processors/Processor.php +++ b/src/database/src/Query/Processors/Processor.php @@ -30,7 +30,7 @@ public function processInsertGetId(Builder $query, string $sql, array $values, ? { $query->getConnection()->insert($sql, $values); - $id = $query->getConnection()->getPdo()->lastInsertId($sequence); + $id = $query->getConnection()->getLastInsertId($sequence); return is_numeric($id) ? (int) $id : $id; } diff --git a/src/database/src/QueryException.php b/src/database/src/QueryException.php index 2b8dcaa8a8..efa5f1afd6 100644 --- a/src/database/src/QueryException.php +++ b/src/database/src/QueryException.php @@ -27,7 +27,7 @@ class QueryException extends PDOException protected array $bindings; /** - * The PDO read / write type for the executed query. + * The connection role used for the executed query. * * @var null|'read'|'write' */ diff --git a/src/database/src/SQLiteConnection.php b/src/database/src/SQLiteConnection.php index ce3a5a66f0..0d1d7353aa 100755 --- a/src/database/src/SQLiteConnection.php +++ b/src/database/src/SQLiteConnection.php @@ -13,7 +13,7 @@ use Hypervel\Filesystem\Filesystem; use Override; -class SQLiteConnection extends Connection +class SQLiteConnection extends PdoConnection { /** * Get a human-readable name for the given connection driver. diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 843ff51a53..265a94e3b2 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -12,7 +12,6 @@ use InvalidArgumentException; use LogicException; use RuntimeException; -use Throwable; class Builder { @@ -637,15 +636,7 @@ protected function setForeignKeyConstraints(bool $enabled): void */ protected function executeSessionStatement(string $statement): void { - try { - if ($this->connection->getPdo()->exec($statement) === false) { - throw new RuntimeException("Failed to execute schema statement [{$statement}]."); - } - } catch (Throwable $exception) { - $this->connection->markCurrentSessionStateUnknown(); - - throw $exception; - } + $this->connection->executeSessionStatement($statement); } /** diff --git a/src/database/src/Schema/SqliteSchemaState.php b/src/database/src/Schema/SqliteSchemaState.php index fe404743a1..dff592ae02 100644 --- a/src/database/src/Schema/SqliteSchemaState.php +++ b/src/database/src/Schema/SqliteSchemaState.php @@ -5,8 +5,10 @@ namespace Hypervel\Database\Schema; use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Collection; +use LogicException; use Override; class SqliteSchemaState extends SchemaState @@ -55,6 +57,10 @@ public function load(string $path): void $database = $this->connection->getDatabaseName(); if (SQLiteDatabase::isInMemory($database)) { + if (! $this->connection instanceof PdoConnection) { + throw new LogicException('In-memory SQLite schema loading requires a PDO-backed connection.'); + } + $this->connection->getPdo()->exec($this->files->get($path)); return; diff --git a/src/database/src/SessionConfigurator.php b/src/database/src/SessionConfigurator.php index 33b9bacb90..3281d7fab4 100644 --- a/src/database/src/SessionConfigurator.php +++ b/src/database/src/SessionConfigurator.php @@ -16,13 +16,13 @@ interface SessionConfigurator * This method runs on every synchronized PDO hand-out and must not execute * database work. */ - public function state(Connection $connection): ?string; + public function state(PdoConnection $connection): ?string; /** * Apply the complete desired state to the physical database session. * - * Use the given PDO directly. Calling Connection query APIs from this + * Use the given PDO directly. Calling PdoConnection query APIs from this * method is reentrant and fails closed. */ - public function apply(PDO $pdo, string $state, Connection $connection): void; + public function apply(PDO $pdo, string $state, PdoConnection $connection): void; } diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index b5d828ea34..96713e1b1e 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -321,7 +321,7 @@ public function castAsJson($value, $connection = null) $database = DB::connection($connection); - $value = $database->getPdo()->quote($value); + $value = $database->escape($value); return $database->raw( $database->getQueryGrammar()->compileJsonValueCast($value) diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index e42d1e0243..fb76fd7f17 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -86,7 +86,7 @@ public static function resetCachedConnections(): void if ($connection instanceof Connection) { $connection->resetForPool(); - if ($connection->hasUnknownSessionState()) { + if (! $connection->isReusable()) { try { static::discardCachedConnection($cacheKey); } catch (Throwable $throwable) { diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index ecc142403c..fbd1aa7848 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -7,11 +7,13 @@ use Hypervel\Contracts\Console\Kernel; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\ConnectionInterface; +use Hypervel\Database\PdoConnection; use Hypervel\Database\SQLiteDatabase; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use LogicException; /** * This concern is mutually exclusive with RefreshDatabase and DatabaseMigrations. @@ -80,7 +82,13 @@ protected function restoreInMemoryDatabases(): void if (isset(RefreshDatabaseState::$inMemoryConnections[$connectionName])) { // The PDO outlives its original application; the dispatcher must not. - $database->connection($name) + $connection = $database->connection($name); + + if (! $connection instanceof PdoConnection) { + throw new LogicException('In-memory SQLite database testing requires a PDO-backed connection.'); + } + + $connection ->setPdo(RefreshDatabaseState::$inMemoryConnections[$connectionName]) ->setEventDispatcher($this->app->make(Dispatcher::class)); } @@ -99,9 +107,13 @@ protected function cacheInMemoryDatabases(): void foreach ($this->connectionsToTruncate() as $name) { if ($this->usingInMemoryDatabaseForTruncation($name)) { $connectionName = $name ?? $defaultConnection; + $connection = $database->connection($name); + + if (! $connection instanceof PdoConnection) { + throw new LogicException('In-memory SQLite database testing requires a PDO-backed connection.'); + } - RefreshDatabaseState::$inMemoryConnections[$connectionName] - = $database->connection($name)->getPdo(); + RefreshDatabaseState::$inMemoryConnections[$connectionName] = $connection->getPdo(); } } } diff --git a/src/foundation/src/Testing/RefreshDatabase.php b/src/foundation/src/Testing/RefreshDatabase.php index d462744e77..3d80f65f3b 100644 --- a/src/foundation/src/Testing/RefreshDatabase.php +++ b/src/foundation/src/Testing/RefreshDatabase.php @@ -7,9 +7,11 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection as DatabaseConnection; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\PdoConnection; use Hypervel\Database\SQLiteDatabase; use Hypervel\Foundation\Testing\Concerns\InteractsWithParallelDatabase; use Hypervel\Foundation\Testing\Traits\CanConfigureMigrationCommands; +use LogicException; trait RefreshDatabase { @@ -65,7 +67,13 @@ protected function restoreInMemoryDatabase(): void $connectionName = $name ?? $this->getRefreshConnection(); if (isset(RefreshDatabaseState::$inMemoryConnections[$connectionName])) { - $database->connection($name) + $connection = $database->connection($name); + + if (! $connection instanceof PdoConnection) { + throw new LogicException('In-memory SQLite database testing requires a PDO-backed connection.'); + } + + $connection ->setPdo(RefreshDatabaseState::$inMemoryConnections[$connectionName]) ->setEventDispatcher($this->app->make(Dispatcher::class)); } @@ -189,6 +197,10 @@ protected function beginDatabaseTransactionWork(): void if ($this->usingInMemoryDatabase($name)) { $connectionName = $name ?? $this->getRefreshConnection(); + if (! $connection instanceof PdoConnection) { + throw new LogicException('In-memory SQLite database testing requires a PDO-backed connection.'); + } + RefreshDatabaseState::$inMemoryConnections[$connectionName] ??= $connection->getPdo(); } @@ -202,13 +214,11 @@ protected function beginDatabaseTransactionWork(): void } } - // Mark the database as migrated only after every connection's PDO - // has been cached. Keeping $migrated and $inMemoryConnections in - // lockstep means a test that skips between refreshTestDatabase() - // and this method (possible under RunTestsInCoroutine, where this - // method is deferred to setUpRefreshDatabaseInCoroutine()) leaves - // the flag false — so the next test's migrate:fresh runs cleanly - // instead of being skipped with no cached PDO to restore. + // Mark the database as migrated only after every connection is ready + // and each in-memory SQLite PDO has been cached. RunTestsInCoroutine + // defers this method, so moving the flag earlier would let a skipped + // setup poison the next test: it would skip migrate:fresh without a + // cached PDO to restore. RefreshDatabaseState::$migrated = true; } @@ -225,7 +235,7 @@ protected function rollbackDatabaseTransactionWork(): void $connection->unsetEventDispatcher(); - if (! $connection->getPdo()->inTransaction()) { + if (! $connection->inTransaction()) { RefreshDatabaseState::$migrated = false; } diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php index 4c32126338..f34a3be5af 100644 --- a/src/queue/src/DatabaseQueue.php +++ b/src/queue/src/DatabaseQueue.php @@ -19,7 +19,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Str; -use PDO; use Throwable; class DatabaseQueue extends Queue implements QueueContract, ClearableQueue @@ -458,8 +457,9 @@ protected function getNextAvailableJob(?string $queue): ?DatabaseJobRecord */ protected function getLockForPopping(): bool|string { - $databaseEngine = $this->getDatabase()->getPdo()->getAttribute(PDO::ATTR_DRIVER_NAME); - $databaseVersion = $this->getDatabase()->getConfig('version') ?? $this->getDatabase()->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); + $connection = $this->getDatabase(); + $databaseEngine = $connection->getDriverName(); + $databaseVersion = $connection->getConfig('version') ?? $connection->getServerVersion(); if (Str::of($databaseVersion)->contains('MariaDB')) { $databaseEngine = 'mariadb'; @@ -477,10 +477,6 @@ protected function getLockForPopping(): bool|string return 'FOR UPDATE SKIP LOCKED'; } - if ($databaseEngine === 'sqlsrv') { - return 'with(rowlock,updlock,readpast)'; - } - return true; } diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 72fd28869c..e33f356788 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -39,8 +39,8 @@ * @method static int affectingStatement(string $query, array $bindings = []) * @method static void afterCommit(callable $callback) * @method static void afterRollBack(callable $callback) - * @method static \Hypervel\Database\Connection beforeExecuting(\Closure $callback) - * @method static \Hypervel\Database\Connection beforeStartingTransaction(\Closure $callback) + * @method static \Hypervel\Database\PdoConnection beforeExecuting(\Closure $callback) + * @method static \Hypervel\Database\PdoConnection beforeStartingTransaction(\Closure $callback) * @method static void beginTransaction() * @method static void bindValues(\PDOStatement $statement, array $bindings) * @method static void clearBeforeExecutingCallbacks() @@ -53,12 +53,13 @@ * @method static string escape(mixed $value, bool $binary = false) * @method static void flushQueryLog() * @method static void forgetRecordModificationState() - * @method static mixed getConfig(string|null $option = null) + * @method static ($option is null ? array : mixed) getConfig(string|null $option = null) * @method static string getDatabaseName() * @method static string getDriverName() * @method static string getDriverTitle() * @method static int getErrorCount() * @method static \Hypervel\Contracts\Events\Dispatcher|null getEventDispatcher() + * @method static string|int getLastInsertId(string|null $sequence = null) * @method static string|null getName() * @method static \PDO getPdo() * @method static \Hypervel\Database\Query\Processors\Processor getPostProcessor() @@ -77,6 +78,7 @@ * @method static \Hypervel\Database\DatabaseTransactionsManager|null getTransactionManager() * @method static bool hasModifiedRecords() * @method static bool insert(string $query, array $bindings = []) + * @method static bool inTransaction() * @method static void listen(\Closure $callback) * @method static bool logging() * @method static void logQuery(string $query, array $bindings, float|null $time = null) @@ -96,17 +98,17 @@ * @method static array selectFromWriteConnection(string $query, array $bindings = []) * @method static mixed selectOne(string $query, array $bindings = [], bool $useReadPdo = true) * @method static array selectResultSets(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []) - * @method static \Hypervel\Database\Connection setDatabaseName(string $database) - * @method static \Hypervel\Database\Connection setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events) - * @method static \Hypervel\Database\Connection setPdo(\PDO|\Closure|null $pdo) - * @method static \Hypervel\Database\Connection setPostProcessor(\Hypervel\Database\Query\Processors\Processor $processor) - * @method static \Hypervel\Database\Connection setQueryGrammar(\Hypervel\Database\Query\Grammars\Grammar $grammar) - * @method static \Hypervel\Database\Connection setReadPdo(\PDO|\Closure|null $pdo) - * @method static \Hypervel\Database\Connection setReadPdoConfig(array $config) - * @method static \Hypervel\Database\Connection setRecordModificationState(bool $value) - * @method static \Hypervel\Database\Connection setSchemaGrammar(\Hypervel\Database\Schema\Grammars\Grammar $grammar) - * @method static \Hypervel\Database\Connection setTablePrefix(string $prefix) - * @method static \Hypervel\Database\Connection setTransactionManager(\Hypervel\Database\DatabaseTransactionsManager $manager) + * @method static \Hypervel\Database\PdoConnection setDatabaseName(string $database) + * @method static \Hypervel\Database\PdoConnection setEventDispatcher(\Hypervel\Contracts\Events\Dispatcher $events) + * @method static \Hypervel\Database\PdoConnection setPdo(\PDO|\Closure|null $pdo) + * @method static \Hypervel\Database\PdoConnection setPostProcessor(\Hypervel\Database\Query\Processors\Processor $processor) + * @method static \Hypervel\Database\PdoConnection setQueryGrammar(\Hypervel\Database\Query\Grammars\Grammar $grammar) + * @method static \Hypervel\Database\PdoConnection setReadPdo(\PDO|\Closure|null $pdo) + * @method static \Hypervel\Database\PdoConnection setReadPdoConfig(array $config) + * @method static \Hypervel\Database\PdoConnection setRecordModificationState(bool $value) + * @method static \Hypervel\Database\PdoConnection setSchemaGrammar(\Hypervel\Database\Schema\Grammars\Grammar $grammar) + * @method static \Hypervel\Database\PdoConnection setTablePrefix(string $prefix) + * @method static \Hypervel\Database\PdoConnection setTransactionManager(\Hypervel\Database\DatabaseTransactionsManager $manager) * @method static bool statement(string $query, array $bindings = []) * @method static \Hypervel\Database\Query\Builder table(\Closure|\Hypervel\Database\Query\Builder|\UnitEnum|string $table, string|null $as = null) * @method static int|null threadCount() @@ -119,7 +121,7 @@ * @method static void useDefaultPostProcessor() * @method static void useDefaultQueryGrammar() * @method static void useDefaultSchemaGrammar() - * @method static \Hypervel\Database\Connection useWriteConnectionWhenReading(bool $value = true) + * @method static \Hypervel\Database\PdoConnection useWriteConnectionWhenReading(bool $value = true) * @method static mixed withoutPretending(\Closure $callback) * @method static mixed withoutTablePrefix(\Closure $callback) * diff --git a/src/telescope/src/Watchers/QueryWatcher.php b/src/telescope/src/Watchers/QueryWatcher.php index 5cfa3e50b0..df1e141b86 100644 --- a/src/telescope/src/Watchers/QueryWatcher.php +++ b/src/telescope/src/Watchers/QueryWatcher.php @@ -9,8 +9,7 @@ use Hypervel\Database\Events\QueryExecuted; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; -use PDO; -use PDOException; +use RuntimeException; class QueryWatcher extends Watcher { @@ -83,9 +82,10 @@ public function replaceBindings(QueryExecuted $event): string $sql = $event->sql; foreach ($this->formatBindings($event) as $key => $binding) { - $regex = is_numeric($key) + $isPositional = is_numeric($key); + $regex = $isPositional ? "/\\?(?=(?:[^'\\\\']*'[^'\\\\']*')*[^'\\\\']*$)/" - : "/:{$key}(?=(?:[^'\\\\']*'[^'\\\\']*')*[^'\\\\']*$)/"; + : '/:' . preg_quote((string) $key, '/') . "(?![A-Za-z0-9_])(?=(?:[^'\\\\']*'[^'\\\\']*')*[^'\\\\']*$)/"; if ($binding === null) { $binding = 'null'; @@ -93,11 +93,11 @@ public function replaceBindings(QueryExecuted $event): string $binding = $this->quoteStringBinding($event, $binding); } - $sql = preg_replace( + $sql = preg_replace_callback( $regex, - (string) $binding, + static fn (): string => (string) $binding, $sql, - is_numeric($key) ? 1 : -1 + $isPositional ? 1 : -1 ); } @@ -110,24 +110,9 @@ public function replaceBindings(QueryExecuted $event): string protected function quoteStringBinding(QueryExecuted $event, string $binding): string { try { - $pdo = $event->connection->getPdo(); - - if ($pdo instanceof PDO) { // @phpstan-ignore instanceof.alwaysTrue (fallback exists for edge cases) - return $pdo->quote($binding); - } - } catch (PDOException $e) { - throw_if($e->getCode() !== 'IM001', $e); + return $event->connection->escape($binding); + } catch (RuntimeException) { + return '[REDACTED: UNESCAPABLE BINDING]'; } - - // Fallback when PDO::quote function is missing... - $binding = \strtr($binding, [ - chr(26) => '\Z', - chr(8) => '\b', - '"' => '\"', - "'" => "\\'", - '\\' => '\\\\', - ]); - - return "'" . $binding . "'"; } } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 3e30770c6c..42fa5fe762 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -154,7 +154,7 @@ protected function flushFrameworkState(): void \Hypervel\Coroutine\Locker::flushState(); \Hypervel\Coroutine\Mutex::flushState(); \Hypervel\Database\Capsule\Manager::flushState(); - \Hypervel\Database\Connection::flushState(); + \Hypervel\Database\PdoConnection::flushState(); \Hypervel\Database\Console\DumpCommand::flushState(); \Hypervel\Database\Console\Migrations\FreshCommand::flushState(); \Hypervel\Database\Console\Migrations\RefreshCommand::flushState(); diff --git a/tests/Coroutine/CoroutineCreateFailureTest.php b/tests/Coroutine/CoroutineCreateFailureTest.php index 9aead76854..4484562f6a 100644 --- a/tests/Coroutine/CoroutineCreateFailureTest.php +++ b/tests/Coroutine/CoroutineCreateFailureTest.php @@ -13,7 +13,7 @@ use Hypervel\Coroutine\Parallel; use Hypervel\Coroutine\WaitConcurrent; use Hypervel\Coroutine\Waiter; -use Hypervel\Database\Connection as DatabaseConnection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Engine\Exceptions\CoroutineCreateException; use Hypervel\Engine\SafeSocket; @@ -197,7 +197,7 @@ public function testConnectionHealthChecksReturnFalseWhenProbeCreationFails(): v (new ReflectionProperty(PooledConnection::class, 'connection')) ->setValue( $database, - new DatabaseConnection(new PDO('sqlite::memory:')), + new PdoConnection(new PDO('sqlite::memory:')), ); $redis = (new ReflectionClass(PhpRedisConnection::class)) diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index 07af605c72..9bc2b551d2 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database; +use Generator; use Hypervel\Container\Container; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Connection; @@ -12,9 +13,11 @@ use Hypervel\Database\SQLiteConnection; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use LogicException; use Mockery as m; use PDO; use ReflectionProperty; +use stdClass; class DatabaseConnectionFactoryTest extends TestCase { @@ -220,13 +223,13 @@ public function testReadWriteConnectionsNotCreatedUntilNeeded() $this->assertNotInstanceOf(PDO::class, $readPdo->getValue($connection)); } - public function testReadWriteConnectionSetsReadPdoConfig() + public function testReadWriteConnectionSetsReadConnectionConfig(): void { $connection = $this->db->getConnection('read_write'); - $readPdoConfig = new ReflectionProperty(get_class($connection), 'readPdoConfig'); + $readConnectionConfig = new ReflectionProperty(get_class($connection), 'readConnectionConfig'); - $config = $readPdoConfig->getValue($connection); + $config = $readConnectionConfig->getValue($connection); $this->assertNotEmpty($config); $this->assertArrayHasKey('database', $config); @@ -401,4 +404,213 @@ public function testExtensionCallbackReceivesConfigAndName() $this->assertArrayHasKey('prefix', $receivedConfig); $this->assertSame('my-conn', $receivedConfig['name']); } + + public function testConfigFirstExtensionCreatesNeutralConnectionWithoutResolvingAConnector(): void + { + $container = m::mock(Container::class); + $factory = new ConnectionFactory($container); + $receivedConfig = null; + + $factory->extend('http', function (array $config, ?string $name) use (&$receivedConfig): FactoryNonPdoConnection { + $receivedConfig = $config; + + return new FactoryNonPdoConnection( + $config['database'] ?? '', + $config['prefix'], + $config, + ); + }); + + $result = $factory->make([ + 'driver' => 'http', + 'endpoint' => 'https://database.test', + ], 'analytics'); + + $this->assertInstanceOf(FactoryNonPdoConnection::class, $result); + $this->assertSame('analytics', $result->getName()); + $this->assertSame('https://database.test', $result->getConfig('endpoint')); + $this->assertSame('analytics', $receivedConfig['name']); + $this->assertSame('http', $receivedConfig['driver']); + $this->assertSame('https://database.test', $receivedConfig['endpoint']); + $this->assertSame('', $receivedConfig['prefix']); + } + + public function testConnectionExtensionMustReturnANeutralConnection(): void + { + $factory = new ConnectionFactory(new Container); + $factory->extend('http', static fn (): object => new stdClass); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Database connection extensions must return a Connection instance.'); + + $factory->make(['driver' => 'http'], 'analytics'); + } + + public function testSharedInMemorySqliteUsesTheSamePdoSubclassAndWriteConfigForEveryGeneration(): void + { + $factory = new ConnectionFactory(new Container); + $resolvedConfigs = []; + Connection::resolverFor('sqlite', function ($connection, $database, $prefix, $config) use (&$resolvedConfigs) { + $resolvedConfigs[] = $config; + + return new FactorySqliteConnection($connection, $database, $prefix, $config); + }); + $config = [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'read' => ['database' => 'ignored.sqlite'], + 'write' => ['database' => ':memory:'], + ]; + + try { + $initial = $factory->makeSharedInMemorySqliteConnection($config, 'memory'); + $pdo = $initial->getPdo(); + $replacement = $factory->makeSqliteFromSharedPdo($pdo, $config, 'memory'); + + $this->assertInstanceOf(FactorySqliteConnection::class, $initial); + $this->assertInstanceOf(FactorySqliteConnection::class, $replacement); + $this->assertSame($pdo, $replacement->getPdo()); + $this->assertCount(2, $resolvedConfigs); + $this->assertSame($resolvedConfigs[0], $resolvedConfigs[1]); + $this->assertArrayNotHasKey('read', $resolvedConfigs[0]); + $this->assertArrayNotHasKey('write', $resolvedConfigs[0]); + + $initial->refreshFrom($replacement); + + $this->assertSame($pdo, $initial->getPdo()); + } finally { + Connection::flushState(); + } + } + + public function testSharedInMemorySqliteRejectsConfigFirstExtensionsBeforeConnectionCreation(): void + { + $factory = new ConnectionFactory(new Container); + $factory->extend('sqlite', static fn (): FactoryNonPdoConnection => new FactoryNonPdoConnection); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + "Pooled in-memory SQLite connections cannot use config-first extensions. Use Connection::resolverFor('sqlite', ...) to register a PDO connection subclass." + ); + + $factory->makeSharedInMemorySqliteConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'memory'); + } + + public function testSharedInMemorySqliteRejectsNameSpecificExtensionsBeforeConnectionCreation(): void + { + $factory = new ConnectionFactory(new Container); + $factory->extend('memory', static fn (): FactoryNonPdoConnection => new FactoryNonPdoConnection); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + "Pooled in-memory SQLite connections cannot use config-first extensions. Use Connection::resolverFor('sqlite', ...) to register a PDO connection subclass." + ); + + $factory->makeSharedInMemorySqliteConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'memory'); + } + + public function testSqlitePdoResolverMustReturnAPdoConnection(): void + { + $factory = new ConnectionFactory(new Container); + Connection::resolverFor('sqlite', static fn (): FactoryNonPdoConnection => new FactoryNonPdoConnection); + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('PDO connection resolvers must return a PdoConnection instance.'); + + $factory->makeSharedInMemorySqliteConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'memory'); + } finally { + Connection::flushState(); + } + } +} + +class FactorySqliteConnection extends SQLiteConnection +{ +} + +class FactoryNonPdoConnection extends Connection +{ + protected bool $driverResourcesPresent = true; + + public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array + { + return []; + } + + public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator + { + yield from []; + } + + public function statement(string $query, array $bindings = []): bool + { + return true; + } + + public function affectingStatement(string $query, array $bindings = []): int + { + return 0; + } + + public function unprepared(string $query): bool + { + return true; + } + + public function ping(): bool + { + return $this->driverResourcesPresent; + } + + public function inTransaction(): bool + { + return false; + } + + public function getServerVersion(): string + { + return '1.0'; + } + + protected function escapeString(string $value): string + { + return "'{$value}'"; + } + + protected function hasDriverResources(): bool + { + return $this->driverResourcesPresent; + } + + protected function disconnectDriverResources(): void + { + $this->forgetDriverResources(); + } + + protected function forgetDriverResources(): void + { + $this->driverResourcesPresent = false; + } + + protected function replaceDriverResources(Connection $fresh): void + { + /** @var self $fresh */ + $driverResourcesPresent = $fresh->driverResourcesPresent; + + try { + $this->disconnectDriverResources(); + } finally { + $this->driverResourcesPresent = $driverResourcesPresent; + } + } } diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index d29d67a3bb..94bc03a503 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -7,6 +7,7 @@ use DateTime; use ErrorException; use Exception; +use Generator; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseTransactionsManager; @@ -17,23 +18,20 @@ use Hypervel\Database\Events\TransactionCommitting; use Hypervel\Database\Events\TransactionRolledBack; use Hypervel\Database\MultipleColumnsSelectedException; -use Hypervel\Database\MySqlConnection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder as BaseBuilder; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\Grammars\Grammar as SchemaGrammar; -use Hypervel\Database\SessionConfigurator; use Hypervel\Testbench\TestCase; use LogicException; use Mockery as m; use PDO; use PDOException; -use PDOStatement; use ReflectionClass; use RuntimeException; -use stdClass; class DatabaseConnectionTest extends TestCase { @@ -55,6 +53,198 @@ public function testFlushStateClearsResolversAndMacros() } } + public function testNeutralConnectionDoesNotExposePdoResourceMethods(): void + { + $connection = new ReflectionClass(Connection::class); + + foreach (['getPdo', 'getRawPdo', 'getReadPdo', 'getRawReadPdo', 'setPdo', 'setReadPdo'] as $method) { + $this->assertFalse($connection->hasMethod($method)); + } + } + + public function testNeutralConnectionProvidesPreciseUnsupportedCapabilityErrors(): void + { + $connection = new NeutralConnectionForTest( + 'analytics', + '', + ['name' => 'analytics', 'driver' => 'http'] + ); + + foreach ([ + [ + static fn () => $connection->selectResultSets('select 1'), + LogicException::class, + 'Database driver [http] does not support multiple result sets.', + ], + [ + static fn () => $connection->getLastInsertId(), + LogicException::class, + 'Database driver [http] does not support retrieving last insert IDs.', + ], + [ + static fn () => $connection->getSchemaState(), + RuntimeException::class, + 'This database driver does not support schema state.', + ], + [ + static fn () => $connection->executeSessionStatement('set state'), + LogicException::class, + 'Database driver [http] does not support physical session statements.', + ], + [ + static fn () => $connection->beginTransaction(), + LogicException::class, + 'Database driver [http] does not support transactions.', + ], + ] as [$operation, $exceptionClass, $message]) { + $exception = null; + + try { + $operation(); + } catch (LogicException|RuntimeException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf($exceptionClass, $exception); + $this->assertSame($message, $exception->getMessage()); + } + } + + public function testNeutralConnectionOwnsEscapingAndLifecycleHooks(): void + { + $connection = new NeutralConnectionForTest( + 'analytics', + '', + ['name' => 'analytics', 'driver' => 'http'] + ); + + $this->assertSame('HTTP_STRING[value]', $connection->escape('value')); + $this->assertSame('null', $connection->escape(null)); + $this->assertSame('1', $connection->escape(true)); + $this->assertTrue($connection->ping()); + $this->assertTrue($connection->isReusable()); + + $connection->disconnect(); + + $this->assertSame(1, $connection->disconnectCalls); + $this->assertSame(1, $connection->forgetCalls); + $this->assertFalse($connection->driverResourcesPresent); + + $reconnects = 0; + $connection->setReconnector(function (NeutralConnectionForTest $connection) use (&$reconnects): void { + ++$reconnects; + $connection->driverResourcesPresent = true; + }); + $connection->reconnectIfMissingConnection(); + + $this->assertSame(1, $reconnects); + $this->assertTrue($connection->driverResourcesPresent); + } + + public function testNeutralConnectionRefreshesOnlyFromTheSameConfiguredType(): void + { + $connection = new NeutralConnectionForTest( + 'analytics', + 'analytics_', + ['name' => 'analytics', 'driver' => 'http'] + ); + $fresh = new NeutralConnectionForTest( + 'fresh_analytics', + 'fresh_', + ['name' => 'analytics', 'driver' => 'http'] + ); + $fresh->driverGeneration = 'fresh'; + + $connection->refreshFrom($fresh); + + $this->assertSame(1, $connection->replaceCalls); + $this->assertSame(1, $connection->disconnectCalls); + $this->assertSame(1, $connection->forgetCalls); + $this->assertTrue($connection->driverResourcesPresent); + $this->assertSame('fresh', $connection->driverGeneration); + + $connection->setDatabaseName('tenant_analytics'); + $connection->setTablePrefix('tenant_'); + $connection->resetForPool(); + + $this->assertSame('fresh_analytics', $connection->getDatabaseName()); + $this->assertSame('fresh_', $connection->getTablePrefix()); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage( + 'Cannot refresh connection [analytics] of type [' . NeutralConnectionForTest::class + . '] from connection [analytics] of type [' . NeutralTransactionConnectionForTest::class . '].' + ); + + $connection->refreshFrom(new NeutralTransactionConnectionForTest( + 'analytics', + '', + ['name' => 'analytics', 'driver' => 'http'] + )); + } + + public function testNeutralPoolResetRestoresConfiguredMetadataAndRouting(): void + { + $connection = new NeutralConnectionForTest( + 'derived_analytics', + 'derived_', + ['name' => 'analytics', 'driver' => 'http'] + ); + $connection->setDatabaseName('tenant_analytics'); + $connection->setTablePrefix('tenant_'); + $connection->setLatestReadWriteTypeForTest('write'); + + $connection->resetForPool(); + + $this->assertSame('derived_analytics', $connection->getDatabaseName()); + $this->assertSame('derived_', $connection->getTablePrefix()); + $this->assertNull($connection->latestReadWriteTypeForTest()); + + $writeConnection = new NeutralConnectionForTest( + 'analytics', + '', + [ + 'name' => 'analytics', + 'driver' => 'http', + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'write', + ] + ); + $writeConnection->setLatestReadWriteTypeForTest('read'); + + $writeConnection->resetForPool(); + + $this->assertSame('write', $writeConnection->latestReadWriteTypeForTest()); + } + + public function testNeutralNestedConcurrencyFailureInvalidatesOnceWithoutRollingBackTheDriver(): void + { + $connection = new NeutralTransactionConnectionForTest( + 'analytics', + '', + ['name' => 'analytics', 'driver' => 'http'] + ); + $connection->beginTransaction(); + $failure = new QueryException( + 'analytics', + '', + [], + new RuntimeException('Deadlock found when trying to get lock') + ); + + try { + $connection->transaction(static fn () => throw $failure); + $this->fail('Expected the nested transaction to deadlock.'); + } catch (DeadlockException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertSame(1, $connection->invalidateCalls); + $this->assertSame(0, $connection->rollBackCalls); + $this->assertSame(1, $connection->transactionLevel()); + + $connection->rollBack(); + } + public function testSettingDefaultCallsGetDefaultGrammar() { $connection = $this->getMockConnection(['getDefaultQueryGrammar']); @@ -102,216 +292,6 @@ public function testScalarReturnsNullIfUnderlyingSelectReturnsNoRows() $this->assertNull($connection->scalar('select foo from tbl where 0=1')); } - public function testSelectProperlyCallsPDO() - { - $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $writePdo->expects($this->never())->method('prepare'); - $statement = $this->getMockBuilder('PDOStatement') - ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue']) - ->getMock(); - $statement->expects($this->once())->method('setFetchMode'); - $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); - $statement->expects($this->once())->method('execute'); - $statement->expects($this->once())->method('fetchAll')->willReturn(['boom']); - $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); - $mock = $this->getMockConnection(['prepareBindings'], $writePdo); - $mock->setReadPdo($pdo); - $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); - $results = $mock->select('foo', ['foo' => 'bar']); - $this->assertEquals(['boom'], $results); - $log = $mock->getQueryLog(); - $this->assertSame('foo', $log[0]['query']); - $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); - $this->assertIsNumeric($log[0]['time']); - } - - public function testSelectResultsetsReturnsMultipleRowset(): void - { - $configurator = new StatementPathSessionConfigurator; - Connection::configureSessionUsing($configurator); - $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $writePdo->expects($this->never())->method('prepare'); - $statement = $this->getMockBuilder('PDOStatement') - ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue', 'nextRowset']) - ->getMock(); - $statement->expects($this->once())->method('setFetchMode'); - $statement->expects($this->once())->method('bindValue')->with(1, 'foo', 2); - $statement->expects($this->once())->method('execute'); - $statement->expects($this->atLeastOnce())->method('fetchAll')->with(PDO::FETCH_COLUMN, 1)->willReturn(['boom']); - $statement->expects($this->atLeastOnce())->method('nextRowset')->willReturnCallback(function () { - static $i = 1; - - return ++$i <= 2; - }); - $pdo->expects($this->once())->method('prepare')->with('CALL a_procedure(?)')->willReturn($statement); - $mock = $this->getMockConnection(['prepareBindings'], $writePdo); - $mock->setReadPdo($pdo); - $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo']))->willReturn(['foo']); - $results = $mock->selectResultsets('CALL a_procedure(?)', ['foo'], true, [PDO::FETCH_COLUMN, 1]); - $this->assertEquals([['boom'], ['boom']], $results); - $log = $mock->getQueryLog(); - $this->assertSame('CALL a_procedure(?)', $log[0]['query']); - $this->assertEquals(['foo'], $log[0]['bindings']); - $this->assertIsNumeric($log[0]['time']); - $this->assertSame(1, $configurator->stateCalls); - $this->assertSame(1, $configurator->applyCalls); - } - - public function testEveryOrdinaryConnectionStatementClosureSynchronizesItsPdo(): void - { - $configurator = new StatementPathSessionConfigurator; - Connection::configureSessionUsing($configurator); - $connection = new Connection( - new PDO('sqlite::memory:'), - ':memory:', - '', - ['name' => 'test', 'driver' => 'sqlite'] - ); - - $operations = [ - static fn () => $connection->select('select 1'), - static fn () => iterator_to_array($connection->cursor('select 1')), - static fn () => $connection->statement('create table records (id integer primary key)'), - static fn () => $connection->affectingStatement('insert into records (id) values (1)'), - static fn () => $connection->unprepared('delete from records'), - ]; - - foreach ($operations as $index => $operation) { - $configurator->desiredState = 'state-' . $index; - $operation(); - $this->assertSame($index + 1, $configurator->applyCalls); - } - - $this->assertSame(count($operations), $configurator->stateCalls); - } - - public function testPretendModeDoesNotResolveOrSynchronizePdo(): void - { - $configurator = new StatementPathSessionConfigurator; - Connection::configureSessionUsing($configurator); - $resolutions = 0; - $connection = new Connection( - static function () use (&$resolutions): PDO { - ++$resolutions; - - return new PDO('sqlite::memory:'); - }, - ':memory:', - '', - ['name' => 'test', 'driver' => 'sqlite'] - ); - - $cursorRows = null; - $queries = $connection->pretend(static function (Connection $connection) use (&$cursorRows): void { - $connection->select('select 1'); - $cursorRows = iterator_to_array($connection->cursor('select cursor_value')); - $connection->statement('create table records (id integer)'); - $connection->affectingStatement('delete from records'); - $connection->unprepared('delete from records'); - }); - - $this->assertSame([], $cursorRows); - $this->assertSame('select cursor_value', $queries[1]['query']); - $this->assertSame(0, $resolutions); - $this->assertSame(0, $configurator->stateCalls); - $this->assertSame(0, $configurator->applyCalls); - } - - public function testCursorPreservesFalseyValuesWithCustomFetchMode(): void - { - $connection = $this->getSqliteTransactionConnection(); - $connection->statement('create table records (id integer primary key, value text null)'); - $connection->insert("insert into records (id, value) values (1, null), (2, ''), (3, '0'), (4, 'later')"); - - $this->assertSame( - [null, '', '0', 'later'], - iterator_to_array($connection->cursor( - 'select id, value from records order by id', - fetchUsing: [PDO::FETCH_COLUMN, 1] - )) - ); - } - - public function testCursorPreservesModeOnlyFetchDefaults(): void - { - $connection = $this->getSqliteTransactionConnection(); - - $this->assertSame( - ['first', 'second'], - iterator_to_array($connection->cursor( - "select 'first' as value union all select 'second'", - fetchUsing: [PDO::FETCH_COLUMN] - )) - ); - - $classRows = iterator_to_array($connection->cursor( - "select 'class' as value", - fetchUsing: [PDO::FETCH_CLASS] - )); - $this->assertInstanceOf(stdClass::class, $classRows[0]); - $this->assertSame('class', $classRows[0]->value); - - $this->assertSame( - [1, 2], - iterator_to_array($connection->cursor( - 'select 1 as value union all select 2', - fetchUsing: [PDO::FETCH_GROUP | PDO::FETCH_COLUMN] - )) - ); - - $classTypeRows = iterator_to_array($connection->cursor( - "select 'stdClass' as class_name, 'typed' as value", - fetchUsing: [PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE] - )); - $this->assertInstanceOf(stdClass::class, $classTypeRows[0]); - $this->assertSame('typed', $classTypeRows[0]->value); - } - - public function testMySqlInsertUsesOneSynchronizedPdoForExecutionAndInsertId(): void - { - $configurator = new StatementPathSessionConfigurator; - Connection::configureSessionUsing($configurator); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['prepare', 'lastInsertId']) - ->getMock(); - $statement = $this->getMockBuilder(PDOStatement::class) - ->onlyMethods(['execute']) - ->getMock(); - $pdo->expects($this->once())->method('prepare')->with('insert into records values ()')->willReturn($statement); - $pdo->expects($this->once())->method('lastInsertId')->with(null)->willReturn('42'); - $statement->expects($this->once())->method('execute')->willReturn(true); - $connection = new MySqlConnection( - $pdo, - 'test_database', - '', - ['name' => 'test', 'driver' => 'mysql'] - ); - - $this->assertTrue($connection->insert('insert into records values ()')); - $this->assertSame('42', $connection->getLastInsertId()); - $this->assertSame(1, $configurator->stateCalls); - $this->assertSame(1, $configurator->applyCalls); - } - - public function testEscapingAndServerIntrospectionUseSynchronizedPdoHandOuts(): void - { - $configurator = new StatementPathSessionConfigurator; - Connection::configureSessionUsing($configurator); - $connection = new Connection( - new PDO('sqlite::memory:'), - ':memory:', - '', - ['name' => 'test', 'driver' => 'sqlite'] - ); - - $this->assertSame("'value'", $connection->escape('value')); - $this->assertNotSame('', $connection->getServerVersion()); - $this->assertSame(2, $configurator->stateCalls); - $this->assertSame(1, $configurator->applyCalls); - } - public function testInsertCallsTheStatementMethod() { $connection = $this->getMockConnection(['statement']); @@ -336,41 +316,6 @@ public function testDeleteCallsTheAffectingStatementMethod() $this->assertSame(1, $results); } - public function testStatementProperlyCallsPDO() - { - $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'bindValue'])->getMock(); - $statement->expects($this->once())->method('bindValue')->with(1, 'bar', 2); - $statement->expects($this->once())->method('execute')->willReturn(true); - $pdo->expects($this->once())->method('prepare')->with($this->equalTo('foo'))->willReturn($statement); - $mock = $this->getMockConnection(['prepareBindings'], $pdo); - $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['bar']))->willReturn(['bar']); - $results = $mock->statement('foo', ['bar']); - $this->assertTrue($results); - $log = $mock->getQueryLog(); - $this->assertSame('foo', $log[0]['query']); - $this->assertEquals(['bar'], $log[0]['bindings']); - $this->assertIsNumeric($log[0]['time']); - } - - public function testAffectingStatementProperlyCallsPDO() - { - $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); - $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'rowCount', 'bindValue'])->getMock(); - $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); - $statement->expects($this->once())->method('execute'); - $statement->expects($this->once())->method('rowCount')->willReturn(42); - $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); - $mock = $this->getMockConnection(['prepareBindings'], $pdo); - $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); - $results = $mock->update('foo', ['foo' => 'bar']); - $this->assertSame(42, $results); - $log = $mock->getQueryLog(); - $this->assertSame('foo', $log[0]['query']); - $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); - $this->assertIsNumeric($log[0]['time']); - } - public function testTransactionLevelNotIncrementedOnTransactionException() { $pdo = $this->createMock(PDOStub::class); @@ -425,16 +370,6 @@ public function testBeginTransactionMethodNeverRetriesIfWithinTransaction() } } - public function testSwapPDOWithOpenTransactionResetsTransactionLevel() - { - $pdo = $this->createMock(PDOStub::class); - $pdo->expects($this->once())->method('beginTransaction')->willReturn(true); - $connection = $this->getMockConnection([], $pdo); - $connection->beginTransaction(); - $connection->disconnect(); - $this->assertEquals(0, $connection->transactionLevel()); - } - public function testDisconnectClearsTransactionManagerStateEvenWhenTheLogicalLevelIsZero(): void { $connection = $this->getSqliteTransactionConnection(); @@ -595,45 +530,6 @@ public function testTransactionMethodRollsbackAndThrows() } } - public function testOnLostConnectionPDOIsNotSwappedWithinATransaction() - { - $this->expectException(QueryException::class); - $this->expectExceptionMessage('server has gone away (Connection: test, Host: , Port: , Database: , SQL: foo)'); - - $pdo = m::mock(PDO::class); - $pdo->shouldReceive('beginTransaction')->once(); - $statement = m::mock(PDOStatement::class); - $pdo->shouldReceive('prepare')->once()->andReturn($statement); - $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); - - $connection = new Connection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); - $connection->beginTransaction(); - $connection->statement('foo'); - } - - public function testOnLostConnectionPDOIsSwappedOutsideTransaction() - { - $pdo = m::mock(PDO::class); - - $statement = m::mock(PDOStatement::class); - $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); - $statement->shouldReceive('execute')->once()->andReturn(true); - - $pdo->shouldReceive('prepare')->twice()->andReturn($statement); - - $connection = new Connection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); - - $called = false; - - $connection->setReconnector(function ($connection) use (&$called) { - $called = true; - }); - - $this->assertTrue($connection->statement('foo')); - - $this->assertTrue($called); - } - public function testRunMethodRetriesOnFailure() { $method = (new ReflectionClass(Connection::class))->getMethod('run'); @@ -1024,135 +920,6 @@ public function testCommitFailureDoesNotRetryWhenRollbackCleanupFails(): void $this->assertCount(0, $manager->getCommittedTransactions()); } - public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void - { - $failure = new RuntimeException('commit failure'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('beginTransaction'); - $pdo->expects($this->once())->method('commit')->willThrowException($failure); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack'); - - $connection = $this->getMockConnection([], $pdo); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $connection->beginTransaction(); - - try { - $connection->commit(); - $this->fail('Expected the physical commit to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); - } - - $this->assertSame(1, $connection->transactionLevel()); - $this->assertCount(1, $manager->getPendingTransactions()); - $this->assertSame($pdo, $connection->getRawPdo()); - $this->assertTrue($connection->hasUnknownSessionState()); - - $connection->rollBack(); - } - - public function testLostManagedCommitTerminallyDetachesTransactionState(): void - { - $failure = new PDOException('server has gone away'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('beginTransaction'); - $pdo->expects($this->once())->method('commit')->willThrowException($failure); - $pdo->expects($this->never())->method('inTransaction'); - $pdo->expects($this->never())->method('rollBack'); - - $connection = $this->getMockConnection([], $pdo); - $connection->setReadPdo(new PDOStub); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $rollbackCallbackCalled = false; - - try { - $connection->transaction(function (Connection $connection) use (&$rollbackCallbackCalled): void { - $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { - $rollbackCallbackCalled = true; - }); - }); - $this->fail('Expected the lost commit to fail.'); - } catch (PDOException $exception) { - $this->assertSame($failure, $exception); - } - - $this->assertTrue($rollbackCallbackCalled); - $this->assertSame(0, $connection->transactionLevel()); - $this->assertCount(0, $manager->getPendingTransactions()); - $this->assertCount(0, $manager->getCommittedTransactions()); - $this->assertNull($connection->getRawPdo()); - $this->assertNull($connection->getRawReadPdo()); - } - - public function testNonLostPhysicalRollbackFailureKeepsActiveStateAndMarksTheSessionUnknown(): void - { - $failure = new RuntimeException('rollback failure'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('beginTransaction'); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); - - $connection = $this->getMockConnection([], $pdo); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $connection->beginTransaction(); - - try { - $connection->rollBack(); - $this->fail('Expected the physical rollback to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); - } - - $this->assertSame(1, $connection->transactionLevel()); - $this->assertCount(1, $manager->getPendingTransactions()); - $this->assertTrue($connection->hasUnknownSessionState()); - $this->assertSame($pdo, $connection->getRawPdo()); - } - - public function testLostPhysicalRollbackTerminallyDetachesTransactionState(): void - { - $failure = new PDOException('server has gone away'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('beginTransaction'); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); - - $connection = $this->getMockConnection([], $pdo); - $connection->setReadPdo(new PDOStub); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $connection->beginTransaction(); - $rollbackCallbackCalled = false; - $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { - $rollbackCallbackCalled = true; - }); - - try { - $connection->rollBack(); - $this->fail('Expected the lost rollback to fail.'); - } catch (PDOException $exception) { - $this->assertSame($failure, $exception); - } - - $this->assertTrue($rollbackCallbackCalled); - $this->assertSame(0, $connection->transactionLevel()); - $this->assertCount(0, $manager->getPendingTransactions()); - $this->assertNull($connection->getRawPdo()); - $this->assertNull($connection->getRawReadPdo()); - } - public function testManagerRollbackFailureStillDispatchesRolledBackEvent(): void { $connection = $this->getSqliteTransactionConnection(); @@ -1210,104 +977,6 @@ public function testRolledBackEventFailureOccursAfterManagerCleanup(): void $this->assertFalse($connection->getPdo()->inTransaction()); } - public function testDisconnectExhaustsCleanupAndPreservesThePhysicalFailure(): void - { - $physicalFailure = new RuntimeException('physical rollback failure'); - $callbackFailure = new RuntimeException('rollback callback failure'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('beginTransaction'); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException($physicalFailure); - - $connection = $this->getMockConnection([], $pdo); - $connection->setReadPdo(new PDOStub); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $connection->beginTransaction(); - $rollbackCallbackCalled = false; - $connection->afterRollBack(function () use (&$rollbackCallbackCalled, $callbackFailure): never { - $rollbackCallbackCalled = true; - - throw $callbackFailure; - }); - - try { - $connection->disconnect(); - $this->fail('Expected disconnect cleanup to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame($physicalFailure, $exception); - } - - $this->assertTrue($rollbackCallbackCalled); - $this->assertSame(0, $connection->transactionLevel()); - $this->assertCount(0, $manager->getPendingTransactions()); - $this->assertNull($connection->getRawPdo()); - $this->assertNull($connection->getRawReadPdo()); - - $connection->setPdo($pdo); - - $this->assertTrue($connection->hasUnknownSessionState()); - } - - public function testDisconnectTreatsLostPhysicalRollbackFailureAsAlreadyTerminal(): void - { - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException( - new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') - ); - - $connection = $this->getMockConnection([], $pdo); - $connection->setReadPdo(new PDOStub); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $manager->begin('test', 1); - - $connection->disconnect(); - - $this->assertSame(0, $connection->transactionLevel()); - $this->assertCount(0, $manager->getPendingTransactions()); - $this->assertNull($connection->getRawPdo()); - $this->assertNull($connection->getRawReadPdo()); - } - - public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFailure(): void - { - $callbackFailure = new RuntimeException('rollback callback failure'); - $pdo = $this->getMockBuilder(PDOStub::class) - ->onlyMethods(['inTransaction', 'rollBack']) - ->getMock(); - $pdo->expects($this->once())->method('inTransaction')->willReturn(true); - $pdo->expects($this->once())->method('rollBack')->willThrowException( - new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') - ); - - $connection = $this->getMockConnection([], $pdo); - $connection->setReadPdo(new PDOStub); - $manager = new DatabaseTransactionsManager; - $connection->setTransactionManager($manager); - $manager->begin('test', 1); - $manager->addCallbackForRollback(static function () use ($callbackFailure): never { - throw $callbackFailure; - }); - - try { - $connection->disconnect(); - $this->fail('Expected disconnect manager cleanup to fail.'); - } catch (RuntimeException $exception) { - $this->assertSame($callbackFailure, $exception); - } - - $this->assertSame(0, $connection->transactionLevel()); - $this->assertCount(0, $manager->getPendingTransactions()); - $this->assertNull($connection->getRawPdo()); - $this->assertNull($connection->getRawReadPdo()); - } - public function testPretendOnlyLogsQueries() { $connection = $this->getMockConnection(); @@ -1455,43 +1124,6 @@ public function testForeignKeyConstraintSuppressionDepthIsConnectionOwned(): voi $connection->endForeignKeyConstraintSuppression(); } - public function testResetForPoolMarksALeakedForeignKeySuppressionScopeUnknown(): void - { - $connection = $this->getMockConnection(); - - $connection->beginForeignKeyConstraintSuppression(); - $connection->resetForPool(); - - $this->assertTrue($connection->hasUnknownSessionState()); - $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); - - $connection->endForeignKeyConstraintSuppression(); - } - - public function testResetForPoolDoesNotResolveALazyConnectionForALeakedForeignKeySuppressionScope(): void - { - $resolutions = 0; - $connection = new Connection( - static function () use (&$resolutions): PDO { - ++$resolutions; - - return new PDOStub; - }, - 'test_db', - '', - ['name' => 'test', 'driver' => 'mysql'] - ); - - $connection->beginForeignKeyConstraintSuppression(); - $connection->resetForPool(); - - $this->assertSame(0, $resolutions); - $this->assertFalse($connection->hasUnknownSessionState()); - $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); - - $connection->endForeignKeyConstraintSuppression(); - } - public function testQueryExceptionContainsReadConnectionDetailsWhenUsingReadPdo() { // Create write PDO mock that will NOT be used for this query @@ -1518,7 +1150,7 @@ public function testQueryExceptionContainsReadConnectionDetailsWhenUsingReadPdo( ]; // Create connection with write config - $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection = new PdoConnection($writePdo, 'write_db', '', $writeConfig); $connection->useDefaultQueryGrammar(); $connection->useDefaultPostProcessor(); @@ -1565,7 +1197,7 @@ public function testQueryExceptionContainsReadConnectionDetailsWhenReadPdoConnec 'database' => 'write_db', ]; - $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection = new PdoConnection($writePdo, 'write_db', '', $writeConfig); $connection->useDefaultQueryGrammar(); $connection->useDefaultPostProcessor(); @@ -1605,7 +1237,7 @@ public function testQueryExceptionContainsDerivedReadConnectionDetails(): void ->method('prepare') ->willThrowException(new PDOException('Connection refused')); - $connection = new Connection($pdo, 'read_db', '', [ + $connection = new PdoConnection($pdo, 'read_db', '', [ 'driver' => 'mysql', 'name' => 'mysql', 'host' => '192.168.1.20', @@ -1654,7 +1286,7 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenUsingWritePd 'database' => 'write_db', ]; - $connection = new Connection($writePdo, 'write_db', '', $writeConfig); + $connection = new PdoConnection($writePdo, 'write_db', '', $writeConfig); $connection->useDefaultQueryGrammar(); $connection->useDefaultPostProcessor(); @@ -1695,7 +1327,7 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConn ]; // Simulate lazy write PDO that fails during connection (e.g., SET NAMES fails) - $connection = new Connection(function () { + $connection = new PdoConnection(function () { throw new PDOException('SQLSTATE[HY000] SET NAMES failed'); }, 'write_db', '', $writeConfig); $connection->useDefaultQueryGrammar(); @@ -1725,9 +1357,9 @@ public function testQueryExceptionContainsWriteConnectionDetailsWhenWritePdoConn } } - protected function getSqliteTransactionConnection(): Connection + protected function getSqliteTransactionConnection(): PdoConnection { - return new Connection( + return new PdoConnection( new PDO('sqlite::memory:'), ':memory:', '', @@ -1738,14 +1370,14 @@ protected function getSqliteTransactionConnection(): Connection /** * Create a read / write connection for sticky routing assertions. * - * @return array{0: Connection, 1: PDOStub, 2: PDOStub} + * @return array{0: PdoConnection, 1: PDOStub, 2: PDOStub} */ protected function getReadWriteConnection(bool $sticky): array { $writePdo = new PDOStub; $readPdo = new PDOStub; - $connection = new Connection($writePdo, 'test_db', '', [ + $connection = new PdoConnection($writePdo, 'test_db', '', [ 'name' => 'test', 'driver' => 'mysql', 'sticky' => $sticky, @@ -1760,7 +1392,7 @@ protected function getMockConnection($methods = [], $pdo = null) $pdo = $pdo ?: new PDOStub; if ($methods === []) { - $connection = new Connection($pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']); + $connection = new PdoConnection($pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']); $connection->setSchemaGrammar(m::mock(SchemaGrammar::class)); $connection->enableQueryLog(); @@ -1768,7 +1400,7 @@ protected function getMockConnection($methods = [], $pdo = null) } $defaults = ['getDefaultQueryGrammar', 'getDefaultPostProcessor', 'getDefaultSchemaGrammar']; - $connection = $this->getMockBuilder(Connection::class)->onlyMethods(array_values(array_unique(array_merge($defaults, $methods))))->setConstructorArgs([$pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']])->getMock(); + $connection = $this->getMockBuilder(PdoConnection::class)->onlyMethods(array_values(array_unique(array_merge($defaults, $methods))))->setConstructorArgs([$pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']])->getMock(); $connection->method('getDefaultSchemaGrammar')->willReturn(m::mock(SchemaGrammar::class)); $connection->enableQueryLog(); @@ -1804,23 +1436,163 @@ public function __construct($message = null, $code = null) } } -class StatementPathSessionConfigurator implements SessionConfigurator +class NeutralConnectionForTest extends Connection { - public string $desiredState = 'state'; + public bool $driverResourcesPresent = true; + + public int $disconnectCalls = 0; - public int $stateCalls = 0; + public int $forgetCalls = 0; - public int $applyCalls = 0; + public int $replaceCalls = 0; - public function state(Connection $connection): ?string + public string $driverGeneration = 'initial'; + + /** + * Set the latest read / write type for testing. + */ + public function setLatestReadWriteTypeForTest(?string $type): void { - ++$this->stateCalls; + $this->latestReadWriteTypeRetrieved = $type; + } + + /** + * Get the effective read / write type for testing. + */ + public function latestReadWriteTypeForTest(): ?string + { + return $this->latestReadWriteTypeUsed(); + } + + public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array + { + return []; + } + + public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator + { + yield from []; + } + + public function statement(string $query, array $bindings = []): bool + { + return true; + } - return $this->desiredState; + public function affectingStatement(string $query, array $bindings = []): int + { + return 0; + } + + public function unprepared(string $query): bool + { + return true; + } + + public function ping(): bool + { + return true; + } + + public function inTransaction(): bool + { + return false; + } + + public function getServerVersion(): string + { + return '1.0'; + } + + protected function escapeString(string $value): string + { + return "HTTP_STRING[{$value}]"; + } + + protected function hasDriverResources(): bool + { + return $this->driverResourcesPresent; + } + + protected function disconnectDriverResources(): void + { + ++$this->disconnectCalls; + $this->forgetDriverResources(); + } + + protected function forgetDriverResources(): void + { + ++$this->forgetCalls; + $this->driverResourcesPresent = false; + } + + protected function replaceDriverResources(Connection $fresh): void + { + ++$this->replaceCalls; + + /** @var self $fresh */ + $driverResourcesPresent = $fresh->driverResourcesPresent; + $driverGeneration = $fresh->driverGeneration; + $database = $fresh->database; + $configuredDatabase = $fresh->configuredDatabase; + $tablePrefix = $fresh->tablePrefix; + $configuredTablePrefix = $fresh->configuredTablePrefix; + $config = $fresh->config; + $readConnectionConfig = $fresh->readConnectionConfig; + $readWriteType = $fresh->readWriteType; + + try { + $this->disconnectDriverResources(); + } finally { + $this->driverResourcesPresent = $driverResourcesPresent; + $this->driverGeneration = $driverGeneration; + $this->database = $database; + $this->configuredDatabase = $configuredDatabase; + $this->tablePrefix = $tablePrefix; + $this->configuredTablePrefix = $configuredTablePrefix; + $this->config = $config; + $this->readConnectionConfig = $readConnectionConfig; + $this->readWriteType = $readWriteType; + $this->latestReadWriteTypeRetrieved = null; + } + } +} + +class NeutralTransactionConnectionForTest extends NeutralConnectionForTest +{ + public int $invalidateCalls = 0; + + public int $rollBackCalls = 0; + + public bool $physicalTransaction = false; + + public function inTransaction(): bool + { + return $this->physicalTransaction; + } + + protected function invalidateCurrentSessionState(): void + { + ++$this->invalidateCalls; + } + + protected function executeBeginTransactionStatement(): void + { + $this->physicalTransaction = true; + } + + protected function createSavepoint(): void + { + } + + protected function performCommit(): void + { + $this->physicalTransaction = false; } - public function apply(PDO $pdo, string $state, Connection $connection): void + protected function performRollBack(int $toLevel): void { - ++$this->applyCalls; + ++$this->rollBackCalls; + $this->physicalTransaction = false; } } diff --git a/tests/Database/DatabaseConnectorTest.php b/tests/Database/DatabaseConnectorTest.php index 7f77eccab5..172a8b97f6 100755 --- a/tests/Database/DatabaseConnectorTest.php +++ b/tests/Database/DatabaseConnectorTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\Database; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Connectors\Connector; +use Hypervel\Database\Connectors\MariaDbConnector; use Hypervel\Database\Connectors\MySqlConnector; use Hypervel\Database\Connectors\PostgresConnector; use Hypervel\Database\Connectors\SQLiteConnector; +use Hypervel\Database\SQLiteDatabaseDoesNotExistException; +use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Process\Process; class DatabaseConnectorTest extends TestCase { @@ -46,6 +51,31 @@ public static function mySqlConnectProvider() ]; } + public function testMySqlAndMariaDbConnectTimeoutUsesCeilingUnlessThePdoOptionIsExplicit(): void + { + foreach ([new MySqlConnector, new MariaDbConnector] as $connector) { + $this->assertSame(2, $connector->getOptions([ + 'connect_timeout' => 1.25, + ])[PDO::ATTR_TIMEOUT]); + + $this->assertSame(7, $connector->getOptions([ + 'connect_timeout' => 1.25, + 'options' => [PDO::ATTR_TIMEOUT => 7], + ])[PDO::ATTR_TIMEOUT]); + } + } + + public function testMySqlEscapesBackticksInTheSelectedDatabaseName(): void + { + $config = ['host' => 'foo', 'database' => 'app`tenant']; + $connector = $this->getMockBuilder(MySqlConnector::class)->onlyMethods(['createConnection'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('createConnection')->willReturn($connection); + $connection->shouldReceive('exec')->once()->with('use `app``tenant`;')->andReturn(true); + + $this->assertSame($connection, $connector->connect($config)); + } + public function testMySqlConnectCallsCreateConnectionWithIsolationLevel() { $dsn = 'mysql:host=foo;dbname=bar'; @@ -111,7 +141,7 @@ public function testPostgresConnectCallsCreateConnectionWithProperArguments() public function testPostgresConnectTimeoutIsBakedIntoDsn(): void { $dsn = "pgsql:host=foo;dbname='bar';connect_timeout=2"; - $config = ['host' => 'foo', 'database' => 'bar', 'connect_timeout' => 2]; + $config = ['host' => 'foo', 'database' => 'bar', 'connect_timeout' => 1.25]; $connector = $this->getMockBuilder(PostgresConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); $connection = m::mock(PDO::class); $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); @@ -403,6 +433,92 @@ public function testSQLiteRejectsLockTimeout(): void $connector->connect($config); } + /** + * Reject missing SQLite paths when no application root exists. + */ + #[DataProvider('missingSQLitePathProvider')] + public function testSQLiteRejectsMissingPathsWithoutAnApplicationRoot(bool $absolute): void + { + $database = 'missing-' . bin2hex(random_bytes(16)) . '.sqlite'; + + if ($absolute) { + $database = sys_get_temp_dir() . '/' . $database; + } + + $process = new Process([ + PHP_BINARY, + '-r', + <<<'PHP' + require $argv[1]; + + $container = new \Hypervel\Container\Container; + \Hypervel\Container\Container::setInstance($container); + + $connector = new class extends \Hypervel\Database\Connectors\SQLiteConnector { + public function createConnection(string $dsn, array $config, array $options): \PDO + { + throw new \LogicException('The missing path unexpectedly reached connection creation.'); + } + }; + + try { + $connector->connect(['database' => $argv[2]]); + $exception = null; + } catch (\Throwable $throwable) { + $exception = $throwable; + } + + echo json_encode([ + 'base_path_defined' => defined('BASE_PATH'), + 'application_bound' => $container->has(\Hypervel\Contracts\Foundation\Application::class), + 'exception' => $exception === null ? null : $exception::class, + 'path' => $exception instanceof \Hypervel\Database\SQLiteDatabaseDoesNotExistException + ? $exception->path + : null, + ], JSON_THROW_ON_ERROR); + PHP, + dirname(__DIR__, 2) . '/vendor/autoload.php', + $database, + ]); + $process->mustRun(); + + $this->assertSame([ + 'base_path_defined' => false, + 'application_bound' => false, + 'exception' => SQLiteDatabaseDoesNotExistException::class, + 'path' => $database, + ], json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Provide missing absolute and relative SQLite paths. + */ + public static function missingSQLitePathProvider(): array + { + return [ + 'absolute' => [true], + 'relative' => [false], + ]; + } + + /** + * Resolve relative SQLite paths against the application root. + */ + public function testSQLiteRelativePathResolvesAgainstApplicationRoot(): void + { + new Application(__DIR__); + + $config = ['database' => basename(__FILE__)]; + $dsn = 'sqlite:' . __FILE__; + $connector = $this->getMockBuilder(SQLiteConnector::class)->onlyMethods(['createConnection', 'getOptions'])->getMock(); + $connection = m::mock(PDO::class); + $connector->expects($this->once())->method('getOptions')->with($this->equalTo($config))->willReturn(['options']); + $connector->expects($this->once())->method('createConnection')->with($this->equalTo($dsn), $this->equalTo($config), $this->equalTo(['options']))->willReturn($connection); + + $this->assertTrue(Application::getInstance()->has(ApplicationContract::class)); + $this->assertSame($connection, $connector->connect($config)); + } + public function testSQLiteNamedMemoryDatabasesMayBeConnectedTo() { $dsn = 'sqlite:file:mydb?mode=memory&cache=shared'; diff --git a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php index e923689564..b87fd107c0 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -17,7 +17,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; -use PDO; use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentBelongsToManyCreateOrFirstTest extends TestCase @@ -777,10 +776,8 @@ protected function mockConnectionForModels(array $models, string $database, arra $class::setConnectionResolver($resolver); } - $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); - foreach ($lastInsertIds as $id) { - $pdo->expects('lastInsertId')->andReturn($id); + $connection->expects('getLastInsertId')->andReturn($id); } } } diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php index 19e38ef019..b2ec7c7436 100755 --- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -15,7 +15,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; -use PDO; use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentBuilderCreateOrFirstTest extends TestCase @@ -671,10 +670,8 @@ protected function mockConnectionForModel(Model $model, string $database, array $class = get_class($model); $class::setConnectionResolver($resolver); - $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); - foreach ($lastInsertIds as $id) { - $pdo->expects('lastInsertId')->andReturn($id); + $connection->expects('getLastInsertId')->andReturn($id); } } } diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index a7a17a3fd4..402158b948 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -16,6 +16,7 @@ use Hypervel\Database\Eloquent\RelationNotFoundException; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Eloquent\SoftDeletes; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Builder as BaseBuilder; use Hypervel\Database\Query\Expression; use Hypervel\Database\Query\Grammars\Grammar; @@ -3204,7 +3205,7 @@ public function getPassthru(): array public function testPipeCallback() { $query = new Builder(new BaseBuilder( - $connection = new Connection(new PDO('sqlite::memory:')), + $connection = new PdoConnection(new PDO('sqlite::memory:')), new Grammar($connection), new Processor, )); diff --git a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php index 451a5bfdbf..32fe114a29 100755 --- a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -15,7 +15,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; -use PDO; use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentHasManyCreateOrFirstTest extends TestCase @@ -429,10 +428,8 @@ protected function mockConnectionForModel(Model $model, string $database, array $class = get_class($model); $class::setConnectionResolver($resolver); - $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); - foreach ($lastInsertIds as $id) { - $pdo->expects('lastInsertId')->andReturn($id); + $connection->expects('getLastInsertId')->andReturn($id); } } } diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php index ea9ad410d5..8b0dcca7f7 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -15,7 +15,6 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; use Mockery as m; -use PDO; use PHPUnit\Framework\Attributes\DataProvider; class DatabaseEloquentHasManyThroughCreateOrFirstTest extends TestCase @@ -409,10 +408,8 @@ protected function mockConnectionForModel(Model $model, string $database, array $class = get_class($model); $class::setConnectionResolver($resolver); - $connection->shouldReceive('getPdo')->andReturn($pdo = m::mock(PDO::class)); - foreach ($lastInsertIds as $id) { - $pdo->expects('lastInsertId')->andReturn($id); + $connection->expects('getLastInsertId')->andReturn($id); } } } diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 7ea47c6bbf..dac3775c5e 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -4,10 +4,15 @@ namespace Hypervel\Tests\Database; +use Closure; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; +use Hypervel\Database\Events\ConnectionEstablished; +use Hypervel\Database\PdoConnection; use Hypervel\Database\SQLiteConnection; +use Hypervel\Database\SQLiteDatabaseDoesNotExistException; +use Hypervel\Events\Dispatcher; use Hypervel\Filesystem\Filesystem; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -171,6 +176,167 @@ public function testReconnectAfterDisconnectOnNonPooledConnection() $this->assertNotNull($reconnected->getRawPdo()); } + public function testNonPooledReconnectRefreshesInPlaceAndDispatchesOneEventAfterReplacement(): void + { + $events = new Dispatcher; + $this->db->setEventDispatcher($events); + $establishedConnections = []; + $events->listen( + ConnectionEstablished::class, + static function (ConnectionEstablished $event) use (&$establishedConnections): void { + $establishedConnections[] = $event->connection; + } + ); + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection(); + $oldPdo = $connection->getPdo(); + $connection->enableQueryLog(); + $connection->select('select 1'); + + $reconnected = $manager->reconnect(); + + $this->assertSame($connection, $reconnected); + $this->assertNotSame($oldPdo, $reconnected->getPdo()); + $this->assertCount(1, $reconnected->getQueryLog()); + $this->assertSame([$connection, $connection], $establishedConnections); + } + + public function testNonPooledReconnectEagerlyAdoptsCompleteSplitResourceGeneration(): void + { + $filesystem = new Filesystem; + $directory = ParallelTesting::tempDir('DatabaseManagerTest-generation-refresh'); + $filesystem->deleteDirectory($directory); + $filesystem->ensureDirectoryExists($directory); + + $oldReadPath = $directory . '/old-read.sqlite'; + $oldWritePath = $directory . '/old-write.sqlite'; + $newReadPath = $directory . '/new-read.sqlite'; + $newWritePath = $directory . '/new-write.sqlite'; + $connection = null; + + try { + $this->createSqliteUsersDatabase($oldReadPath, 'Old Read'); + $this->createSqliteUsersDatabase($oldWritePath, 'Old Write'); + $this->createSqliteUsersDatabase($newReadPath, 'New Read'); + $this->createSqliteUsersDatabase($newWritePath, 'New Write'); + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => $oldWritePath, + 'read' => ['database' => $oldReadPath], + 'write' => ['database' => $oldWritePath], + ], 'generation-refresh'); + + $events = new Dispatcher; + $this->db->setEventDispatcher($events); + $establishedConnections = []; + $events->listen( + ConnectionEstablished::class, + static function (ConnectionEstablished $event) use (&$establishedConnections): void { + $establishedConnections[] = $event->connection; + } + ); + + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection('generation-refresh'); + $oldWritePdo = $connection->getPdo(); + $oldReadPdo = $connection->getReadPdo(); + $establishedConnections = []; + + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => $newWritePath, + 'read' => ['database' => $newReadPath], + 'write' => ['database' => $newWritePath], + ], 'generation-refresh'); + + $reconnected = $manager->reconnect('generation-refresh'); + + $this->assertSame($connection, $reconnected); + $this->assertInstanceOf(PDO::class, $connection->getRawPdo()); + $this->assertInstanceOf(PDO::class, $connection->getRawReadPdo()); + $this->assertNotSame($oldWritePdo, $connection->getRawPdo()); + $this->assertNotSame($oldReadPdo, $connection->getRawReadPdo()); + $this->assertSame($newWritePath, $connection->getDatabaseName()); + $this->assertSame($newWritePath, $connection->getConfig('database')); + $this->assertSame('New Read', $connection->selectOne('select name from users')->name); + $this->assertSame('New Write', $connection->selectFromWriteConnection('select name from users')[0]->name); + $this->assertSame([$connection], $establishedConnections); + } finally { + $connection?->disconnect(); + $filesystem->deleteDirectory($directory); + } + } + + public function testFailedNonPooledReconnectPreservesCompleteSplitResourceGeneration(): void + { + $filesystem = new Filesystem; + $directory = ParallelTesting::tempDir('DatabaseManagerTest-generation-refresh-failure'); + $filesystem->deleteDirectory($directory); + $filesystem->ensureDirectoryExists($directory); + + $oldReadPath = $directory . '/old-read.sqlite'; + $oldWritePath = $directory . '/old-write.sqlite'; + $newWritePath = $directory . '/new-write.sqlite'; + $missingReadPath = $directory . '/missing-read.sqlite'; + $connection = null; + + try { + $this->createSqliteUsersDatabase($oldReadPath, 'Old Read'); + $this->createSqliteUsersDatabase($oldWritePath, 'Old Write'); + $this->createSqliteUsersDatabase($newWritePath, 'New Write'); + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => $oldWritePath, + 'read' => ['database' => $oldReadPath], + 'write' => ['database' => $oldWritePath], + ], 'generation-refresh-failure'); + + $events = new Dispatcher; + $this->db->setEventDispatcher($events); + $establishedConnections = []; + $events->listen( + ConnectionEstablished::class, + static function (ConnectionEstablished $event) use (&$establishedConnections): void { + $establishedConnections[] = $event->connection; + } + ); + + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection('generation-refresh-failure'); + $oldWritePdo = $connection->getPdo(); + $oldReadPdo = $connection->getReadPdo(); + $establishedConnections = []; + + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => $newWritePath, + 'read' => ['database' => $missingReadPath], + 'write' => ['database' => $newWritePath], + ], 'generation-refresh-failure'); + + $exception = null; + + try { + $manager->reconnect('generation-refresh-failure'); + } catch (SQLiteDatabaseDoesNotExistException $sqliteException) { + $exception = $sqliteException; + } + + $this->assertNotNull($exception); + $this->assertSame($missingReadPath, $exception->path); + $this->assertSame($oldWritePdo, $connection->getRawPdo()); + $this->assertSame($oldReadPdo, $connection->getRawReadPdo()); + $this->assertSame($oldWritePath, $connection->getDatabaseName()); + $this->assertSame($oldWritePath, $connection->getConfig('database')); + $this->assertSame('Old Read', $connection->selectOne('select name from users')->name); + $this->assertSame('Old Write', $connection->selectFromWriteConnection('select name from users')[0]->name); + $this->assertSame([], $establishedConnections); + } finally { + $connection?->disconnect(); + $filesystem->deleteDirectory($directory); + } + } + public function testExtendWorksEndToEndThroughNonPooledPath() { $custom = new SQLiteConnection(new PDO('sqlite::memory:'), ':memory:'); @@ -264,13 +430,16 @@ public function testNonPooledReadConnectionReconnectsUsingReadSuffix(): void ], ], 'split-reconnect'); - $connection = $this->db->getDatabaseManager()->connection('split-reconnect::read'); + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection('split-reconnect::read'); $this->assertSame('Read Side', $connection->selectOne('select name from users')->name); + $this->assertSame(['split-reconnect::read'], array_keys($manager->getConnections())); $connection->setPdo(null); $connection->reconnectIfMissingConnection(); $this->assertSame('Read Side', $connection->selectOne('select name from users')->name); + $this->assertSame(['split-reconnect::read'], array_keys($manager->getConnections())); } finally { if ($connection instanceof Connection) { $connection->disconnect(); @@ -306,13 +475,16 @@ public function testNonPooledWriteConnectionReconnectsUsingWriteSide(): void ], ], 'split-write-reconnect'); - $connection = $this->db->getDatabaseManager()->connection('split-write-reconnect::write'); + $manager = $this->db->getDatabaseManager(); + $connection = $manager->connection('split-write-reconnect::write'); $this->assertSame('Write Side', $connection->selectOne('select name from users')->name); + $this->assertSame(['split-write-reconnect::write'], array_keys($manager->getConnections())); $connection->setPdo(null); $connection->reconnectIfMissingConnection(); $this->assertSame('Write Side', $connection->selectOne('select name from users')->name); + $this->assertSame(['split-write-reconnect::write'], array_keys($manager->getConnections())); } finally { if ($connection instanceof Connection) { $connection->disconnect(); @@ -336,15 +508,20 @@ public function testNonPooledWriteConnectionForcesReadsThroughWritePdo(): void ], 'split'); $connection = $this->db->getDatabaseManager()->connection('split::write'); + $this->assertInstanceOf(PdoConnection::class, $connection); + $readPdoResolver = $connection->getRawReadPdo(); + $this->assertInstanceOf(Closure::class, $readPdoResolver); $connection->statement('create table users (id integer primary key, name varchar)'); $connection->insert('insert into users (name) values (?)', ['Taylor']); $this->assertSame('split', $connection->getName()); - $this->assertNull($connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame('write', $connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame("'value'", $connection->escape('value')); + $this->assertSame($readPdoResolver, $connection->getRawReadPdo()); $this->assertSame('Taylor', $connection->selectOne('select name from users')->name); } - public function testReadAndWriteSuffixesAreCompatibilityAliasesForUnsplitConnections(): void + public function testReadAndWriteSuffixesRetainTheirRolesForUnsplitConnections(): void { $manager = $this->db->getDatabaseManager(); @@ -355,8 +532,8 @@ public function testReadAndWriteSuffixesAreCompatibilityAliasesForUnsplitConnect $this->assertInstanceOf(Connection::class, $write); $this->assertSame('default', $read->getName()); $this->assertSame('default', $write->getName()); - $this->assertNull($read->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); - $this->assertNull($write->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame('read', $read->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame('write', $write->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); } public function testDirectConnectionSuffixIsRejected(): void diff --git a/tests/Database/DatabaseMySqlBuilderTest.php b/tests/Database/DatabaseMySqlBuilderTest.php index 61dc823378..c4b0a94930 100644 --- a/tests/Database/DatabaseMySqlBuilderTest.php +++ b/tests/Database/DatabaseMySqlBuilderTest.php @@ -9,7 +9,6 @@ use Hypervel\Database\Schema\MySqlBuilder; use Hypervel\Tests\TestCase; use Mockery as m; -use PDO; use RuntimeException; class DatabaseMySqlBuilderTest extends TestCase @@ -49,7 +48,6 @@ public function testDropAllTablesPreservesEnabledForeignKeyConstraints(): void { $connection = m::mock(Connection::class); $grammar = new MySqlGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $builder = m::mock(MySqlBuilder::class, [$connection])->makePartial(); @@ -58,14 +56,13 @@ public function testDropAllTablesPreservesEnabledForeignKeyConstraints(): void $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); $connection->shouldReceive('pretending')->times(3)->andReturnFalse(); $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks', [], false)->andReturn(1); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('SET FOREIGN_KEY_CHECKS=0;')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('SET FOREIGN_KEY_CHECKS=0;')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables(['users'])) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('SET FOREIGN_KEY_CHECKS=1;')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('SET FOREIGN_KEY_CHECKS=1;')->ordered(); $connection->shouldReceive('endForeignKeyConstraintSuppression')->once(); $builder->dropAllTables(); @@ -83,7 +80,7 @@ public function testDropAllTablesPreservesDisabledForeignKeyConstraints(): void $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); $connection->shouldReceive('pretending')->once()->andReturnFalse(); $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks', [], false)->andReturn(0); - $connection->shouldReceive('getPdo')->never(); + $connection->shouldNotReceive('executeSessionStatement'); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables(['users'])) @@ -97,7 +94,6 @@ public function testDropAllTablesPropagatesAFalseStatementResultAfterRestoringCo { $connection = m::mock(Connection::class); $grammar = new MySqlGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $builder = m::mock(MySqlBuilder::class, [$connection])->makePartial(); @@ -106,11 +102,10 @@ public function testDropAllTablesPropagatesAFalseStatementResultAfterRestoringCo $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); $connection->shouldReceive('pretending')->times(3)->andReturnFalse(); $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks', [], false)->andReturn(1); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('SET FOREIGN_KEY_CHECKS=0;')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('SET FOREIGN_KEY_CHECKS=0;')->ordered(); $statement = $grammar->compileDropAllTables(['users']); $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse()->ordered(); - $pdo->shouldReceive('exec')->once()->with('SET FOREIGN_KEY_CHECKS=1;')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('SET FOREIGN_KEY_CHECKS=1;')->ordered(); $connection->shouldReceive('endForeignKeyConstraintSuppression')->once(); $this->expectException(RuntimeException::class); diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php new file mode 100755 index 0000000000..1643847223 --- /dev/null +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -0,0 +1,1087 @@ +getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo->expects($this->never())->method('prepare'); + $statement = $this->getMockBuilder('PDOStatement') + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue']) + ->getMock(); + $statement->expects($this->once())->method('setFetchMode'); + $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->once())->method('fetchAll')->willReturn(['boom']); + $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $writePdo); + $mock->setReadPdo($pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); + $results = $mock->select('foo', ['foo' => 'bar']); + $this->assertEquals(['boom'], $results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testSelectResultsetsReturnsMultipleRowset(): void + { + $configurator = new StatementPathSessionConfigurator; + PdoConnection::configureSessionUsing($configurator); + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $writePdo->expects($this->never())->method('prepare'); + $statement = $this->getMockBuilder('PDOStatement') + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll', 'bindValue', 'nextRowset']) + ->getMock(); + $statement->expects($this->once())->method('setFetchMode'); + $statement->expects($this->once())->method('bindValue')->with(1, 'foo', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->atLeastOnce())->method('fetchAll')->with(PDO::FETCH_COLUMN, 1)->willReturn(['boom']); + $statement->expects($this->atLeastOnce())->method('nextRowset')->willReturnCallback(function () { + static $i = 1; + + return ++$i <= 2; + }); + $pdo->expects($this->once())->method('prepare')->with('CALL a_procedure(?)')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $writePdo); + $mock->setReadPdo($pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo']))->willReturn(['foo']); + $results = $mock->selectResultsets('CALL a_procedure(?)', ['foo'], true, [PDO::FETCH_COLUMN, 1]); + $this->assertEquals([['boom'], ['boom']], $results); + $log = $mock->getQueryLog(); + $this->assertSame('CALL a_procedure(?)', $log[0]['query']); + $this->assertEquals(['foo'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + $this->assertSame(1, $configurator->stateCalls); + $this->assertSame(1, $configurator->applyCalls); + } + + public function testEveryOrdinaryConnectionStatementClosureSynchronizesItsPdo(): void + { + $configurator = new StatementPathSessionConfigurator; + PdoConnection::configureSessionUsing($configurator); + $connection = new PdoConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + + $operations = [ + static fn () => $connection->select('select 1'), + static fn () => iterator_to_array($connection->cursor('select 1')), + static fn () => $connection->statement('create table records (id integer primary key)'), + static fn () => $connection->affectingStatement('insert into records (id) values (1)'), + static fn () => $connection->unprepared('delete from records'), + ]; + + foreach ($operations as $index => $operation) { + $configurator->desiredState = 'state-' . $index; + $operation(); + $this->assertSame($index + 1, $configurator->applyCalls); + } + + $this->assertSame(count($operations), $configurator->stateCalls); + } + + public function testPretendModeDoesNotResolveOrSynchronizePdo(): void + { + $configurator = new StatementPathSessionConfigurator; + PdoConnection::configureSessionUsing($configurator); + $resolutions = 0; + $connection = new PdoConnection( + static function () use (&$resolutions): PDO { + ++$resolutions; + + return new PDO('sqlite::memory:'); + }, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + + $cursorRows = null; + $queries = $connection->pretend(static function (Connection $connection) use (&$cursorRows): void { + $connection->select('select 1'); + $cursorRows = iterator_to_array($connection->cursor('select cursor_value')); + $connection->statement('create table records (id integer)'); + $connection->affectingStatement('delete from records'); + $connection->unprepared('delete from records'); + }); + + $this->assertSame([], $cursorRows); + $this->assertSame('select cursor_value', $queries[1]['query']); + $this->assertSame(0, $resolutions); + $this->assertSame(0, $configurator->stateCalls); + $this->assertSame(0, $configurator->applyCalls); + } + + public function testCursorPreservesFalseyValuesWithCustomFetchMode(): void + { + $connection = $this->getSqliteTransactionConnection(); + $connection->statement('create table records (id integer primary key, value text null)'); + $connection->insert("insert into records (id, value) values (1, null), (2, ''), (3, '0'), (4, 'later')"); + + $this->assertSame( + [null, '', '0', 'later'], + iterator_to_array($connection->cursor( + 'select id, value from records order by id', + fetchUsing: [PDO::FETCH_COLUMN, 1] + )) + ); + } + + public function testCursorPreservesModeOnlyFetchDefaults(): void + { + $connection = $this->getSqliteTransactionConnection(); + + $this->assertSame( + ['first', 'second'], + iterator_to_array($connection->cursor( + "select 'first' as value union all select 'second'", + fetchUsing: [PDO::FETCH_COLUMN] + )) + ); + + $classRows = iterator_to_array($connection->cursor( + "select 'class' as value", + fetchUsing: [PDO::FETCH_CLASS] + )); + $this->assertInstanceOf(stdClass::class, $classRows[0]); + $this->assertSame('class', $classRows[0]->value); + + $this->assertSame( + [1, 2], + iterator_to_array($connection->cursor( + 'select 1 as value union all select 2', + fetchUsing: [PDO::FETCH_GROUP | PDO::FETCH_COLUMN] + )) + ); + + $classTypeRows = iterator_to_array($connection->cursor( + "select 'stdClass' as class_name, 'typed' as value", + fetchUsing: [PDO::FETCH_CLASS | PDO::FETCH_CLASSTYPE] + )); + $this->assertInstanceOf(stdClass::class, $classTypeRows[0]); + $this->assertSame('typed', $classTypeRows[0]->value); + } + + public function testMySqlInsertUsesOneSynchronizedPdoForExecutionAndInsertId(): void + { + $configurator = new StatementPathSessionConfigurator; + PdoConnection::configureSessionUsing($configurator); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['prepare', 'lastInsertId']) + ->getMock(); + $statement = $this->getMockBuilder(PDOStatement::class) + ->onlyMethods(['execute']) + ->getMock(); + $pdo->expects($this->once())->method('prepare')->with('insert into records values ()')->willReturn($statement); + $pdo->expects($this->once())->method('lastInsertId')->with(null)->willReturn('42'); + $statement->expects($this->once())->method('execute')->willReturn(true); + $connection = new MySqlConnection( + $pdo, + 'test_database', + '', + ['name' => 'test', 'driver' => 'mysql'] + ); + + $this->assertTrue($connection->insert('insert into records values ()')); + $this->assertSame('42', $connection->getLastInsertId()); + $this->assertSame(1, $configurator->stateCalls); + $this->assertSame(1, $configurator->applyCalls); + } + + public function testPdoLastInsertIdFailureThrowsARuntimeException(): void + { + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['lastInsertId']) + ->getMock(); + $pdo->expects($this->once()) + ->method('lastInsertId') + ->with('records_id_seq') + ->willReturn(false); + $connection = $this->getMockConnection([], $pdo); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The database driver could not retrieve the last insert ID.'); + + $connection->getLastInsertId('records_id_seq'); + } + + public function testMySqlInsertWrapsLastInsertIdFailureAsAQueryException(): void + { + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['prepare', 'lastInsertId']) + ->getMock(); + $statement = $this->getMockBuilder(PDOStatement::class) + ->onlyMethods(['execute']) + ->getMock(); + $pdo->expects($this->once())->method('prepare')->with('insert into records values ()')->willReturn($statement); + $pdo->expects($this->once())->method('lastInsertId')->with(null)->willReturn(false); + $statement->expects($this->once())->method('execute')->willReturn(true); + $connection = new MySqlConnection( + $pdo, + 'test_database', + '', + ['name' => 'test', 'driver' => 'mysql'] + ); + + try { + $connection->insert('insert into records values ()'); + $this->fail('Expected last insert ID retrieval to fail.'); + } catch (QueryException $exception) { + $this->assertInstanceOf(RuntimeException::class, $exception->getPrevious()); + $this->assertSame( + 'The database driver could not retrieve the last insert ID.', + $exception->getPrevious()->getMessage(), + ); + } + } + + public function testMySqlLastInsertIdRequiresACapturedInsert(): void + { + $connection = new MySqlConnection( + new PDOStub, + 'test_database', + '', + ['name' => 'test', 'driver' => 'mysql'] + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No last insert ID has been captured for this connection.'); + + $connection->getLastInsertId(); + } + + public function testMySqlCapturedInsertIdIsClearedWheneverItsGenerationOrPoolStateChanges(): void + { + $resets = [ + 'set PDO' => static function (MySqlConnection $connection): void { + $connection->setPdo(new PDO('sqlite::memory:')); + }, + 'disconnect' => static function (MySqlConnection $connection): void { + $connection->disconnect(); + }, + 'refresh' => static function (MySqlConnection $connection): void { + $connection->refreshFrom(new MySqlConnection( + new PDO('sqlite::memory:'), + 'test_database', + '', + ['name' => 'test', 'driver' => 'mysql'] + )); + }, + 'pool reset' => static function (MySqlConnection $connection): void { + $connection->resetForPool(); + }, + ]; + + foreach ($resets as $reset => $operation) { + $pdo = new PDO('sqlite::memory:'); + $pdo->exec('create table records (id integer primary key autoincrement)'); + $connection = new MySqlConnection( + $pdo, + 'test_database', + '', + ['name' => 'test', 'driver' => 'mysql'] + ); + $connection->insert('insert into records default values'); + $this->assertSame('1', $connection->getLastInsertId(), $reset); + + $operation($connection); + + $exception = null; + + try { + $connection->getLastInsertId(); + } catch (RuntimeException $thrown) { + $exception = $thrown; + } + + $this->assertInstanceOf(RuntimeException::class, $exception, $reset); + $this->assertSame( + 'No last insert ID has been captured for this connection.', + $exception->getMessage(), + $reset, + ); + } + } + + public function testStatementPreparedIsDispatchedFromThePdoPreparationPath(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder(PDOStatement::class) + ->onlyMethods(['setFetchMode', 'execute', 'fetchAll']) + ->getMock(); + $pdo->expects($this->once())->method('prepare')->with('select 1')->willReturn($statement); + $statement->expects($this->once())->method('setFetchMode')->with(PDO::FETCH_OBJ); + $statement->expects($this->once())->method('execute')->willReturn(true); + $statement->expects($this->once())->method('fetchAll')->willReturn([]); + $connection = $this->getMockConnection([], $pdo); + $events = m::mock(Dispatcher::class); + $events->shouldReceive('hasListeners')->once()->with(StatementPrepared::class)->andReturn(true); + $events->shouldReceive('hasListeners')->once()->with(QueryExecuted::class)->andReturn(false); + $events->shouldReceive('dispatch') + ->once() + ->with(m::on(static fn (StatementPrepared $event): bool => $event->connection === $connection + && $event->statement === $statement)); + $connection->setEventDispatcher($events); + + $this->assertSame([], $connection->select('select 1', useReadPdo: false)); + } + + public function testEscapingAndServerIntrospectionUseSynchronizedPdoHandOuts(): void + { + $configurator = new StatementPathSessionConfigurator; + PdoConnection::configureSessionUsing($configurator); + $connection = new PdoConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + + $this->assertSame("'value'", $connection->escape('value')); + $this->assertNotSame('', $connection->getServerVersion()); + $this->assertSame(2, $configurator->stateCalls); + $this->assertSame(1, $configurator->applyCalls); + } + + public function testEscapingUsesThePdoThatExecutedTheLastWrite(): void + { + $writePdo = new EscapingPdoStub('write'); + $readResolutions = 0; + $connection = new PdoConnection( + $writePdo, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->setReadPdo(static function () use (&$readResolutions): PDO { + ++$readResolutions; + + return new EscapingPdoStub('read'); + }); + + $connection->unprepared('create table records (id integer)'); + + $this->assertSame("'write:value'", $connection->escape('value')); + $this->assertSame(0, $readResolutions); + } + + public function testEscapingUsesThePdoThatExecutedTheLastRead(): void + { + $writeResolutions = 0; + $connection = new PdoConnection( + static function () use (&$writeResolutions): PDO { + ++$writeResolutions; + + return new EscapingPdoStub('write'); + }, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->setReadPdo(new EscapingPdoStub('read')); + + $connection->select('select 1'); + + $this->assertSame("'read:value'", $connection->escape('value')); + $this->assertSame(0, $writeResolutions); + } + + public function testEscapingUsesTheWritePdoAfterAWriteForcedRead(): void + { + $writePdo = new EscapingPdoStub('write'); + $readResolutions = 0; + $connection = new PdoConnection( + $writePdo, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->setReadPdo(static function () use (&$readResolutions): PDO { + ++$readResolutions; + + return new EscapingPdoStub('read'); + }); + + $connection->selectFromWriteConnection('select 1'); + + $this->assertSame("'write:value'", $connection->escape('value')); + $this->assertSame(0, $readResolutions); + } + + public function testEscapingUsesTheWritePdoAfterAStickyRead(): void + { + $writePdo = new EscapingPdoStub('write'); + $readResolutions = 0; + $connection = new PdoConnection( + $writePdo, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite', 'sticky' => true] + ); + $connection->setReadPdo(static function () use (&$readResolutions): PDO { + ++$readResolutions; + + return new EscapingPdoStub('read'); + }); + $connection->recordsHaveBeenModified(); + + $connection->select('select 1'); + + $this->assertSame("'write:value'", $connection->escape('value')); + $this->assertSame(0, $readResolutions); + } + + public function testEscapingUsesTheConfiguredWriteRoleWithoutResolvingTheReadPdo(): void + { + $readResolutions = 0; + $connection = new PdoConnection( + new EscapingPdoStub('write'), + ':memory:', + '', + [ + 'name' => 'test', + 'driver' => 'sqlite', + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'write', + ] + ); + $connection->setReadPdo(static function () use (&$readResolutions): PDO { + ++$readResolutions; + + return new EscapingPdoStub('read'); + }); + + $this->assertSame("'write:value'", $connection->escape('value')); + $this->assertSame(0, $readResolutions); + } + + public function testEscapingDefaultsToTheReadPdoWithoutAPriorRole(): void + { + $writeResolutions = 0; + $readResolutions = 0; + $connection = new PdoConnection( + static function () use (&$writeResolutions): PDO { + ++$writeResolutions; + + return new EscapingPdoStub('write'); + }, + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->setReadPdo(static function () use (&$readResolutions): PDO { + ++$readResolutions; + + return new EscapingPdoStub('read'); + }); + + $this->assertSame("'read:value'", $connection->escape('value')); + $this->assertSame(0, $writeResolutions); + $this->assertSame(1, $readResolutions); + } + + public function testPoolResetClearsTheLastExecutionRoleBeforeEscaping(): void + { + $connection = new PdoConnection( + new EscapingPdoStub('write'), + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->setReadPdo(new EscapingPdoStub('read')); + $connection->unprepared('create table records (id integer)'); + + $this->assertSame("'write:value'", $connection->escape('value')); + + $connection->resetForPool(); + + $this->assertSame("'read:value'", $connection->escape('value')); + } + + public function testStatementProperlyCallsPDO(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'bindValue'])->getMock(); + $statement->expects($this->once())->method('bindValue')->with(1, 'bar', 2); + $statement->expects($this->once())->method('execute')->willReturn(true); + $pdo->expects($this->once())->method('prepare')->with($this->equalTo('foo'))->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['bar']))->willReturn(['bar']); + $results = $mock->statement('foo', ['bar']); + $this->assertTrue($results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testAffectingStatementProperlyCallsPDO(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['prepare'])->getMock(); + $statement = $this->getMockBuilder('PDOStatement')->onlyMethods(['execute', 'rowCount', 'bindValue'])->getMock(); + $statement->expects($this->once())->method('bindValue')->with('foo', 'bar', 2); + $statement->expects($this->once())->method('execute'); + $statement->expects($this->once())->method('rowCount')->willReturn(42); + $pdo->expects($this->once())->method('prepare')->with('foo')->willReturn($statement); + $mock = $this->getMockConnection(['prepareBindings'], $pdo); + $mock->expects($this->once())->method('prepareBindings')->with($this->equalTo(['foo' => 'bar']))->willReturn(['foo' => 'bar']); + $results = $mock->update('foo', ['foo' => 'bar']); + $this->assertSame(42, $results); + $log = $mock->getQueryLog(); + $this->assertSame('foo', $log[0]['query']); + $this->assertEquals(['foo' => 'bar'], $log[0]['bindings']); + $this->assertIsNumeric($log[0]['time']); + } + + public function testSwapPDOWithOpenTransactionResetsTransactionLevel(): void + { + $pdo = $this->createMock(PDOStub::class); + $pdo->expects($this->once())->method('beginTransaction')->willReturn(true); + $connection = $this->getMockConnection([], $pdo); + $connection->beginTransaction(); + $connection->disconnect(); + $this->assertEquals(0, $connection->transactionLevel()); + } + + public function testOnLostConnectionPDOIsNotSwappedWithinATransaction(): void + { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('server has gone away (Connection: test, Host: , Port: , Database: , SQL: foo)'); + + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('beginTransaction')->once(); + $statement = m::mock(PDOStatement::class); + $pdo->shouldReceive('prepare')->once()->andReturn($statement); + $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); + + $connection = new PdoConnection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); + $connection->beginTransaction(); + $connection->statement('foo'); + } + + public function testOnLostConnectionPDOIsSwappedOutsideTransaction(): void + { + $pdo = m::mock(PDO::class); + + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('execute')->once()->andThrow(new PDOException('server has gone away')); + $statement->shouldReceive('execute')->once()->andReturn(true); + + $pdo->shouldReceive('prepare')->twice()->andReturn($statement); + + $connection = new PdoConnection($pdo, '', '', ['name' => 'test', 'driver' => 'mysql']); + + $called = false; + + $connection->setReconnector(function ($connection) use (&$called) { + $called = true; + }); + + $this->assertTrue($connection->statement('foo')); + + $this->assertTrue($called); + } + + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void + { + $failure = new RuntimeException('commit failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit')->willThrowException($failure); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack'); + + $connection = $this->getMockConnection([], $pdo); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + try { + $connection->commit(); + $this->fail('Expected the physical commit to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertSame($pdo, $connection->getRawPdo()); + $this->assertFalse($connection->isReusable()); + + $connection->rollBack(); + } + + public function testLostManagedCommitTerminallyDetachesTransactionState(): void + { + $failure = new PDOException('server has gone away'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'commit', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('commit')->willThrowException($failure); + $pdo->expects($this->never())->method('inTransaction'); + $pdo->expects($this->never())->method('rollBack'); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $rollbackCallbackCalled = false; + + try { + $connection->transaction(function (Connection $connection) use (&$rollbackCallbackCalled): void { + $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { + $rollbackCallbackCalled = true; + }); + }); + $this->fail('Expected the lost commit to fail.'); + } catch (PDOException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testNonLostPhysicalRollbackFailureKeepsActiveStateAndMarksTheSessionUnknown(): void + { + $failure = new RuntimeException('rollback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); + + $connection = $this->getMockConnection([], $pdo); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + + try { + $connection->rollBack(); + $this->fail('Expected the physical rollback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertCount(1, $manager->getPendingTransactions()); + $this->assertFalse($connection->isReusable()); + $this->assertSame($pdo, $connection->getRawPdo()); + } + + public function testLostPhysicalRollbackTerminallyDetachesTransactionState(): void + { + $failure = new PDOException('server has gone away'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($failure); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $rollbackCallbackCalled = false; + $connection->afterRollBack(function () use (&$rollbackCallbackCalled): void { + $rollbackCallbackCalled = true; + }); + + try { + $connection->rollBack(); + $this->fail('Expected the lost rollback to fail.'); + } catch (PDOException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testDisconnectExhaustsCleanupAndPreservesThePhysicalFailure(): void + { + $physicalFailure = new RuntimeException('physical rollback failure'); + $callbackFailure = new RuntimeException('rollback callback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('beginTransaction'); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException($physicalFailure); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $rollbackCallbackCalled = false; + $connection->afterRollBack(function () use (&$rollbackCallbackCalled, $callbackFailure): never { + $rollbackCallbackCalled = true; + + throw $callbackFailure; + }); + + try { + $connection->disconnect(); + $this->fail('Expected disconnect cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($physicalFailure, $exception); + } + + $this->assertTrue($rollbackCallbackCalled); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + + $connection->setPdo($pdo); + + $this->assertFalse($connection->isReusable()); + } + + public function testDisconnectTreatsLostPhysicalRollbackFailureAsAlreadyTerminal(): void + { + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException( + new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') + ); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $manager->begin('test', 1); + + $connection->disconnect(); + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFailure(): void + { + $callbackFailure = new RuntimeException('rollback callback failure'); + $pdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['inTransaction', 'rollBack']) + ->getMock(); + $pdo->expects($this->once())->method('inTransaction')->willReturn(true); + $pdo->expects($this->once())->method('rollBack')->willThrowException( + new PDOException('SQLSTATE[HY000]: General error: 7 no connection to the server') + ); + + $connection = $this->getMockConnection([], $pdo); + $connection->setReadPdo(new PDOStub); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $manager->begin('test', 1); + $manager->addCallbackForRollback(static function () use ($callbackFailure): never { + throw $callbackFailure; + }); + + try { + $connection->disconnect(); + $this->fail('Expected disconnect manager cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($callbackFailure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testRefreshRetainsConfiguredBaselinesWhenPreparationFails(): void + { + $oldPdo = new PDOStub; + $connection = new PdoConnection( + $oldPdo, + 'old_database', + 'old_', + ['name' => 'test', 'driver' => 'sqlite', 'endpoint' => 'old'] + ); + $connection->setDatabaseName('tenant_database'); + $connection->setTablePrefix('tenant_'); + $failure = new RuntimeException('read connection failed'); + $fresh = new PdoConnection( + new PDOStub, + 'fresh_database', + 'fresh_', + ['name' => 'test', 'driver' => 'sqlite', 'endpoint' => 'fresh'] + ); + $fresh->setReadPdo(static fn (): never => throw $failure); + + try { + $connection->refreshFrom($fresh); + $this->fail('Expected replacement preparation to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($oldPdo, $connection->getRawPdo()); + $this->assertSame('old', $connection->getConfig('endpoint')); + + $connection->resetForPool(); + + $this->assertSame('old_database', $connection->getDatabaseName()); + $this->assertSame('old_', $connection->getTablePrefix()); + } + + /** + * Adopt the prepared generation when transaction-manager cleanup fails. + */ + public function testRefreshAdoptsPreparedGenerationWhenManagerCleanupFails(): void + { + $oldPdo = new PDO('sqlite::memory:'); + $freshWritePdo = new PDOStub; + $freshReadPdo = new PDOStub; + $connection = new PdoConnection( + $oldPdo, + 'old_database', + 'old_', + ['name' => 'test', 'driver' => 'sqlite', 'endpoint' => 'old'] + ); + $manager = new DatabaseTransactionsManager; + $connection->setTransactionManager($manager); + $connection->beginTransaction(); + $cleanupFailure = new RuntimeException('rollback callback failure'); + $connection->afterRollBack(static fn () => throw $cleanupFailure); + $fresh = new PdoConnection( + $freshWritePdo, + 'fresh_database', + 'fresh_', + ['name' => 'test', 'driver' => 'sqlite', 'endpoint' => 'fresh'] + ); + $fresh->setReadPdo($freshReadPdo); + + try { + $connection->refreshFrom($fresh); + $this->fail('Expected transaction-manager cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($cleanupFailure, $exception); + } + + $this->assertFalse($oldPdo->inTransaction()); + $this->assertSame(0, $connection->transactionLevel()); + $this->assertCount(0, $manager->getPendingTransactions()); + $this->assertCount(0, $manager->getCommittedTransactions()); + $this->assertSame($freshWritePdo, $connection->getRawPdo()); + $this->assertSame($freshReadPdo, $connection->getRawReadPdo()); + $this->assertSame('fresh_database', $connection->getDatabaseName()); + $this->assertSame('fresh_', $connection->getTablePrefix()); + $this->assertSame('fresh', $connection->getConfig('endpoint')); + $this->assertTrue($connection->isReusable()); + + $connection->setDatabaseName('tenant_database'); + $connection->setTablePrefix('tenant_'); + $connection->resetForPool(); + + $this->assertSame('fresh_database', $connection->getDatabaseName()); + $this->assertSame('fresh_', $connection->getTablePrefix()); + } + + /** + * Keep a failed old rollback from poisoning the prepared generation. + */ + public function testRefreshAdoptsCleanPreparedGenerationWhenPhysicalCleanupFails(): void + { + $cleanupFailure = new RuntimeException('physical rollback failure'); + $oldPdo = $this->getMockBuilder(PDOStub::class) + ->onlyMethods(['inTransaction', 'rollBack']) + ->getMock(); + $oldPdo->expects($this->once())->method('inTransaction')->willReturn(true); + $oldPdo->expects($this->once())->method('rollBack')->willThrowException($cleanupFailure); + $freshWritePdo = new PDOStub; + $freshReadPdo = new PDOStub; + $connection = new PdoConnection( + $oldPdo, + 'old_database', + 'old_', + ['name' => 'test', 'driver' => 'mysql', 'endpoint' => 'old'] + ); + $fresh = new PdoConnection( + $freshWritePdo, + 'fresh_database', + 'fresh_', + ['name' => 'test', 'driver' => 'mysql', 'endpoint' => 'fresh'] + ); + $fresh->setReadPdo($freshReadPdo); + + try { + $connection->refreshFrom($fresh); + $this->fail('Expected physical cleanup to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($cleanupFailure, $exception); + } + + $this->assertSame(0, $connection->transactionLevel()); + $this->assertSame($freshWritePdo, $connection->getRawPdo()); + $this->assertSame($freshReadPdo, $connection->getRawReadPdo()); + $this->assertSame('fresh_database', $connection->getDatabaseName()); + $this->assertSame('fresh_', $connection->getTablePrefix()); + $this->assertSame('fresh', $connection->getConfig('endpoint')); + $this->assertFalse((new PdoConnection($oldPdo, 'test_database'))->isReusable()); + $this->assertTrue($connection->isReusable()); + + $connection->setDatabaseName('tenant_database'); + $connection->setTablePrefix('tenant_'); + $connection->resetForPool(); + + $this->assertSame('fresh_database', $connection->getDatabaseName()); + $this->assertSame('fresh_', $connection->getTablePrefix()); + } + + public function testResetForPoolMarksALeakedForeignKeySuppressionScopeUnknown(): void + { + $connection = $this->getMockConnection(); + + $connection->beginForeignKeyConstraintSuppression(); + $connection->resetForPool(); + + $this->assertFalse($connection->isReusable()); + $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); + + $connection->endForeignKeyConstraintSuppression(); + } + + public function testResetForPoolDoesNotResolveALazyConnectionForALeakedForeignKeySuppressionScope(): void + { + $resolutions = 0; + $connection = new PdoConnection( + static function () use (&$resolutions): PDO { + ++$resolutions; + + return new PDOStub; + }, + 'test_db', + '', + ['name' => 'test', 'driver' => 'mysql'] + ); + + $connection->beginForeignKeyConstraintSuppression(); + $connection->resetForPool(); + + $this->assertSame(0, $resolutions); + $this->assertTrue($connection->isReusable()); + $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); + + $connection->endForeignKeyConstraintSuppression(); + } + + protected function getSqliteTransactionConnection(): PdoConnection + { + return new PdoConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['name' => 'default', 'driver' => 'sqlite'] + ); + } + + protected function getMockConnection($methods = [], $pdo = null) + { + $pdo = $pdo ?: new PDOStub; + + if ($methods === []) { + $connection = new PdoConnection($pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']); + $connection->setSchemaGrammar(m::mock(SchemaGrammar::class)); + $connection->enableQueryLog(); + + return $connection; + } + + $defaults = ['getDefaultQueryGrammar', 'getDefaultPostProcessor', 'getDefaultSchemaGrammar']; + $connection = $this->getMockBuilder(PdoConnection::class)->onlyMethods(array_values(array_unique(array_merge($defaults, $methods))))->setConstructorArgs([$pdo, 'test_db', '', ['name' => 'test', 'driver' => 'mysql']])->getMock(); + $connection->method('getDefaultSchemaGrammar')->willReturn(m::mock(SchemaGrammar::class)); + $connection->enableQueryLog(); + + return $connection; + } +} + +class PDOStub extends PDO +{ + public function __construct() + { + } +} + +class EscapingPdoStub extends PDO +{ + public function __construct(protected string $role) + { + parent::__construct('sqlite::memory:'); + } + + public function quote(string $string, int $type = PDO::PARAM_STR): string|false + { + return "'{$this->role}:{$string}'"; + } +} + +class StatementPathSessionConfigurator implements SessionConfigurator +{ + public string $desiredState = 'state'; + + public int $stateCalls = 0; + + public int $applyCalls = 0; + + public function state(PdoConnection $connection): ?string + { + ++$this->stateCalls; + + return $this->desiredState; + } + + public function apply(PDO $pdo, string $state, PdoConnection $connection): void + { + ++$this->applyCalls; + } +} diff --git a/tests/Database/DatabaseProcessorTest.php b/tests/Database/DatabaseProcessorTest.php index 4e2ccd427b..c3a7c203a3 100755 --- a/tests/Database/DatabaseProcessorTest.php +++ b/tests/Database/DatabaseProcessorTest.php @@ -9,33 +9,39 @@ use Hypervel\Database\Query\Processors\Processor; use Hypervel\Tests\TestCase; use Mockery as m; -use PDO; +use RuntimeException; class DatabaseProcessorTest extends TestCase { - public function testInsertGetIdProcessing() + public function testInsertGetIdProcessing(): void { - $pdo = $this->createMock(PDOStub::class); - $pdo->expects($this->once())->method('lastInsertId')->with($this->equalTo('id'))->willReturn('1'); $connection = m::mock(Connection::class); $connection->shouldReceive('insert')->once()->with('sql', ['foo']); - $connection->shouldReceive('getPdo')->once()->andReturn($pdo); + $connection->shouldReceive('getLastInsertId')->once()->with('id')->andReturn('1'); $builder = m::mock(Builder::class); $builder->shouldReceive('getConnection')->andReturn($connection); $processor = new Processor; $result = $processor->processInsertGetId($builder, 'sql', ['foo'], 'id'); $this->assertSame(1, $result); } -} -class PDOStub extends PDO -{ - public function __construct() + public function testInsertGetIdPreservesTheConnectionFailureAfterTheInsertCompletes(): void { - } + $failure = new RuntimeException('The database driver could not retrieve the last insert ID.'); + $connection = m::mock(Connection::class); + $connection->shouldReceive('insert')->once()->with('sql', ['foo'])->andReturnTrue(); + $connection->shouldReceive('getLastInsertId')->once()->with('id')->andThrow($failure); + $builder = m::mock(Builder::class); + $builder->shouldReceive('getConnection')->twice()->andReturn($connection); - public function lastInsertId($sequence = null): string|false - { - return ''; + $exception = null; + + try { + (new Processor)->processInsertGetId($builder, 'sql', ['foo'], 'id'); + } catch (RuntimeException $thrown) { + $exception = $thrown; + } + + $this->assertSame($failure, $exception); } } diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php index b54fc182f0..8ad0f0c09c 100644 --- a/tests/Database/DatabaseSQLiteBuilderTest.php +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -130,20 +130,17 @@ public function testExecuteBlueprintWrapsAKnownRebuildAndMaintainsForeignKeyStat ['pragma foreign_keys = 0', 'first statement', 'second statement', 'pragma foreign_keys = 1'], [new Fluent(['name' => 'alter'])], ); - $pdo = m::mock(PDO::class); - $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('pretending')->once()->andReturnFalse(); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 0')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma foreign_keys = 0')->ordered(); $connection->shouldReceive('transaction') ->once() ->andReturnUsing(static fn (Closure $callback) => $callback()) ->ordered(); $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma foreign_keys = 1')->ordered(); (new SQLiteBuilder($connection))->executeBlueprint($blueprint); } @@ -160,7 +157,7 @@ public function testExecuteBlueprintPreservesDisabledForeignKeysOutsideTheTransa $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('pretending')->once()->andReturnFalse(); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('getPdo')->never(); + $connection->shouldReceive('executeSessionStatement')->never(); $connection->shouldReceive('transaction') ->once() ->andReturnUsing(static fn (Closure $callback) => $callback()); @@ -242,13 +239,10 @@ public function testExecuteBlueprintRestoresForeignKeysWhenTheTransactionFails() ['pragma foreign_keys = 0', 'failing statement', 'pragma foreign_keys = 1'], [new Fluent(['name' => 'alter'])], ); - $pdo = m::mock(PDO::class); - $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('pretending')->once()->andReturnFalse(); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 0')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma foreign_keys = 0')->ordered(); $connection->shouldReceive('transaction') ->once() ->andReturnUsing(static fn (Closure $callback) => $callback()) @@ -258,7 +252,7 @@ public function testExecuteBlueprintRestoresForeignKeysWhenTheTransactionFails() ->with('failing statement') ->andThrow(new LogicException('statement failed')) ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma foreign_keys = 1')->ordered(); $this->expectException(LogicException::class); $this->expectExceptionMessage('statement failed'); @@ -301,7 +295,7 @@ public function testExecuteBlueprintCommitsSchemaBeforeForeignKeyRestorationFail ); } - $this->assertTrue($connection->hasUnknownSessionState()); + $this->assertFalse($connection->isReusable()); $this->assertSame(0, (int) $pdo->query('pragma foreign_keys')->fetchColumn()); $this->assertSame( 1, @@ -405,7 +399,7 @@ public function testExecuteBlueprintDoesNotMutateSessionOrTransactionStateWhileP $connection->shouldReceive('pretending')->once()->andReturnTrue(); $connection->shouldReceive('transactionLevel')->never(); $connection->shouldReceive('transaction')->never(); - $connection->shouldReceive('getPdo')->never(); + $connection->shouldReceive('executeSessionStatement')->never(); $connection->shouldReceive('statement')->once()->with('pragma foreign_keys = 0')->andReturnTrue()->ordered(); $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); @@ -560,20 +554,18 @@ public function testDropAllTablesUsesGuardedCatalogCleanup(): void { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0)->ordered(); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(0)->ordered(); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0')->ordered(); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = RESET')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileRebuild('main')) @@ -587,25 +579,23 @@ public function testDropAllViewsRestoresAnEnabledWritableSchema(): void { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0)->ordered(); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(1)->ordered(); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0')->ordered(); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllViews('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = RESET')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileRebuild('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); (new SQLiteBuilder($connection))->dropAllViews(); } @@ -614,20 +604,18 @@ public function testDropAllTablesReloadsTheSchemaAfterADeleteFailure(): void { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(0); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables('main')) ->andReturnFalse() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = RESET')->ordered(); $connection->shouldReceive('statement')->with($grammar->compileRebuild('main'))->never(); $this->expectException(RuntimeException::class); @@ -642,20 +630,18 @@ public function testDropAllTablesMarksALegacySessionUnknownWhenVacuumFails(): vo { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(0); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.36.0'); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 0')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 0')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileRebuild('main')) @@ -673,20 +659,18 @@ public function testDropAllTablesKeepsAModernSessionKnownWhenVacuumFailsAfterRes { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(0); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = RESET')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileRebuild('main')) @@ -700,25 +684,26 @@ public function testDropAllTablesKeepsAModernSessionKnownWhenVacuumFailsAfterRes (new SQLiteBuilder($connection))->dropAllTables(); } - public function testDropAllTablesMarksTheSessionUnknownWhenSchemaReloadFails(): void + public function testDropAllTablesStopsBeforeSchemaReloadWhenWritableSchemaResetFails(): void { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(0); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = 1')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllTables('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturnFalse()->ordered(); - $connection->shouldReceive('markCurrentSessionStateUnknown')->once(); + $connection->shouldReceive('executeSessionStatement') + ->once() + ->with('pragma writable_schema = RESET') + ->andThrow(new RuntimeException('Failed to execute schema statement [pragma writable_schema = RESET].')) + ->ordered(); $connection->shouldReceive('statement')->with($grammar->compileRebuild('main'))->never(); $this->expectException(RuntimeException::class); @@ -727,30 +712,31 @@ public function testDropAllTablesMarksTheSessionUnknownWhenSchemaReloadFails(): (new SQLiteBuilder($connection))->dropAllTables(); } - public function testDropAllViewsMarksTheSessionUnknownWhenWritableModeRestorationFails(): void + public function testDropAllViewsPropagatesWritableModeRestorationFailure(): void { $connection = m::mock(Connection::class); $grammar = new SQLiteGrammar($connection); - $pdo = m::mock(PDO::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('scalar')->once()->with('pragma writable_schema', [], false)->andReturn(1); $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); - $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); $connection->shouldReceive('statement') ->once() ->with($grammar->compileDropAllViews('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('executeSessionStatement')->once()->with('pragma writable_schema = RESET')->ordered(); $connection->shouldReceive('statement') ->once() ->with($grammar->compileRebuild('main')) ->andReturnTrue() ->ordered(); - $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturnFalse()->ordered(); - $connection->shouldReceive('markCurrentSessionStateUnknown')->once(); + $connection->shouldReceive('executeSessionStatement') + ->once() + ->with('pragma writable_schema = 1') + ->andThrow(new RuntimeException('Failed to execute schema statement [pragma writable_schema = 1].')) + ->ordered(); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Failed to execute schema statement [pragma writable_schema = 1].'); diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index 0d50a4b061..8140074ed7 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; @@ -74,7 +75,7 @@ public function testExecuteBlueprintThrowsWhenAStatementReturnsFalse(): void public function testWithoutForeignKeyConstraintsNestsAcrossBuilderInstances(): void { $pdo = m::mock(PDO::class); - $connection = new Connection($pdo, 'test'); + $connection = new PdoConnection($pdo, 'test'); $grammar = m::mock(Grammar::class); $connection->setSchemaGrammar($grammar); @@ -96,7 +97,7 @@ public function testWithoutForeignKeyConstraintsNestsAcrossBuilderInstances(): v public function testWithoutForeignKeyConstraintsUsesTheStatementPathWhilePretending(): void { $resolutions = 0; - $connection = new Connection( + $connection = new PdoConnection( static function () use (&$resolutions): PDO { ++$resolutions; @@ -124,7 +125,7 @@ static function () use (&$resolutions): PDO { public function testWithoutForeignKeyConstraintsMarksTheSessionUnknownWhenRestorationFails(): void { $pdo = m::mock(PDO::class); - $connection = new Connection($pdo, 'test'); + $connection = new PdoConnection($pdo, 'test'); $grammar = m::mock(Grammar::class); $connection->setSchemaGrammar($grammar); @@ -143,7 +144,7 @@ public function testWithoutForeignKeyConstraintsMarksTheSessionUnknownWhenRestor ); } - $this->assertTrue($connection->hasUnknownSessionState()); + $this->assertFalse($connection->isReusable()); $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); $connection->endForeignKeyConstraintSuppression(); @@ -152,7 +153,7 @@ public function testWithoutForeignKeyConstraintsMarksTheSessionUnknownWhenRestor public function testWithoutForeignKeyConstraintsPreservesNativeExceptionChainingWhenCallbackAndRestorationFail(): void { $pdo = m::mock(PDO::class); - $connection = new Connection($pdo, 'test'); + $connection = new PdoConnection($pdo, 'test'); $grammar = m::mock(Grammar::class); $connection->setSchemaGrammar($grammar); $callbackFailure = new RuntimeException('callback failed'); @@ -173,7 +174,7 @@ public function testWithoutForeignKeyConstraintsPreservesNativeExceptionChaining $this->assertSame($callbackFailure, $exception->getPrevious()); } - $this->assertTrue($connection->hasUnknownSessionState()); + $this->assertFalse($connection->isReusable()); } public function testHasTableCorrectlyCallsGrammar() diff --git a/tests/Database/DatabaseSessionConfiguratorTest.php b/tests/Database/DatabaseSessionConfiguratorTest.php index 6bbb5553ec..32ba4d335e 100644 --- a/tests/Database/DatabaseSessionConfiguratorTest.php +++ b/tests/Database/DatabaseSessionConfiguratorTest.php @@ -11,6 +11,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\DeadlockException; use Hypervel\Database\LostConnectionException; +use Hypervel\Database\PdoConnection; use Hypervel\Database\QueryException; use Hypervel\Database\SessionConfigurator; use Hypervel\Tests\TestCase; @@ -60,9 +61,9 @@ public function testConfiguratorsRunInRegistrationOrderWithoutDeduplication(): v $calls[] = 'second'; }; - Connection::configureSessionUsing($first); - Connection::configureSessionUsing($second); - Connection::configureSessionUsing($first); + PdoConnection::configureSessionUsing($first); + PdoConnection::configureSessionUsing($second); + PdoConnection::configureSessionUsing($first); $this->connection()->getPdo(); @@ -76,14 +77,14 @@ public function testConfiguratorsRunInRegistrationOrderWithoutDeduplication(): v public function testFlushStateRemovesConfiguratorsAndPhysicalState(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->getPdo(); $this->assertSame(1, TestSessionConnection::physicalSessionStateCount()); - Connection::flushState(); + PdoConnection::flushState(); $this->assertNull(TestSessionConnection::physicalSessionStateCount()); $connection->getPdo(); @@ -96,8 +97,8 @@ public function testNullSkipsAndEmptyStringIsMemoizedAsARealState(): void { $skipped = $this->configurator(null); $empty = $this->configurator(''); - Connection::configureSessionUsing($skipped); - Connection::configureSessionUsing($empty); + PdoConnection::configureSessionUsing($skipped); + PdoConnection::configureSessionUsing($empty); $connection = $this->connection(); $connection->getPdo(); @@ -113,7 +114,7 @@ public function testNullSkipsAndEmptyStringIsMemoizedAsARealState(): void public function testMatchingStateSkipsApplyAndChangedStateReplacesTheMemo(): void { $configurator = $this->configurator('first'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->getPdo(); @@ -131,8 +132,8 @@ public function testMultipleConfiguratorsMemoizeTheirStatesIndependently(): void { $first = $this->configurator('first'); $second = $this->configurator('second'); - Connection::configureSessionUsing($first); - Connection::configureSessionUsing($second); + PdoConnection::configureSessionUsing($first); + PdoConnection::configureSessionUsing($second); $connection = $this->connection(); $connection->getPdo(); @@ -147,7 +148,7 @@ public function testMultipleConfiguratorsMemoizeTheirStatesIndependently(): void public function testReadAndWritePdosAreMemoizedIndependently(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $writePdo = $this->pdo(); $readPdo = $this->pdo(); $connection = $this->connection($writePdo); @@ -166,7 +167,7 @@ public function testReadAndWritePdosAreMemoizedIndependently(): void public function testReadFallbackAndMultipleWrappersShareThePhysicalMemo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = $this->pdo(); $connection = $this->connection($pdo); $secondConnection = $this->connection($pdo); @@ -183,7 +184,7 @@ public function testReadFallbackAndMultipleWrappersShareThePhysicalMemo(): void public function testWeakMapReleasesStateWithThePhysicalPdo(): void { - Connection::configureSessionUsing($this->configurator()); + PdoConnection::configureSessionUsing($this->configurator()); $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->getPdo(); @@ -199,7 +200,7 @@ public function testWeakMapReleasesStateWithThePhysicalPdo(): void public function testRawAccessAndInternalResolutionDoNotSynchronize(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $writeResolutions = 0; $readResolutions = 0; $writePdo = $this->pdo(); @@ -233,7 +234,7 @@ public function testRawAccessAndInternalResolutionDoNotSynchronize(): void public function testRetainedPdoIsAnExplicitUnsynchronizedEscapeHatch(): void { $configurator = $this->configurator('first'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $retainedPdo = $connection->getPdo(); @@ -252,7 +253,7 @@ public function testStateExceptionDoesNotTaintThePhysicalSession(): void $configurator = $this->configurator(); $exception = new Exception('State failed.'); $configurator->stateCallback = static fn () => throw $exception; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = $this->pdo(); $connection = $this->connection($pdo); @@ -275,8 +276,8 @@ public function testApplyFailureClearsAllMemosAndTaintsThePhysicalSession(): voi $second = $this->configurator('second'); $exception = new Exception('Apply failed.'); $second->applyCallback = static fn () => throw $exception; - Connection::configureSessionUsing($first); - Connection::configureSessionUsing($second); + PdoConnection::configureSessionUsing($first); + PdoConnection::configureSessionUsing($second); $pdo = $this->pdo(); $connection = $this->connection($pdo); @@ -299,7 +300,7 @@ public function testReentrantConfigurationFailsClosedForTheSameConnection(): voi $configurator->applyCallback = static function (PDO $pdo, string $state, Connection $connection): void { $connection->getPdo(); }; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = $this->pdo(); $connection = $this->connection($pdo); @@ -321,7 +322,7 @@ public function testReentrantConfigurationFailsClosedAcrossWrappersSharingAPdo() $configurator->applyCallback = static function () use ($otherConnection): void { $otherConnection->getPdo(); }; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection($pdo); $this->expectException(RuntimeException::class); @@ -337,7 +338,7 @@ public function testReentrantConfigurationFailsClosedAcrossWrappersSharingAPdo() public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigured(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $oldPdo = $this->pdo(); $newPdo = $this->pdo(); $connection = $this->connection($oldPdo); @@ -358,7 +359,7 @@ public function testUnknownWriteSessionIsReplacedOnceAndTheReplacementIsConfigur public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $writePdo = $this->pdo(); $oldReadPdo = $this->pdo(); $newReadPdo = $this->pdo(); @@ -378,7 +379,7 @@ public function testUnknownReadSessionRecoveryKeepsTheReadRoute(): void public function testUnknownReadFallbackRecoveryUsesTheReplacementWritePdo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $oldPdo = $this->pdo(); $newPdo = $this->pdo(); $connection = $this->connection($oldPdo); @@ -395,7 +396,7 @@ public function testUnknownReadFallbackRecoveryUsesTheReplacementWritePdo(): voi public function testUnknownSessionThatSurvivesReconnectFailsAfterOneAttempt(): void { - Connection::configureSessionUsing($this->configurator()); + PdoConnection::configureSessionUsing($this->configurator()); $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->getPdo(); @@ -417,7 +418,7 @@ public function testUnknownSessionThatSurvivesReconnectFailsAfterOneAttempt(): v public function testReentrantReconnectorCannotRecursivelyReplaceAnUnknownSession(): void { - Connection::configureSessionUsing($this->configurator()); + PdoConnection::configureSessionUsing($this->configurator()); $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->getPdo(); @@ -441,7 +442,7 @@ public function testReentrantReconnectorCannotRecursivelyReplaceAnUnknownSession public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void { - Connection::configureSessionUsing($this->configurator()); + PdoConnection::configureSessionUsing($this->configurator()); $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -465,7 +466,7 @@ public function testUnknownSessionInsideTransactionFailsWithoutReconnect(): void public function testUnknownSessionWithoutAReconnectorPreservesTheExistingFailure(): void { - Connection::configureSessionUsing($this->configurator()); + PdoConnection::configureSessionUsing($this->configurator()); $pdo = $this->pdo(); $connection = $this->connection($pdo); $connection->getPdo(); @@ -482,7 +483,7 @@ public function testConfigurationFailureIsWrappedForTheApplicationQuery(): void $configurator = $this->configurator(); $configurationException = new Exception('Configuration failed.'); $configurator->applyCallback = static fn () => throw $configurationException; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); try { @@ -503,7 +504,7 @@ public function testDirectGetterFailureIsUnwrappedAndDoesNotIncrementQueryErrors $configurator = $this->configurator(); $configurationException = new Exception('Configuration failed.'); $configurator->applyCallback = static fn () => throw $configurationException; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); try { @@ -524,7 +525,7 @@ public function testLostConnectionDuringConfigurationUsesTheExistingQueryRetry() throw new PDOException('server has gone away'); } }; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection($this->pdo()); $replacement = $this->pdo(); $reconnects = 0; @@ -543,7 +544,7 @@ public function testLostConnectionDuringConfigurationUsesTheExistingQueryRetry() public function testLostConnectionDuringConfigurationIsNotRetriedInsideATransaction(): void { $configurator = $this->configurator('first'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->beginTransaction(); $configurator->desiredState = 'second'; @@ -573,7 +574,7 @@ public function testLostConnectionDuringConfigurationUsesTheExistingBeginRetry() throw new PDOException('server has gone away'); } }; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection($this->pdo()); $replacement = $this->pdo(); $reconnects = 0; @@ -594,7 +595,7 @@ public function testDirectGetterDoesNotRetryItsCurrentConfigurationFailure(): vo { $configurator = $this->configurator(); $configurator->applyCallback = static fn () => throw new PDOException('server has gone away'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $reconnects = 0; $connection->setReconnector(static function () use (&$reconnects): void { @@ -614,7 +615,7 @@ public function testDirectGetterDoesNotRetryItsCurrentConfigurationFailure(): vo public function testSuccessfulCommitPreservesThePhysicalMemo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->beginTransaction(); @@ -629,7 +630,7 @@ public function testSuccessfulCommitPreservesThePhysicalMemo(): void public function testSuccessfulTransactionCallbackCommitPreservesThePhysicalMemo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->transaction(static fn () => null); @@ -643,7 +644,7 @@ public function testSuccessfulTransactionCallbackCommitPreservesThePhysicalMemo( public function testFullAndSavepointRollbackInvalidateThePhysicalMemo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $connection = $this->connection(); $connection->beginTransaction(); @@ -662,7 +663,7 @@ public function testFullAndSavepointRollbackInvalidateThePhysicalMemo(): void public function testInvalidRollbackLevelDoesNotResolveOrSynchronizeAPdo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $resolutions = 0; $pdo = $this->pdo(); $connection = $this->connection(function () use (&$resolutions, $pdo): PDO { @@ -735,7 +736,7 @@ public function testConcurrencyCommitFailureUsesStandaloneDetectorWithoutTaintin $this->assertFalse(Container::getInstance()->has(ConcurrencyErrorDetectorContract::class)); $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new CommitRetryPdo; $connection = $this->connection($pdo); @@ -751,7 +752,7 @@ public function testConcurrencyCommitFailureUsesStandaloneDetectorWithoutTaintin public function testNestedDriverOwnedRollbackInvalidatesWithoutIssuingAnotherRollback(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new TrackingPdo; $connection = $this->connection($pdo); $connection->getPdo(); @@ -773,7 +774,7 @@ public function testNestedDriverOwnedRollbackInvalidatesWithoutIssuingAnotherRol public function testNonLostCommitFailureMarksThePhysicalSessionUnknown(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new FailingCommitPdo; $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -792,7 +793,7 @@ public function testNonLostCommitFailureMarksThePhysicalSessionUnknown(): void public function testLostCommitFailureInvalidatesWithoutTaintingTheDeadPdo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new LostCommitPdo; $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -806,12 +807,14 @@ public function testLostCommitFailureInvalidatesWithoutTaintingTheDeadPdo(): voi $this->assertSame([], TestSessionConnection::appliedStatesForTest($pdo)); $this->assertFalse(TestSessionConnection::sessionStateIsUnknownForTest($pdo)); + $this->assertSame(0, $pdo->inTransactionCalls); + $this->assertSame(0, $pdo->rollbackCalls); } public function testNonLostRollbackFailureInvalidatesAndTaintsThePhysicalSession(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new FailingRollbackPdo; $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -831,7 +834,7 @@ public function testNonLostRollbackFailureInvalidatesAndTaintsThePhysicalSession public function testLostRollbackFailureInvalidatesWithoutTaintingTheDeadPdo(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new LostRollbackPdo; $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -846,6 +849,8 @@ public function testLostRollbackFailureInvalidatesWithoutTaintingTheDeadPdo(): v $this->assertSame([], TestSessionConnection::appliedStatesForTest($pdo)); $this->assertFalse(TestSessionConnection::sessionStateIsUnknownForTest($pdo)); $this->assertSame(0, $connection->transactionLevel()); + $this->assertSame(1, $pdo->inTransactionCalls); + $this->assertSame(1, $pdo->rollbackCalls); } public function testDisconnectDoesNotResolveLazyPdosWithoutATransaction(): void @@ -874,7 +879,7 @@ public function testDisconnectDoesNotResolveLazyPdosWithoutATransaction(): void public function testDisconnectRollbackInvalidatesStateRetainedByAnotherWrapper(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = $this->pdo(); $connection = $this->connection($pdo); $otherConnection = $this->connection($pdo); @@ -891,7 +896,7 @@ public function testDisconnectRollbackInvalidatesStateRetainedByAnotherWrapper() public function testFailedDisconnectRollbackTaintsStateAndDropsWrapperReferences(): void { $configurator = $this->configurator(); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pdo = new FailingRollbackPdo; $connection = $this->connection($pdo); $connection->beginTransaction(); @@ -952,7 +957,7 @@ public function __construct( ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { ++$this->stateCalls; @@ -961,7 +966,7 @@ public function state(Connection $connection): ?string : $this->desiredState; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { ++$this->applyCalls; $this->appliedStates[] = $state; @@ -972,7 +977,7 @@ public function apply(PDO $pdo, string $state, Connection $connection): void } } -class TestSessionConnection extends Connection +class TestSessionConnection extends PdoConnection { public function resolveWritePdo(): PDO { @@ -1132,6 +1137,10 @@ public function rollBack(): bool class LostCommitPdo extends PDO { + public int $inTransactionCalls = 0; + + public int $rollbackCalls = 0; + public function __construct() { } @@ -1145,10 +1154,28 @@ public function commit(): bool { throw new PDOException('server has gone away'); } + + public function inTransaction(): bool + { + ++$this->inTransactionCalls; + + return true; + } + + public function rollBack(): bool + { + ++$this->rollbackCalls; + + return true; + } } class LostRollbackPdo extends PDO { + public int $inTransactionCalls = 0; + + public int $rollbackCalls = 0; + public function __construct() { } @@ -1160,11 +1187,15 @@ public function beginTransaction(): bool public function inTransaction(): bool { + ++$this->inTransactionCalls; + return true; } public function rollBack(): bool { + ++$this->rollbackCalls; + throw new PDOException('server has gone away'); } } diff --git a/tests/Database/DatabaseSqliteSchemaStateTest.php b/tests/Database/DatabaseSqliteSchemaStateTest.php index 21273ffb59..9665036e04 100644 --- a/tests/Database/DatabaseSqliteSchemaStateTest.php +++ b/tests/Database/DatabaseSqliteSchemaStateTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Database; +use Hypervel\Database\Connection; use Hypervel\Database\Schema\SqliteSchemaState; use Hypervel\Database\SQLiteConnection; use Hypervel\Filesystem\Filesystem; use Hypervel\Tests\TestCase; +use LogicException; use Mockery as m; use PDO; use PHPUnit\Framework\Attributes\DataProvider; @@ -81,4 +83,17 @@ public static function inMemoryDatabaseProvider(): array 'named memory URI' => ['file:database?mode=memory'], ]; } + + public function testLoadSchemaToInMemoryRequiresPdoConnection(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getDatabaseName')->once()->andReturn(':memory:'); + + $schemaState = new SqliteSchemaState($connection, m::mock(Filesystem::class)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('In-memory SQLite schema loading requires a PDO-backed connection.'); + + $schemaState->load('database/schema/sqlite-schema.dump'); + } } diff --git a/tests/Database/PoolFactoryTest.php b/tests/Database/PoolFactoryTest.php index 9777f622fb..4dd3790ea5 100644 --- a/tests/Database/PoolFactoryTest.php +++ b/tests/Database/PoolFactoryTest.php @@ -14,7 +14,6 @@ use Hypervel\Pool\Connection; use Hypervel\Tests\TestCase; use Mockery as m; -use PDO; class PoolFactoryTest extends TestCase { @@ -271,55 +270,24 @@ public function testReadConnectionUsesBasePoolWhenReadConfigIsMissingOrNull(): v $this->assertTrue($factory->hasPool('default::read')); } - public function testPoolConnectTimeoutConfiguresMySqlDriversWithoutOverridingNativeOptions(): void + public function testPoolConnectTimeoutIsExposedWithoutLosingFractionalPrecision(): void { - foreach (['mysql', 'mariadb'] as $driver) { + foreach (['mysql', 'mariadb', 'pgsql', 'sqlite'] as $driver) { $config = $this->connectionConfig(['driver' => $driver]); $config['pool']['connect_timeout'] = 1.25; $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $poolConfig = $pool->configForTest(); - $this->assertArrayHasKey('options', $poolConfig); - $this->assertSame(2, $poolConfig['options'][PDO::ATTR_TIMEOUT]); + $this->assertSame(1.25, $pool->configForTest()['connect_timeout']); - $config['options'][PDO::ATTR_TIMEOUT] = 7; + $config['connect_timeout'] = 7.5; $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $this->assertSame(7, $pool->configForTest()['options'][PDO::ATTR_TIMEOUT]); + $this->assertSame(7.5, $pool->configForTest()['connect_timeout']); } } - public function testPoolConnectTimeoutConfiguresPostgresWithoutOverridingNativeConfig(): void - { - $config = $this->connectionConfig(['driver' => 'pgsql']); - $config['pool']['connect_timeout'] = 1.25; - $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); - - $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $poolConfig = $pool->configForTest(); - $this->assertArrayHasKey('connect_timeout', $poolConfig); - $this->assertSame(2, $poolConfig['connect_timeout']); - - $config['connect_timeout'] = 7; - $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); - - $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $this->assertSame(7, $pool->configForTest()['connect_timeout']); - } - - public function testPoolConnectTimeoutDoesNotAddNetworkOptionsToSqlite(): void - { - $config = $this->connectionConfig(['driver' => 'sqlite']); - $config['pool']['connect_timeout'] = 1.25; - $pool = (new PoolFactory($this->mockContainerWithPools(['default' => $config])))->getPool('default'); - - $this->assertInstanceOf(PoolFactoryTestPool::class, $pool); - $this->assertArrayNotHasKey('connect_timeout', $pool->configForTest()); - $this->assertArrayNotHasKey(PDO::ATTR_TIMEOUT, $pool->configForTest()['options'] ?? []); - } - public function testFlushPoolResolvesWriteAliasToBasePool(): void { $container = $this->mockContainerWithPools(); diff --git a/tests/Database/QueryDurationThresholdTest.php b/tests/Database/QueryDurationThresholdTest.php index ba524d26dc..8b5f1e5203 100644 --- a/tests/Database/QueryDurationThresholdTest.php +++ b/tests/Database/QueryDurationThresholdTest.php @@ -7,6 +7,7 @@ use Carbon\CarbonInterval; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; @@ -27,7 +28,7 @@ public function testElapsedQueryTimeUsesTheMonotonicClock(): void public function testItCanHandleReachingADurationThresholdInTheDb() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = 0; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1.1), function () use (&$called) { @@ -44,7 +45,7 @@ public function testItCanHandleReachingADurationThresholdInTheDb() public function testItIsOnlyCalledOnce() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = 0; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { @@ -60,7 +61,7 @@ public function testItIsOnlyCalledOnce() public function testItIsOnlyCalledOnceWhenHandlerRunsAnotherQuery() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = 0; @@ -80,7 +81,7 @@ public function testItIsOnlyCalledOnceWhenGivenDateTime() { CarbonImmutable::setTestNow($this->now = CarbonImmutable::create(2017, 6, 27, 13, 14, 15, 'UTC')); - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = 0; $connection->whenQueryingForLongerThan($this->now->addMilliseconds(1), function () use (&$called) { @@ -96,7 +97,7 @@ public function testItIsOnlyCalledOnceWhenGivenDateTime() public function testItCanSpecifyMultipleHandlersWithTheSameIntervals() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = []; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { @@ -117,7 +118,7 @@ public function testItCanSpecifyMultipleHandlersWithTheSameIntervals() public function testItCanSpecifyMultipleHandlersWithDifferentIntervals() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = []; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { @@ -142,7 +143,7 @@ public function testItCanSpecifyMultipleHandlersWithDifferentIntervals() public function testItHasAccessToConnectionInHandler() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'expected-name']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'expected-name']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $name = null; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function ($connection) use (&$name) { @@ -157,7 +158,7 @@ public function testItHasAccessToConnectionInHandler() public function testItHasSpecifyThresholdWithFloat() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = false; $connection->whenQueryingForLongerThan(1.1, function () use (&$called) { @@ -173,7 +174,7 @@ public function testItHasSpecifyThresholdWithFloat() public function testItHasSpecifyThresholdWithInt() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = false; $connection->whenQueryingForLongerThan(2, function () use (&$called) { @@ -189,7 +190,7 @@ public function testItHasSpecifyThresholdWithInt() public function testItCanResetTotalQueryDuration() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $connection->logQuery('xxxx', [], 1.1); @@ -203,7 +204,7 @@ public function testItCanResetTotalQueryDuration() public function testItCanRestoreAlreadyRunHandlers() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $called = 0; $connection->whenQueryingForLongerThan(CarbonInterval::milliseconds(1), function () use (&$called) { @@ -230,7 +231,7 @@ public function testItCanRestoreAlreadyRunHandlers() public function testItCanAccessAllQueriesWhenQueryLoggingIsActive() { - $connection = new Connection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); + $connection = new PdoConnection(new PDO('sqlite::memory:'), '', '', ['name' => 'sqlite']); $connection->setEventDispatcher($this->app->make(Dispatcher::class)); $connection->enableQueryLog(); $queries = []; @@ -251,7 +252,7 @@ public function testItCanAccessAllQueriesWhenQueryLoggingIsActive() } } -class QueryDurationConnection extends Connection +class QueryDurationConnection extends PdoConnection { public function elapsedTimeSince(float $start): float { diff --git a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php index 7838121c4e..0352a6d4f5 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php @@ -78,7 +78,7 @@ public function testCastAsJsonUsesSpecifiedConnection(): void $connection->shouldReceive('raw')->once()->andReturnUsing( static fn (string $value): Expression => new Expression($value), ); - $connection->shouldReceive('getPdo->quote')->once()->andReturnUsing( + $connection->shouldReceive('escape')->once()->andReturnUsing( static fn (string $value): string => "'{$value}'", ); diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 88afa91bb9..dbd1d6938b 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -111,10 +111,10 @@ public function testResetCachedConnectionsCompletesEveryDiscardBeforeRethrowing( $firstConnection = m::mock(Connection::class); $firstConnection->shouldReceive('resetForPool')->once(); - $firstConnection->shouldReceive('hasUnknownSessionState')->once()->andReturnTrue(); + $firstConnection->shouldReceive('isReusable')->once()->andReturnFalse(); $secondConnection = m::mock(Connection::class); $secondConnection->shouldReceive('resetForPool')->once(); - $secondConnection->shouldReceive('hasUnknownSessionState')->once()->andReturnTrue(); + $secondConnection->shouldReceive('isReusable')->once()->andReturnFalse(); $failure = new RuntimeException('discard failed'); $firstPooledConnection = m::mock(PooledConnection::class); $firstPooledConnection->shouldReceive('discard')->once()->andThrow($failure); diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index 0a5a616978..e7fc9447cf 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -9,12 +9,14 @@ use Hypervel\Contracts\Events\Dispatcher; 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\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Tests\TestCase; +use LogicException; use Mockery as m; use PDO; @@ -199,8 +201,8 @@ public function testCachesAndRestoresConfiguredInMemoryConnections(): void { $defaultPdo = m::mock(PDO::class); $namedPdo = m::mock(PDO::class); - $sourceDefault = m::mock(Connection::class); - $sourceNamed = m::mock(Connection::class); + $sourceDefault = m::mock(PdoConnection::class); + $sourceNamed = m::mock(PdoConnection::class); $sourceDefault->shouldReceive('getPdo')->once()->andReturn($defaultPdo); $sourceNamed->shouldReceive('getPdo')->once()->andReturn($namedPdo); @@ -225,8 +227,8 @@ public function testCachesAndRestoresConfiguredInMemoryConnections(): void $this->cacheInMemoryDatabases(); $dispatcher = m::mock(Dispatcher::class); - $restoredDefault = m::mock(Connection::class); - $restoredNamed = m::mock(Connection::class); + $restoredDefault = m::mock(PdoConnection::class); + $restoredNamed = m::mock(PdoConnection::class); $restoredDefault->shouldReceive('setPdo')->once()->with($defaultPdo)->andReturnSelf(); $restoredDefault->shouldReceive('setEventDispatcher')->once()->with($dispatcher)->andReturnSelf(); $restoredNamed->shouldReceive('setPdo')->once()->with($namedPdo)->andReturnSelf(); @@ -255,6 +257,49 @@ public function testCachesAndRestoresConfiguredInMemoryConnections(): void ], RefreshDatabaseState::$inMemoryConnections); } + public function testCachingInMemoryConnectionRequiresPdoConnection(): void + { + $this->app->instance('config', new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], + ], + ], + ])); + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn(m::mock(Connection::class)); + $this->app->instance('db', $database); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('In-memory SQLite database testing requires a PDO-backed connection.'); + + $this->cacheInMemoryDatabases(); + } + + public function testRestoringInMemoryConnectionRequiresPdoConnection(): void + { + $this->app->instance('config', new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], + ], + ], + ])); + RefreshDatabaseState::$inMemoryConnections = ['default' => m::mock(PDO::class)]; + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn(m::mock(Connection::class)); + $this->app->instance('db', $database); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('In-memory SQLite database testing requires a PDO-backed connection.'); + + $this->restoreInMemoryDatabases(); + } + private function arrangeConnection( ?array &$actual, array $allTables, diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index 9df6390f0d..8de76bdf4d 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -7,9 +7,9 @@ use Hypervel\Config\Repository; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\Connection as DatabaseConnection; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\DatabaseManager; +use Hypervel\Database\PdoConnection; use Hypervel\Foundation\Application; use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\RefreshDatabase; @@ -17,6 +17,7 @@ use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; +use LogicException; use Mockery as m; use PDO; use RuntimeException; @@ -238,7 +239,7 @@ public function testBeginDatabaseTransactionWorkSetsMigratedAndCachesPdoTogether $pdo = m::mock(PDO::class); $eventDispatcher = m::mock(Dispatcher::class); - $connection = m::mock(ConnectionInterface::class); + $connection = m::mock(PdoConnection::class); $connection->shouldReceive('setTransactionManager')->once(); $connection->shouldReceive('getPdo')->once()->andReturn($pdo); $connection->shouldReceive('getEventDispatcher')->once()->andReturn($eventDispatcher); @@ -277,7 +278,7 @@ public function testRestoreInMemoryDatabaseUsesResolvedDefaultConnectionName(): { $pdo = m::mock(PDO::class); $eventDispatcher = m::mock(Dispatcher::class); - $connection = m::mock(DatabaseConnection::class); + $connection = m::mock(PdoConnection::class); $connection->shouldReceive('setPdo')->once()->with($pdo)->andReturnSelf(); $connection->shouldReceive('setEventDispatcher')->once()->with($eventDispatcher)->andReturnSelf(); @@ -341,7 +342,7 @@ public function testBeginDatabaseTransactionWorkCachesOnlyNamedInMemoryConnectio $memoryPdo = m::mock(PDO::class); $eventDispatcher = m::mock(Dispatcher::class); $fileConnection = m::mock(ConnectionInterface::class); - $memoryConnection = m::mock(ConnectionInterface::class); + $memoryConnection = m::mock(PdoConnection::class); foreach ([$fileConnection, $memoryConnection] as $connection) { $connection->shouldReceive('setTransactionManager')->once(); @@ -384,6 +385,56 @@ public function testBeginDatabaseTransactionWorkCachesOnlyNamedInMemoryConnectio ); } + public function testBeginDatabaseTransactionWorkRequiresPdoForInMemoryConnection(): void + { + $connection = m::mock(ConnectionInterface::class); + $connection->shouldReceive('setTransactionManager')->once(); + + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); + + $this->app = new Application; + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], + ], + ], + ])); + $this->app->singleton('db', fn () => $database); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('In-memory SQLite database testing requires a PDO-backed connection.'); + + $this->beginDatabaseTransactionWork(); + } + + public function testRestoreInMemoryDatabaseRequiresPdoConnection(): void + { + $pdo = m::mock(PDO::class); + $connection = m::mock(ConnectionInterface::class); + $database = m::mock(DatabaseManager::class); + $database->shouldReceive('connection')->once()->with(null)->andReturn($connection); + RefreshDatabaseState::$inMemoryConnections = ['default' => $pdo]; + + $this->app = new Application; + $this->app->singleton('config', fn () => new Repository([ + 'database' => [ + 'default' => 'default', + 'connections' => [ + 'default' => ['driver' => 'sqlite', 'database' => ':memory:'], + ], + ], + ])); + $this->app->singleton('db', fn () => $database); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('In-memory SQLite database testing requires a PDO-backed connection.'); + + $this->restoreInMemoryDatabase(); + } + public function testRefreshTestDatabaseLeavesMigratedFalseWhenTransactionWorkNotYetRun(): void { // Regression test for the skip-window scenario: a RunTestsInCoroutine @@ -450,12 +501,9 @@ protected function getMockedDatabase(): DatabaseManager $connection->shouldReceive('setTransactionManager') ->once(); - $pdo = m::mock(PDO::class); - $pdo->shouldReceive('inTransaction') - ->andReturn(true); - $connection->shouldReceive('getPdo') + $connection->shouldReceive('inTransaction') ->once() - ->andReturn($pdo); + ->andReturnTrue(); $db = m::mock(DatabaseManager::class); $db->shouldReceive('connection') diff --git a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php index 1bf111b276..40b8c2cfde 100644 --- a/tests/Integration/Database/ConnectionCoroutineSafetyTest.php +++ b/tests/Integration/Database/ConnectionCoroutineSafetyTest.php @@ -5,11 +5,14 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Context\CoroutineContext; +use Hypervel\Coroutine\Coroutine; use Hypervel\Coroutine\WaitGroup; use Hypervel\Database\Connection; +use Hypervel\Database\ConnectionResolver; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\Database\DatabaseManager; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Pool\DbPool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\Schema\Blueprint; @@ -357,6 +360,125 @@ public function testUsingConnectionAffectsConnectionResolver(): void $this->assertSame($originalDefault, $resolver->getDefaultConnection()); } + public function testCopiedChildrenBorrowIndependentDatabaseConnections(): void + { + CoroutineContext::set( + ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, + 'sqlite_readwrite_pool', + ); + $parentConnection = DB::connection(); + $childrenReady = new Channel(2); + $releaseChildren = new Channel(2); + + $childCoroutineIds = [ + go(static function () use ($childrenReady, $releaseChildren): void { + $connection = DB::connection(); + $childrenReady->push([$connection, $connection->getName()]); + $releaseChildren->pop(); + }, copyContext: true), + go(static function () use ($childrenReady, $releaseChildren): void { + $connection = DB::connection(); + $childrenReady->push([$connection, $connection->getName()]); + $releaseChildren->pop(); + }, copyContext: true), + ]; + + try { + [$firstChildConnection, $firstChildName] = $childrenReady->pop(1.0); + [$secondChildConnection, $secondChildName] = $childrenReady->pop(1.0); + + $this->assertSame('sqlite_readwrite_pool', $firstChildName); + $this->assertSame('sqlite_readwrite_pool', $secondChildName); + $this->assertNotSame($parentConnection, $firstChildConnection); + $this->assertNotSame($parentConnection, $secondChildConnection); + $this->assertNotSame($firstChildConnection, $secondChildConnection); + } finally { + $releaseChildren->push(true); + $releaseChildren->push(true); + + Coroutine::join($childCoroutineIds, 1.0); + } + + foreach ($childCoroutineIds as $childCoroutineId) { + $this->assertFalse(Coroutine::exists($childCoroutineId)); + } + } + + public function testDetachedCopiedChildOwnsItsSingleSlotPoolCheckout(): void + { + $allowChildCheckout = new Channel(1); + $childBorrowed = new Channel(1); + $releaseChild = new Channel(1); + $childCoroutineId = new Channel(1); + + $parentCoroutineId = go(static function () use ( + $allowChildCheckout, + $childBorrowed, + $releaseChild, + $childCoroutineId, + ): void { + DB::connection('session_context_pool'); + + $childCoroutineId->push(go(static function () use ( + $allowChildCheckout, + $childBorrowed, + $releaseChild, + ): void { + $allowChildCheckout->pop(); + DB::connection('session_context_pool'); + $childBorrowed->push(true); + $releaseChild->pop(); + }, copyContext: true)); + }); + + $contenderBorrowed = new Channel(1); + $releaseContender = new Channel(1); + $detachedChildCoroutineId = null; + $contenderCoroutineId = null; + $parentStillRunning = true; + $contenderAcquiredWhileChildHeld = null; + + try { + $detachedChildCoroutineId = $childCoroutineId->pop(1.0); + $this->assertIsInt($detachedChildCoroutineId); + Coroutine::join([$parentCoroutineId], 1.0); + $parentStillRunning = Coroutine::exists($parentCoroutineId); + + $allowChildCheckout->push(true); + $this->assertTrue($childBorrowed->pop(1.0)); + + $contenderCoroutineId = go(static function () use ($contenderBorrowed, $releaseContender): void { + DB::connection('session_context_pool'); + $contenderBorrowed->push(true); + $releaseContender->pop(); + }); + + $contenderAcquiredWhileChildHeld = $contenderBorrowed->pop(0.05); + $releaseChild->push(true); + + if ($contenderAcquiredWhileChildHeld === false) { + $this->assertTrue($contenderBorrowed->pop(1.0)); + } + } finally { + $allowChildCheckout->push(true, 0.01); + $releaseChild->push(true, 0.01); + $releaseContender->push(true, 0.01); + + Coroutine::join(array_values(array_filter([ + $parentCoroutineId, + $detachedChildCoroutineId, + $contenderCoroutineId, + ], is_int(...))), 1.0); + } + + $this->assertFalse($parentStillRunning); + $this->assertIsInt($detachedChildCoroutineId); + $this->assertIsInt($contenderCoroutineId); + $this->assertFalse(Coroutine::exists($detachedChildCoroutineId)); + $this->assertFalse(Coroutine::exists($contenderCoroutineId)); + $this->assertFalse($contenderAcquiredWhileChildHeld); + } + public function testBeforeExecutingCallbackIsCalled(): void { $called = false; @@ -432,7 +554,7 @@ public function testPooledConnectionHasTransactionManager(): void public function testSessionConfiguratorReadsCoroutineContextOnEachPooledHandOut(): void { $configurator = new CoroutineSessionConfigurator('session_context_pool'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'session_context_pool'); $firstFinished = new Channel(1); @@ -497,12 +619,12 @@ public function testOverlappingConfigurationOfSharedPdoFailsClosedForBothCallers { $connectionName = 'session_shared_connection'; $configurator = new CoroutineSessionConfigurator($connectionName); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); CoroutineContext::set(CoroutineSessionConfigurator::CONTEXT_KEY, '0'); $pdo = new PDO('sqlite::memory:'); $config = ['name' => $connectionName]; - $firstConnection = new Connection($pdo, ':memory:', '', $config); - $secondConnection = new Connection($pdo, ':memory:', '', $config); + $firstConnection = new PdoConnection($pdo, ':memory:', '', $config); + $secondConnection = new PdoConnection($pdo, ':memory:', '', $config); $configurationStarted = new Channel(1); $resumeConfiguration = new Channel(1); $configurator->blockedState = '101'; @@ -644,7 +766,7 @@ public function __construct( ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { ++$this->stateCalls; @@ -653,7 +775,7 @@ public function state(Connection $connection): ?string : null; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { ++$this->applyCalls; $this->appliedStates[] = $state; diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 26b7401aea..7f3d51dc16 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -6,11 +6,14 @@ use Closure; use Exception; +use Generator; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; use Hypervel\Database\Events\ConnectionEstablished; +use Hypervel\Database\MySqlConnection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Pool\DbPool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\SessionConfigurator; @@ -201,15 +204,20 @@ public function testDerivedReadPoolForFileBackedSqliteUsesReadConfig(): void $writePath = $directory . '/write.sqlite'; touch($readPath); touch($writePath); + $pool = null; + $pooledConnection = null; try { $this->app->make('config')->set('database.connections.file_read_pool_test', [ 'driver' => 'sqlite', + 'prefix' => 'base_', 'read' => [ 'database' => $readPath, + 'prefix' => 'read_', ], 'write' => [ 'database' => $writePath, + 'prefix' => 'write_', ], 'pool' => [ 'min_connections' => 1, @@ -226,10 +234,27 @@ public function testDerivedReadPoolForFileBackedSqliteUsesReadConfig(): void $this->assertSame('file_read_pool_test', $connection->getName()); $this->assertSame($readPath, $connection->getConfig('database')); + $this->assertSame($readPath, $connection->getDatabaseName()); + $this->assertSame('read_', $connection->getTablePrefix()); $this->assertSame('read', $connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $connection->setDatabaseName('tenant_database'); + $connection->setTablePrefix('tenant_'); + $releasedConnection = $pooledConnection; $pooledConnection->release(); + $pooledConnection = null; + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $connection = $pooledConnection->getConnection(); + + $this->assertSame($releasedConnection, $pooledConnection); + $this->assertSame($readPath, $connection->getDatabaseName()); + $this->assertSame('read_', $connection->getTablePrefix()); + $this->assertSame('read', $connection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); } finally { + $pooledConnection?->release(); + $pool?->close(); $filesystem->deleteDirectory($directory); } } @@ -389,7 +414,7 @@ public function testReleaseRollsBackOpenTransactions(): void public function testCleanReleasePreservesMatchingPhysicalSessionState(): void { $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ @@ -425,7 +450,7 @@ public function testCleanReleasePreservesMatchingPhysicalSessionState(): void public function testAbandonedTransactionRollbackInvalidatesPhysicalSessionState(): void { $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ @@ -452,7 +477,7 @@ public function testAbandonedTransactionRollbackInvalidatesPhysicalSessionState( public function testUnknownSessionIsMarkedInvalidAtFinalReleaseBoundary(): void { $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ @@ -483,7 +508,7 @@ public function testUnknownSessionIsMarkedInvalidAtFinalReleaseBoundary(): void public function testUnknownReadSessionIsDetectedWithoutResolvingUnopenedPdos(): void { $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ @@ -524,7 +549,7 @@ public function testUnknownStateCaughtByReleaseListenerIsStillMarkedInvalid(): v ReleaseConnection::class, ]); $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); $configurator->desiredState = 'fail'; $configurator->applyCallback = static fn () => throw new Exception('Configuration failed.'); @@ -574,7 +599,7 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v $configurator = new PoolSessionConfigurator('session_reconnect_test'); $configurationException = new Exception('Configuration failed.'); $configurator->applyCallback = static fn () => throw $configurationException; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'session_reconnect_test'); $pooledConnection = null; @@ -677,7 +702,7 @@ public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid( ], ]); $configurator = new PoolSessionConfigurator('session_refresh_failure_test'); - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'session_refresh_failure_test'); $pooledConnection = null; @@ -777,7 +802,7 @@ static function () use (&$connectionEstablished): void { public function testHeartbeatDoesNotComputeOrInvalidateSessionState(): void { $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); + PdoConnection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); $stateCallsAfterCreation = $configurator->stateCalls; $applyCallsAfterCreation = $configurator->applyCalls; @@ -1077,8 +1102,8 @@ public function testSharedPdoDataVisibleAcrossConnections(): void public function testReconnectHonoursFactoryExtensions(): void { // Use a file-based SQLite connection so reconnect() takes the - // factory->make() path (not the makeSqliteFromSharedPdo() path - // that in-memory SQLite uses). + // factory->make() path rather than the shared-PDO path used by + // pooled in-memory SQLite. $filesystem = new Filesystem; $directory = ParallelTesting::tempDir('PooledConnectionTest-extension'); $filesystem->deleteDirectory($directory); @@ -1086,6 +1111,7 @@ public function testReconnectHonoursFactoryExtensions(): void $databasePath = $directory . '/extension.sqlite'; touch($databasePath); + $pooledConnection = null; try { $this->app->make('config')->set('database.connections.extension_test', [ @@ -1102,27 +1128,156 @@ public function testReconnectHonoursFactoryExtensions(): void ], ]); - $custom = new SQLiteConnection( - new PDO('sqlite::memory:'), - ':memory:', - '', - ['name' => 'extension_test'] - ); - /** @var ConnectionFactory $factory */ $factory = $this->app->make('db.factory'); - $factory->extend('sqlite', fn () => $custom); + $resolutions = 0; + $factory->extend('sqlite', static function (array $config) use (&$resolutions): SQLiteConnection { + ++$resolutions; + + return new SQLiteConnection( + new PDO('sqlite:' . $config['database']), + $config['database'], + $config['prefix'], + $config + ); + }); $pool = new DbPool($this->app, 'extension_test'); $pooledConnection = $this->createPooledConnectionForName($pool, 'extension_test'); + $connection = $pooledConnection->getConnection(); + $firstPdo = $connection->getPdo(); + + // Reconnecting through the pool should consult the factory extension. + $connection->setPdo(null); + $connection->reconnectIfMissingConnection(); - // reconnect() calls factory->make() which should consult the extension - $this->assertSame($custom, $pooledConnection->getConnection()); + $this->assertSame($connection, $pooledConnection->getConnection()); + $this->assertNotSame($firstPdo, $connection->getPdo()); + $this->assertSame(2, $resolutions); } finally { + $pooledConnection?->close(); $filesystem->deleteDirectory($directory); } } + public function testConfigFirstNonPdoExtensionSupportsTheCompletePoolLifecycle(): void + { + $this->app->make('config')->set('database.connections.neutral_pool_test', [ + 'driver' => 'neutral', + 'database' => 'first', + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 1, + 'heartbeat' => -1, + ], + ]); + + /** @var ConnectionFactory $factory */ + $factory = $this->app->make('db.factory'); + $resolutions = 0; + $factory->extend('neutral', static function (array $config) use (&$resolutions): NeutralPoolConnection { + return new NeutralPoolConnection(++$resolutions, $config['database'], $config['prefix'], $config); + }); + + $pool = new DbPool($this->app, 'neutral_pool_test'); + $pooledConnection = null; + + try { + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $connection = $pooledConnection->getConnection(); + + $this->assertInstanceOf(NeutralPoolConnection::class, $connection); + $this->assertSame(1, $connection->generation); + $this->assertTrue($pooledConnection->ping(1.0)); + $this->assertSame(1, $connection->pingCalls); + + $connection->dropResources(); + $connection->reconnectIfMissingConnection(); + + $this->assertSame($connection, $pooledConnection->getConnection()); + $this->assertSame(2, $connection->generation); + $this->assertSame(2, $resolutions); + $this->assertSame(1, $connection->disconnectCalls); + + $pooledConnection->release(); + $pooledConnection = null; + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $this->assertSame($connection, $pooledConnection->getConnection()); + + $pooledConnection->release(); + $pooledConnection = null; + $pool->close(); + + $this->assertSame(2, $connection->disconnectCalls); + } finally { + $pooledConnection?->release(); + $pool->close(); + } + } + + public function testReleaseClearsCapturedMySqlInsertIdBeforeReborrow(): void + { + $this->app->make('config')->set('database.connections.mysql_insert_id_pool_test', [ + 'driver' => 'mysql_insert_id', + 'database' => 'unused', + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 1, + 'heartbeat' => -1, + ], + ]); + + /** @var ConnectionFactory $factory */ + $factory = $this->app->make('db.factory'); + $factory->extend( + 'mysql_insert_id', + static fn (array $config): PoolMySqlConnection => new PoolMySqlConnection( + new PDO('sqlite::memory:'), + $config['database'], + $config['prefix'], + $config + ) + ); + + $pool = new DbPool($this->app, 'mysql_insert_id_pool_test'); + $pooledConnection = null; + + try { + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $connection = $pooledConnection->getConnection(); + $this->assertInstanceOf(PoolMySqlConnection::class, $connection); + $connection->rememberLastInsertId(42); + $this->assertSame(42, $connection->getLastInsertId()); + + $pooledConnection->release(); + $pooledConnection = null; + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $this->assertSame($connection, $pooledConnection->getConnection()); + + $exception = null; + + try { + $connection->getLastInsertId(); + } catch (RuntimeException $runtimeException) { + $exception = $runtimeException; + } + + $this->assertNotNull($exception); + $this->assertSame('No last insert ID has been captured for this connection.', $exception->getMessage()); + } finally { + $pooledConnection?->release(); + $pool->close(); + } + } + /** * Create a PooledConnection directly (bypassing pool.get() for unit-style tests). */ @@ -1179,7 +1334,7 @@ public function __construct( ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { ++$this->stateCalls; @@ -1188,7 +1343,7 @@ public function state(Connection $connection): ?string : null; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { ++$this->applyCalls; @@ -1197,3 +1352,111 @@ public function apply(PDO $pdo, string $state, Connection $connection): void } } } + +class NeutralPoolConnection extends Connection +{ + public int $pingCalls = 0; + + public int $disconnectCalls = 0; + + private bool $hasResources = true; + + public function __construct( + public int $generation, + string $database, + string $tablePrefix, + array $config, + ) { + parent::__construct($database, $tablePrefix, $config); + } + + public function select(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): array + { + return []; + } + + public function cursor(string $query, array $bindings = [], bool $useReadPdo = true, array $fetchUsing = []): Generator + { + yield from []; + } + + public function statement(string $query, array $bindings = []): bool + { + return true; + } + + public function affectingStatement(string $query, array $bindings = []): int + { + return 0; + } + + public function unprepared(string $query): bool + { + return true; + } + + public function ping(): bool + { + ++$this->pingCalls; + + return $this->hasResources; + } + + public function inTransaction(): bool + { + return false; + } + + public function getServerVersion(): string + { + return 'test'; + } + + public function dropResources(): void + { + $this->hasResources = false; + } + + protected function escapeString(string $value): string + { + return "'" . str_replace("'", "''", $value) . "'"; + } + + protected function hasDriverResources(): bool + { + return $this->hasResources; + } + + protected function disconnectDriverResources(): void + { + ++$this->disconnectCalls; + $this->forgetDriverResources(); + } + + protected function forgetDriverResources(): void + { + $this->hasResources = false; + } + + protected function replaceDriverResources(Connection $fresh): void + { + /** @var self $fresh */ + $generation = $fresh->generation; + $hasResources = $fresh->hasResources; + + try { + $this->disconnectDriverResources(); + } finally { + $this->generation = $generation; + $this->hasResources = $hasResources; + } + } +} + +class PoolMySqlConnection extends MySqlConnection +{ + public function rememberLastInsertId(int|string $lastInsertId): void + { + $this->lastInsertId = $lastInsertId; + } +} diff --git a/tests/Integration/Database/Postgres/SessionConfiguratorTest.php b/tests/Integration/Database/Postgres/SessionConfiguratorTest.php index 917cd0c732..d745df09ba 100644 --- a/tests/Integration/Database/Postgres/SessionConfiguratorTest.php +++ b/tests/Integration/Database/Postgres/SessionConfiguratorTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Connection; use Hypervel\Database\Connectors\ConnectionFactory; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Pool\DbPool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\QueryException; @@ -51,7 +52,7 @@ protected function setUp(): void parent::setUp(); $this->configurator = new PostgresSessionConfigurator(self::CONNECTION_NAME); - Connection::configureSessionUsing($this->configurator); + PdoConnection::configureSessionUsing($this->configurator); $this->sessionPool = new DbPool($this->app, self::CONNECTION_NAME); } @@ -239,7 +240,7 @@ public function __construct( ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { if ($connection->getName() !== $this->connectionName) { return null; @@ -250,7 +251,7 @@ public function state(Connection $connection): ?string return $this->desiredState; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { ++$this->applyCalls; $this->appliedStates[] = $state; diff --git a/tests/Integration/Database/SessionConfiguratorTest.php b/tests/Integration/Database/SessionConfiguratorTest.php index bb7a9c04cb..dee5d09bc8 100644 --- a/tests/Integration/Database/SessionConfiguratorTest.php +++ b/tests/Integration/Database/SessionConfiguratorTest.php @@ -9,6 +9,7 @@ use Hypervel\Database\Connection; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Events\StatementPrepared; +use Hypervel\Database\PdoConnection; use Hypervel\Database\Pool\DbPool; use Hypervel\Database\Pool\PooledConnection; use Hypervel\Database\SessionConfigurator; @@ -59,7 +60,7 @@ protected function setUp(): void parent::setUp(); $this->configurator = new CrossDriverSessionConfigurator(self::CONNECTION_NAME, $this->driver); - Connection::configureSessionUsing($this->configurator); + PdoConnection::configureSessionUsing($this->configurator); $this->sessionPool = new DbPool($this->app, self::CONNECTION_NAME); } @@ -230,7 +231,7 @@ public function __construct( ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { if ($connection->getName() !== $this->connectionName) { return null; @@ -241,7 +242,7 @@ public function state(Connection $connection): ?string return $this->desiredState; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { ++$this->applyCalls; diff --git a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php b/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php index 6353371fe5..254bda7e4a 100644 --- a/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php +++ b/tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php @@ -13,13 +13,12 @@ use Hypervel\Database\Events\QueryExecuted; use Hypervel\Database\Pool\DbPool; use Hypervel\Database\Pool\PooledConnection; +use Hypervel\Database\SQLiteConnection; use Hypervel\Engine\Coroutine; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\ClassInvoker; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; -use PDO; -use PDOStatement; use Psr\Log\AbstractLogger; use ReflectionProperty; use RuntimeException; @@ -326,14 +325,24 @@ public function testHeartbeatDiscardsInvalidIdleConnectionBelowMinimum(): void public function testHeartbeatPingTimeoutDiscardsWithoutRequeueingLateCompletion(): void { run(function () { - SlowHeartbeatPdo::$coroutineId = null; + SlowHeartbeatConnection::$coroutineId = null; + + $this->app->make('db.factory')->extend( + 'heartbeat_test', + static fn (array $config): SlowHeartbeatConnection => new SlowHeartbeatConnection( + static fn () => throw new RuntimeException('The slow heartbeat test must not resolve its PDO.'), + $config['database'], + $config['prefix'], + $config, + ) + ); $pool = $this->createPool([ 'min_connections' => 1, 'max_connections' => 1, 'heartbeat' => -1, 'heartbeat_timeout' => 0.001, - ], SlowHeartbeatDbPool::class); + ]); $pooledConnection = $pool->get(); $pooledConnection->release(); @@ -345,12 +354,12 @@ public function testHeartbeatPingTimeoutDiscardsWithoutRequeueingLateCompletion( $this->assertLessThan(0.2, $elapsed); $this->assertSame(0, $pool->getCurrentConnections()); $this->assertSame(0, $pool->getConnectionsInChannel()); - $this->assertIsInt(SlowHeartbeatPdo::$coroutineId); + $this->assertIsInt(SlowHeartbeatConnection::$coroutineId); $deadline = microtime(true) + 0.1; - while (Coroutine::exists(SlowHeartbeatPdo::$coroutineId) && microtime(true) < $deadline) { + while (Coroutine::exists(SlowHeartbeatConnection::$coroutineId) && microtime(true) < $deadline) { usleep(1000); } - $this->assertFalse(Coroutine::exists(SlowHeartbeatPdo::$coroutineId)); + $this->assertFalse(Coroutine::exists(SlowHeartbeatConnection::$coroutineId)); usleep(100000); @@ -540,31 +549,11 @@ public function log($level, string|Stringable $message, array $context = []): vo } } -class SlowHeartbeatDbPool extends InspectableHeartbeatDbPool -{ - protected function createConnection(): ConnectionInterface - { - return new SlowHeartbeatPooledConnection($this->container, $this, $this->config); - } -} - -class SlowHeartbeatPooledConnection extends PooledConnection -{ - protected function getOpenPdos(): array - { - return [new SlowHeartbeatPdo]; - } -} - -class SlowHeartbeatPdo extends PDO +class SlowHeartbeatConnection extends SQLiteConnection { public static ?int $coroutineId = null; - public function __construct() - { - } - - public function query(string $query, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false + public function ping(): bool { self::$coroutineId = Coroutine::id(); diff --git a/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php b/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php index 068d3bcf49..2b45c9de2f 100644 --- a/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php +++ b/tests/Integration/Database/Sqlite/EloquentModelConnectionsTest.php @@ -12,6 +12,7 @@ use Hypervel\Support\Facades\Schema; use Hypervel\Support\Str; use Hypervel\Tests\Integration\Database\Sqlite\SqliteTestCase; +use Override; use UnitEnum; class EloquentModelConnectionsTest extends SqliteTestCase @@ -34,7 +35,8 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } - protected function defineDatabaseMigrations(): void + #[Override] + protected function afterRefreshingDatabase(): void { // Clean up any existing tables from previous tests Schema::dropIfExists('child'); diff --git a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php index b110fbea78..d5c8484b53 100644 --- a/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php +++ b/tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php @@ -556,7 +556,8 @@ public function testPooledConnectionRefreshRebindsSharedPdoAfterRollbackCallback } $this->assertSame($sharedPdo, $connection->getRawPdo()); - $this->assertSame($sharedPdo, $connection->getRawReadPdo()); + $this->assertNull($connection->getRawReadPdo()); + $this->assertSame($sharedPdo, $connection->getReadPdo()); $this->assertSame(0, $connection->transactionLevel()); $this->assertFalse($sharedPdo->inTransaction()); $this->assertNull( diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 70686bf069..a07b27986e 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -63,6 +63,62 @@ public function testQueueNamesPreserveZeroAndDefaultEmptyString(): void $this->assertSame('0', $queue->getQueue('0')); } + public function testLockForPoppingUsesOneConnectionAndConfiguredVersion(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection); + $connection->shouldReceive('getDriverName')->once()->andReturn('mysql'); + $connection->shouldReceive('getConfig')->once()->with('version')->andReturn('8.0.1'); + $connection->shouldNotReceive('getServerVersion'); + + $queue = new TestDatabaseQueue( + resolver: $resolver, + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + + $this->assertSame('FOR UPDATE SKIP LOCKED', $queue->lockForPopping()); + } + + #[DataProvider('databaseLockProvider')] + public function testLockForPoppingUsesDriverOwnedServerVersion( + string $driver, + string $version, + bool|string $expected, + ): void { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $resolver->shouldReceive('connection')->once()->with(null)->andReturn($connection); + $connection->shouldReceive('getDriverName')->once()->andReturn($driver); + $connection->shouldReceive('getConfig')->once()->with('version')->andReturnNull(); + $connection->shouldReceive('getServerVersion')->once()->andReturn($version); + + $queue = new TestDatabaseQueue( + resolver: $resolver, + connection: null, + table: 'table', + default: 'default', + currentTime: 1732502704, + ); + + $this->assertSame($expected, $queue->lockForPopping()); + } + + public static function databaseLockProvider(): array + { + return [ + 'mysql' => ['mysql', '8.0.1', 'FOR UPDATE SKIP LOCKED'], + 'old mysql' => ['mysql', '5.7.44', true], + 'mariadb' => ['mysql', '5.5.5-10.6.1-MariaDB', 'FOR UPDATE SKIP LOCKED'], + 'postgres' => ['pgsql', '9.5', 'FOR UPDATE SKIP LOCKED'], + 'vitess' => ['mysql', '19.0.0-vitess', 'FOR UPDATE SKIP LOCKED'], + 'planetscale' => ['mysql', '19.0.0-PlanetScale', 'FOR UPDATE SKIP LOCKED'], + ]; + } + #[DataProvider('pushJobsDataProvider')] public function testPushProperlyPushesJobOntoDatabase($uuid, $job, $displayNameStartsWith, $jobStartsWith) { @@ -745,6 +801,14 @@ protected function currentTime(): int return $this->currentTime; } + /** + * Get the lock used when popping a job. + */ + public function lockForPopping(): bool|string + { + return $this->getLockForPopping(); + } + protected function availableAt(DateInterval|DateTimeInterface|int|null $delay = 0): int { return $this->availableAt ?? parent::availableAt($delay); diff --git a/tests/Sentry/CoroutineSafetyTest.php b/tests/Sentry/CoroutineSafetyTest.php index d5128ebf86..b1413f0528 100644 --- a/tests/Sentry/CoroutineSafetyTest.php +++ b/tests/Sentry/CoroutineSafetyTest.php @@ -11,6 +11,7 @@ use Hypervel\Sentry\Features\CacheFeature; use Hypervel\Sentry\Integration; use Hypervel\Sentry\Tracing\EventHandler as TracingEventHandler; +use Mockery as m; use Sentry\SentrySdk; use Sentry\Tracing\TransactionContext; use Swoole\Coroutine\Channel; @@ -60,12 +61,8 @@ public function testTracingEventHandlerSpanStacksAreIsolatedPerCoroutine() $transaction->setSampled(true); $hub->setSpan($transaction); - $connection = new Connection( - static fn (): null => null, - 'database', - '', - ['driver' => 'sqlite', 'name' => 'parent'], - ); + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('parent'); $handler->transactionBeginning(new TransactionBeginning($connection)); // Verify parent has a span on its stack diff --git a/tests/Sentry/Features/DatabaseIntegrationTest.php b/tests/Sentry/Features/DatabaseIntegrationTest.php index 31ddd11a08..ebd830718f 100644 --- a/tests/Sentry/Features/DatabaseIntegrationTest.php +++ b/tests/Sentry/Features/DatabaseIntegrationTest.php @@ -10,8 +10,10 @@ use Hypervel\Database\Events\TransactionBeginning; use Hypervel\Database\Events\TransactionCommitted; use Hypervel\Database\Events\TransactionRolledBack; +use Hypervel\Database\SQLiteConnection; use Hypervel\Support\Facades\DB; use Hypervel\Tests\Sentry\SentryTestCase; +use PDO; use Sentry\Breadcrumb; use Sentry\Tracing\Span; @@ -26,7 +28,12 @@ class DatabaseIntegrationTest extends SentryTestCase */ protected function createTestConnection(): Connection { - return new Connection(fn () => null, '', '', ['name' => 'sqlite']); + return new SQLiteConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['driver' => 'sqlite', 'name' => 'sqlite'], + ); } // ────────────────────────────────────────────────────── diff --git a/tests/Sentry/Tracing/EventHandlerTest.php b/tests/Sentry/Tracing/EventHandlerTest.php index 1a87847eab..07d2c796a7 100644 --- a/tests/Sentry/Tracing/EventHandlerTest.php +++ b/tests/Sentry/Tracing/EventHandlerTest.php @@ -12,12 +12,14 @@ use Hypervel\Database\Events\TransactionBeginning; use Hypervel\Database\Events\TransactionCommitted; use Hypervel\Database\Events\TransactionRolledBack; +use Hypervel\Database\SQLiteConnection; use Hypervel\Http\Request; use Hypervel\Routing\Events\PreparingResponse; use Hypervel\Routing\Events\ResponsePrepared; use Hypervel\Sentry\Tracing\EventHandler; use Hypervel\Tests\Sentry\SentryTestCase; use Mockery as m; +use PDO; use ReflectionClass; use RuntimeException; use Sentry\SentrySdk; @@ -199,8 +201,8 @@ private function getEventHandlerMapFromEventHandler(): array private function connection(string $name): Connection { - return new Connection( - static fn (): null => null, + return new SQLiteConnection( + new PDO('sqlite::memory:'), 'database', '', ['driver' => 'sqlite', 'name' => $name], diff --git a/tests/Telescope/Watchers/QueryWatcherTest.php b/tests/Telescope/Watchers/QueryWatcherTest.php index e59bc0ee30..fc92e8a6d5 100644 --- a/tests/Telescope/Watchers/QueryWatcherTest.php +++ b/tests/Telescope/Watchers/QueryWatcherTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Telescope\Watchers; -use Exception; use Hypervel\Database\Connection; use Hypervel\Database\Events\QueryExecuted; use Hypervel\Support\CarbonImmutable; @@ -14,9 +13,8 @@ use Hypervel\Telescope\Watchers\QueryWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; -use PDO; -use PDOException; -use ReflectionProperty; +use Mockery as m; +use TypeError; #[WithConfig('telescope.watchers', [ QueryWatcher::class => [ @@ -26,7 +24,7 @@ ])] class QueryWatcherTest extends FeatureTestCase { - public function testQueryWatcherRegistersDatabaseQueries() + public function testQueryWatcherRegistersDatabaseQueries(): void { EntryModel::count(); @@ -38,7 +36,7 @@ public function testQueryWatcherRegistersDatabaseQueries() $this->assertSame('sqlite', $entry->content['driver']); } - public function testQueryWatcherCanTagSlowQueries() + public function testQueryWatcherCanTagSlowQueries(): void { $bindings = array_map( static fn (int $record): string => sprintf('tag-%012d', $record), @@ -119,54 +117,93 @@ public function testQueryWatcherCanPrepareNamedBindings(): void $this->assertSame('testing', $entry->content['connection']); } - public function testQueryWatcherCanPrepareBindingsForNonstandardConnections() + public function testQueryWatcherUsesTheConnectionsEscapingContract(): void + { + [$event, $connection] = $this->queryEvent( + 'select * from records where external_id = ?', + ['=ABC001'], + ); + $connection->shouldReceive('escape') + ->once() + ->with('=ABC001') + ->andReturn('FILEMAKER_STRING[=ABC001]'); + + $sql = $this->app->make(QueryWatcher::class)->replaceBindings($event); + + $this->assertSame( + 'select * from records where external_id = FILEMAKER_STRING[=ABC001]', + $sql, + ); + } + + public function testQueryWatcherSubstitutesEscapedBindingsLiterally(): void + { + $binding = <<<'TEXT' +O'Reilly \ café $1 \1 +TEXT; + $quotedBinding = <<<'TEXT' +'O''Reilly \\ café $1 \1' +TEXT; + [$event, $connection] = $this->queryEvent('select ?', [$binding]); + $connection->shouldReceive('escape') + ->once() + ->with($binding) + ->andReturn($quotedBinding); + + $this->assertSame( + 'select ' . $quotedBinding, + $this->app->make(QueryWatcher::class)->replaceBindings($event), + ); + } + + public function testQueryWatcherMatchesCompleteLiteralNamedBindings(): void + { + [$event] = $this->queryEvent( + 'select :id, :id2, :id, :i.d, :iXd', + [ + 'id' => 1, + 'id2' => 2, + 'i.d' => 3, + ], + ); + + $this->assertSame( + 'select 1, 2, 1, 3, :iXd', + $this->app->make(QueryWatcher::class)->replaceBindings($event), + ); + } + + public function testQueryWatcherRedactsBindingsTheConnectionCannotEscape(): void { $event = new QueryExecuted( - <<<'SQL' -select -Method: post -URL: https://fms.example.com/fmi/data/vLatest/databases/Database_Name/layouts/dapi_layout/_find -Data: { - "query": [ - { - "kp_iti": "=ITI0130" - } - ], - "limit": 1 -} -SQL, - ['kp_id' => '=ABC001'], + 'select ? as null_byte, ? as invalid_utf8', + ["before\0after", "\xC3\x28"], 500, - new class(fn () => null, '', '', ['name' => 'filemaker']) extends Connection { - public function getName(): string - { - return $this->config['name']; - } - - public function getPdo(): PDO - { - $e = new PDOException('Driver does not support this function'); - (new ReflectionProperty(Exception::class, 'code'))->setValue($e, 'IM001'); - throw $e; - } - }, + DB::connection(), ); - $sql = $this->app->make(QueryWatcher::class)->replaceBindings($event); + $this->app->make(QueryWatcher::class)->recordQuery($event); + + $entry = $this->loadTelescopeEntries()->first(); - $this->assertSame(<<<'SQL' -select -Method: post -URL: https://fms.example.com/fmi/data/vLatest/databases/Database_Name/layouts/dapi_layout/_find -Data: { - "query": [ - { - "kp_iti": "=ITI0130" + $this->assertSame( + 'select [REDACTED: UNESCAPABLE BINDING] as null_byte, [REDACTED: UNESCAPABLE BINDING] as invalid_utf8', + $entry->content['sql'], + ); + } + + public function testQueryWatcherDoesNotHideBrokenDriverErrors(): void + { + [$event, $connection] = $this->queryEvent('select ?', ['value']); + $failure = new TypeError('Broken driver.'); + $connection->shouldReceive('escape')->once()->andThrow($failure); + + try { + $this->app->make(QueryWatcher::class)->replaceBindings($event); + $this->fail('A broken driver error should remain visible.'); + } catch (TypeError $exception) { + $this->assertSame($failure, $exception); } - ], - "limit": 1 -} -SQL, $sql); } public function testQueryWatcherUsesConfiguredPackageAndPathStackFilters(): void @@ -193,4 +230,18 @@ public function stackTraceIgnoredPaths(): array '/custom/path', ], $watcher->stackTraceIgnoredPaths()); } + + /** + * Create a query event and its test connection. + * + * @return array{QueryExecuted, Connection} + */ + private function queryEvent(string $sql, array $bindings): array + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('filemaker'); + $connection->shouldReceive('prepareBindings')->once()->with($bindings)->andReturn($bindings); + + return [new QueryExecuted($sql, $bindings, 500, $connection), $connection]; + } } diff --git a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php index dc642a2e86..a4a7560588 100644 --- a/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +++ b/tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php @@ -8,7 +8,10 @@ use Hypervel\Contracts\Cache\Factory as CacheFactory; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Contracts\Pool\ConnectionInterface; +use Hypervel\Database\Connection; use Hypervel\Database\Eloquent\Factories\Factory as EloquentFactory; +use Hypervel\Database\PdoConnection; +use Hypervel\Database\SessionConfigurator; use Hypervel\Encryption\Commands\KeyGenerateCommand; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Http\Client\Factory as HttpFactory; @@ -41,6 +44,7 @@ use Mockery as m; use Mockery\Exception\InvalidCountException; use Override; +use PDO; use ReflectionClass; use ReflectionProperty; use RuntimeException; @@ -251,6 +255,57 @@ public function flushFrameworkStateForTest(): void } } + public function testFrameworkCleanupFlushesNeutralAndPdoConnectionState(): void + { + $macro = 'databaseCleanupProbe'; + Connection::macro($macro, static fn (): string => 'macro'); + Connection::resolverFor('cleanup', static fn (): null => null); + PdoConnection::configureSessionUsing(new class implements SessionConfigurator { + public function state(PdoConnection $connection): ?string + { + return 'state'; + } + + public function apply(PDO $pdo, string $state, PdoConnection $connection): void + { + } + }); + + $connection = new PdoConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['driver' => 'sqlite', 'name' => 'cleanup'] + ); + $connection->getPdo(); + + $sessionConfigurators = new ReflectionProperty(PdoConnection::class, 'sessionConfigurators'); + $physicalSessionStates = new ReflectionProperty(PdoConnection::class, 'physicalSessionStates'); + + $this->assertTrue(Connection::hasMacro($macro)); + $this->assertNotNull(Connection::getResolver('cleanup')); + $this->assertCount(1, $sessionConfigurators->getValue()); + $this->assertCount(1, $physicalSessionStates->getValue()); + + $subscriber = new class extends AfterEachTestSubscriber { + public function flushFrameworkStateForTest(): void + { + $this->flushFrameworkState(); + } + }; + + try { + $subscriber->flushFrameworkStateForTest(); + + $this->assertFalse(Connection::hasMacro($macro)); + $this->assertNull(Connection::getResolver('cleanup')); + $this->assertSame([], $sessionConfigurators->getValue()); + $this->assertNull($physicalSessionStates->getValue()); + } finally { + PdoConnection::flushState(); + } + } + public function testFrameworkCleanupFlushesSaloonStaticState(): void { $macro = 'saloonCleanupProbe'; From ff392167d02a7786eeb48d81a6256178ab174fe1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:25 +0000 Subject: [PATCH 04/18] feat(database): route migration connection targets Discover each migration's configured connection before execution and resolve it through the connection's migrations_connection setting. Reject non-terminal aliases with a precise configuration error so resolution stays single-hop and idempotent. Centralize migration path loading and target discovery in the command base, then use the same routing semantics for migrate, rollback, reset, and status flows. Keep pretend mode side-effect free and preserve the selected connection while repositories and batches are prepared. Cover default, explicit, per-migration, aliased, missing, self-referential, and invalid chained targets, including path ordering and connection restoration after success or failure. --- .../src/Console/Migrations/BaseCommand.php | 148 ++++++ .../src/Console/Migrations/MigrateCommand.php | 173 ++----- .../src/Console/Migrations/ResetCommand.php | 5 - .../Console/Migrations/RollbackCommand.php | 5 - .../src/Console/Migrations/StatusCommand.php | 5 - src/database/src/Migrations/Migrator.php | 82 +++- .../DatabaseMigrationMigrateCommandTest.php | 464 ++++++++++++++---- .../DatabaseMigratorConnectionRoutingTest.php | 282 +++++++++-- ...26_01_01_000000_create_analytics_probe.php | 40 ++ ...26_01_01_000001_create_reporting_probe.php | 32 ++ ...2026_01_01_000002_create_context_probe.php | 39 ++ 11 files changed, 970 insertions(+), 305 deletions(-) create mode 100644 tests/Database/migrations/connection_targets/2026_01_01_000000_create_analytics_probe.php create mode 100644 tests/Database/migrations/connection_targets/2026_01_01_000001_create_reporting_probe.php create mode 100644 tests/Database/migrations/connection_targets/2026_01_01_000002_create_context_probe.php diff --git a/src/database/src/Console/Migrations/BaseCommand.php b/src/database/src/Console/Migrations/BaseCommand.php index 57a48e7091..326dc51aba 100644 --- a/src/database/src/Console/Migrations/BaseCommand.php +++ b/src/database/src/Console/Migrations/BaseCommand.php @@ -5,8 +5,14 @@ namespace Hypervel\Database\Console\Migrations; use Hypervel\Console\Command; +use Hypervel\Database\Connection; use Hypervel\Database\Migrations\Migrator; +use Hypervel\Database\SQLiteDatabaseDoesNotExistException; use Hypervel\Support\Collection; +use Hypervel\Support\Str; +use PDOException; +use RuntimeException; +use Throwable; abstract class BaseCommand extends Command { @@ -54,4 +60,146 @@ protected function getMigrationPath(): string { return $this->hypervel->databasePath() . DIRECTORY_SEPARATOR . 'migrations'; } + + /** + * Inspect the given migration connections for missing physical databases. + * + * @param list $connections + * @return array + */ + protected function inspectMigrationConnections(array $connections): array + { + $missingDatabases = []; + + foreach ($connections as $connection) { + try { + $this->migrator->usingConnection( + $connection, + fn (): bool => $this->migrator->repositoryExists(), + ); + } catch (Throwable $throwable) { + $cause = $this->findMissingDatabaseCause($connection, $throwable); + + if ($cause === null) { + throw $throwable; + } + + $missingDatabases[$connection] = $cause; + } + } + + return $missingDatabases; + } + + /** + * Find a supported missing-database cause in the throwable chain. + */ + protected function findMissingDatabaseCause(string $connectionName, Throwable $throwable): ?Throwable + { + $connection = null; + + for ($cause = $throwable; $cause !== null; $cause = $cause->getPrevious()) { + if ($cause instanceof SQLiteDatabaseDoesNotExistException) { + return $cause; + } + + if (! $cause instanceof PDOException) { + continue; + } + + $connection ??= $this->migrator->resolveConnection($connectionName); + + if ($cause->getCode() === 1049 + && in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) { + return $cause; + } + + if (($cause->errorInfo[0] ?? null) === '08006' + && $connection->getDriverName() === 'pgsql' + && Str::contains($cause->getMessage(), '"' . $connection->getDatabaseName() . '"')) { + return $cause; + } + } + + return null; + } + + /** + * Create and verify the classified missing databases. + * + * @param array $missingDatabases + */ + protected function createMissingDatabases(array $missingDatabases): void + { + foreach ($missingDatabases as $connectionName => $cause) { + $this->components->task( + "Creating database [{$connectionName}]", + fn () => $this->createMissingDatabase($connectionName, $cause), + ); + } + + $unverified = $this->inspectMigrationConnections(array_keys($missingDatabases)); + + if ($unverified !== []) { + throw new RuntimeException(sprintf( + 'Database creation could not be verified for connections [%s].', + implode(', ', array_keys($unverified)), + )); + } + } + + /** + * Create one classified missing database. + */ + protected function createMissingDatabase(string $connectionName, Throwable $cause): void + { + if ($cause instanceof SQLiteDatabaseDoesNotExistException) { + if (! touch($cause->path)) { + throw new RuntimeException("SQLite database [{$cause->path}] could not be created."); + } + + return; + } + + $this->createMissingServerDatabase( + $this->migrator->resolveConnection($connectionName) + ); + } + + /** + * Create a missing MySQL, MariaDB, or PostgreSQL database. + */ + protected function createMissingServerDatabase(Connection $connection): void + { + // Use the resolved write configuration without mutating worker-global config. + $adminConfig = $connection->getConfig(); + $database = $connection->getDatabaseName(); + $identifier = $connection->getQueryGrammar()->wrapIdentifier($database); + $driver = $connection->getDriverName(); + + [$adminDatabase, $createSql] = match ($driver) { + 'mysql', 'mariadb' => ['', "CREATE DATABASE IF NOT EXISTS {$identifier}"], + 'pgsql' => ['postgres', "CREATE DATABASE {$identifier}"], + default => throw new RuntimeException( + "Unsupported driver [{$driver}] for database creation." + ), + }; + + $adminConfig['database'] = $adminDatabase; + + if ($driver === 'pgsql') { + unset($adminConfig['connect_via_database']); + } + + $factory = $this->hypervel->make('db.factory'); + $adminConnection = $factory->make($adminConfig, $connection->getName()); + + try { + if (! $adminConnection->unprepared($createSql)) { + throw new RuntimeException("Database [{$database}] could not be created."); + } + } finally { + $adminConnection->disconnect(); + } + } } diff --git a/src/database/src/Console/Migrations/MigrateCommand.php b/src/database/src/Console/Migrations/MigrateCommand.php index 995ea5b109..3ab9587a4d 100644 --- a/src/database/src/Console/Migrations/MigrateCommand.php +++ b/src/database/src/Console/Migrations/MigrateCommand.php @@ -7,13 +7,9 @@ use Hypervel\Console\ConfirmableTrait; use Hypervel\Contracts\Console\Isolatable; use Hypervel\Contracts\Events\Dispatcher; -use Hypervel\Database\ConfigurationUrlParser; use Hypervel\Database\Connection; use Hypervel\Database\Events\SchemaLoaded; use Hypervel\Database\Migrations\Migrator; -use Hypervel\Database\SQLiteDatabaseDoesNotExistException; -use Hypervel\Support\Str; -use PDOException; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Throwable; @@ -44,11 +40,6 @@ class MigrateCommand extends BaseCommand implements Isolatable */ protected string $description = 'Run the database migrations'; - /** - * The migrator instance. - */ - protected Migrator $migrator; - /** * The event dispatcher instance. */ @@ -96,14 +87,33 @@ public function handle(): int */ protected function runMigrations(): void { - $this->migrator->usingConnection($this->option('database'), function () { + $paths = $this->getMigrationPaths(); + $connections = $this->migrator->getMigrationConnections( + $paths, + $this->option('database'), + ); + $missingDatabases = $this->inspectMigrationConnections($connections); + + if ($missingDatabases !== []) { + if ($this->option('pretend')) { + throw new RuntimeException(sprintf( + 'Cannot pretend migrations because databases are missing for connections [%s].', + implode(', ', array_keys($missingDatabases)), + )); + } + + $this->authorizeMissingDatabases($missingDatabases); + $this->createMissingDatabases($missingDatabases); + } + + $this->migrator->usingConnection($this->option('database'), function () use ($paths) { $this->prepareDatabase(); // Next, we will check to see if a path option has been defined. If it has // we will use the path relative to the root of this installation folder // so that migrations may be run for any path within the applications. $this->migrator->setOutput($this->output) - ->run($this->getMigrationPaths(), [ + ->run($paths, [ 'pretend' => $this->option('pretend'), 'step' => $this->option('step'), ]); @@ -124,143 +134,54 @@ protected function runMigrations(): void } /** - * Prepare the migration database for running. - */ - protected function prepareDatabase(): void - { - if (! $this->repositoryExists()) { - $this->components->info('Preparing database.'); - - $this->components->task('Creating migration table', function () { - return $this->callSilent('migrate:install', array_filter([ - '--database' => $this->option('database'), - ])) === 0; - }); - - $this->newLine(); - } - - if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) { - $this->loadSchemaState(); - } - } - - /** - * Determine if the migrator repository exists. - */ - protected function repositoryExists(): bool - { - return retry(2, fn () => $this->migrator->repositoryExists(), 0, function ($e) { - try { - return $this->handleMissingDatabase($e->getPrevious()); - } catch (Throwable) { - return false; - } - }); - } - - /** - * Attempt to create the database if it is missing. - */ - protected function handleMissingDatabase(Throwable $e): bool - { - if ($e instanceof SQLiteDatabaseDoesNotExistException) { - return $this->createMissingSqliteDatabase($e->path); - } - - $connection = $this->migrator->resolveConnection($this->option('database')); - - if (! $e instanceof PDOException) { - return false; - } - - if (($e->getCode() === 1049 && in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) - || (($e->errorInfo[0] ?? null) === '08006' - && $connection->getDriverName() === 'pgsql' - && Str::contains($e->getMessage(), '"' . $connection->getDatabaseName() . '"'))) { - return $this->createMissingMySqlOrPgsqlDatabase($connection); - } - - return false; - } - - /** - * Create a missing SQLite database. + * Authorize creation of all missing migration databases. * - * @throws RuntimeException + * @param array $missingDatabases */ - protected function createMissingSqliteDatabase(string $path): bool + protected function authorizeMissingDatabases(array $missingDatabases): void { if ($this->option('force')) { - return touch($path); + return; } + $connections = array_keys($missingDatabases); + if ($this->option('no-interaction')) { - return false; + throw new RuntimeException(sprintf( + 'Missing databases for connections [%s] cannot be created in non-interactive mode without --force.', + implode(', ', $connections), + )); } - $this->components->warn('The SQLite database configured for this application does not exist: ' . $path); + $this->components->warn('The following database connections do not have a reachable database:'); + $this->components->bulletList($connections); - if (! confirm('Would you like to create it?', default: true)) { + if (! confirm('Would you like to create the missing databases?', default: true)) { $this->components->info('Operation cancelled. No database was created.'); - throw new RuntimeException('Database was not created. Aborting migration.'); + throw new RuntimeException('Databases were not created. Aborting migration.'); } - - return touch($path); } /** - * Create a missing MySQL or Postgres database. - * - * Unlike Laravel, this avoids mutating process-global config because Hypervel's - * config is shared across all coroutines in a Swoole worker. Instead, a one-off - * admin connection is created from a copied config array. - * - * @throws RuntimeException + * Prepare the migration database for running. */ - protected function createMissingMySqlOrPgsqlDatabase(Connection $connection): bool + protected function prepareDatabase(): void { - $adminConfig = (new ConfigurationUrlParser)->parseConfiguration( - $this->hypervel->make('config')->array("database.connections.{$connection->getName()}") - ); - - if (($adminConfig['database'] ?? null) !== $connection->getDatabaseName()) { - return false; - } - - if (! $this->option('force') && $this->option('no-interaction')) { - return false; - } - - if (! $this->option('force') && ! $this->option('no-interaction')) { - $this->components->warn("The database '{$connection->getDatabaseName()}' does not exist on the '{$connection->getName()}' connection."); + if (! $this->migrator->repositoryExists()) { + $this->components->info('Preparing database.'); - if (! confirm('Would you like to create it?', default: true)) { - $this->components->info('Operation cancelled. No database was created.'); + $this->components->task('Creating migration table', function () { + return $this->callSilent('migrate:install', array_filter([ + '--database' => $this->option('database'), + ])) === 0; + }); - throw new RuntimeException('Database was not created. Aborting migration.'); - } + $this->newLine(); } - // Build a one-off admin connection from a copied config with the database - // changed to the server default. This avoids mutating process-global config - // which would race with other coroutines in Swoole's long-lived workers. - [$adminDatabase, $createSql] = match ($connection->getDriverName()) { - 'mysql', 'mariadb' => [null, "CREATE DATABASE IF NOT EXISTS `{$connection->getDatabaseName()}`"], - 'pgsql' => ['postgres', 'CREATE DATABASE "' . $connection->getDatabaseName() . '"'], - default => throw new RuntimeException("Unsupported driver [{$connection->getDriverName()}] for database creation."), - }; - - $adminConfig['database'] = $adminDatabase; - - $factory = $this->hypervel->make('db.factory'); - $adminConnection = $factory->make($adminConfig, $connection->getName()); - - try { - return $adminConnection->unprepared($createSql); - } finally { - $adminConnection->disconnect(); + if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) { + $this->loadSchemaState(); } } diff --git a/src/database/src/Console/Migrations/ResetCommand.php b/src/database/src/Console/Migrations/ResetCommand.php index 7acc880719..0b07389955 100644 --- a/src/database/src/Console/Migrations/ResetCommand.php +++ b/src/database/src/Console/Migrations/ResetCommand.php @@ -27,11 +27,6 @@ class ResetCommand extends BaseCommand */ protected string $description = 'Rollback all database migrations'; - /** - * The migrator instance. - */ - protected Migrator $migrator; - /** * Create a new migration rollback command instance. */ diff --git a/src/database/src/Console/Migrations/RollbackCommand.php b/src/database/src/Console/Migrations/RollbackCommand.php index e6884fc0f6..84bb8ccdd7 100644 --- a/src/database/src/Console/Migrations/RollbackCommand.php +++ b/src/database/src/Console/Migrations/RollbackCommand.php @@ -27,11 +27,6 @@ class RollbackCommand extends BaseCommand */ protected string $description = 'Rollback the last database migration'; - /** - * The migrator instance. - */ - protected Migrator $migrator; - /** * Create a new migration rollback command instance. */ diff --git a/src/database/src/Console/Migrations/StatusCommand.php b/src/database/src/Console/Migrations/StatusCommand.php index fea574c764..8b6bfd5c3a 100644 --- a/src/database/src/Console/Migrations/StatusCommand.php +++ b/src/database/src/Console/Migrations/StatusCommand.php @@ -23,11 +23,6 @@ class StatusCommand extends BaseCommand */ protected string $description = 'Show the status of each migration'; - /** - * The migrator instance. - */ - protected Migrator $migrator; - /** * Create a new migration rollback command instance. */ diff --git a/src/database/src/Migrations/Migrator.php b/src/database/src/Migrations/Migrator.php index 5ad9194689..9eb712b22c 100755 --- a/src/database/src/Migrations/Migrator.php +++ b/src/database/src/Migrations/Migrator.php @@ -28,6 +28,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Str; +use InvalidArgumentException; use ReflectionClass; class Migrator @@ -501,6 +502,39 @@ protected function resolvePath(string $path): object return new $class; } + /** + * Get the distinct connections declared by the migrations at the given paths. + * + * @return list + */ + public function getMigrationConnections( + array|string $paths, + ?string $defaultConnection = null, + ): array { + $defaultConnection = static::resolveMigrationConnectionName($defaultConnection); + + if ($defaultConnection === null || $defaultConnection === '') { + throw new InvalidArgumentException('Migration connection name cannot be empty.'); + } + + return $this->usingConnection($defaultConnection, function () use ($paths, $defaultConnection): array { + $connections = [$defaultConnection => true]; + + foreach ($this->getMigrationFiles($paths) as $file) { + /** @var Migration $migration */ + $migration = $this->resolvePath($file); + $connection = $migration->getConnection(); + $connection = static::resolveMigrationConnectionName( + $connection === null || $connection === '' ? $defaultConnection : $connection + ); + + $connections[$connection] = true; + } + + return array_keys($connections); + }); + } + /** * Generate a migration class name based on the migration file name. */ @@ -592,6 +626,9 @@ public function getConnection(): ?string * state required by migrations — advisory locks, LOCK TABLE, temp tables — is * incompatible with transaction-pooling mode. * + * The target must be terminal: it may omit migrations_connection or reference + * itself, but it may not route migrations to another connection. + * * When $name is null, falls back to the "effective default connection" — * the current coroutine's Context override first, then the configured * default (database.default). This mirrors DatabaseManager::getDefaultConnection() @@ -601,6 +638,10 @@ public function getConnection(): ?string * Defensively passes the name through when the container has no "config" * binding so unit tests that construct Migrator without a booted framework * still work. + * + * @return ($name is null ? null|string : string) + * + * @throws InvalidArgumentException */ public static function resolveMigrationConnectionName(?string $name): ?string { @@ -615,26 +656,49 @@ public static function resolveMigrationConnectionName(?string $name): ?string if ($name === null) { $name = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY) ?? $config->get('database.default'); + } - if ($name === null) { - return null; - } + if ($name === null || $name === '') { + throw new InvalidArgumentException('Migration connection name cannot be empty.'); } - return $config->string( + $target = $config->string( "database.connections.{$name}.migrations_connection", $name, ); + + if ($target === '') { + throw new InvalidArgumentException( + "The migrations_connection value for database connection [{$name}] cannot be empty." + ); + } + + $terminalTarget = $config->string( + "database.connections.{$target}.migrations_connection", + $target, + ); + + if ($terminalTarget === '') { + throw new InvalidArgumentException( + "The migrations_connection value for database connection [{$target}] cannot be empty." + ); + } + + if ($terminalTarget !== $target) { + throw new InvalidArgumentException( + "Database connection [{$name}] routes migrations to [{$target}], but [{$target}] routes migrations to [{$terminalTarget}]. Migration connections must resolve directly to a terminal connection." + ); + } + + return $target; } /** * Execute the given callback using the given connection as the default connection. * - * Snapshots the prior coroutine Context value and the stored migrator - * connection on entry, then restores them directly in finally without - * routing back through setConnection() — otherwise the restoration would - * apply migrations_connection to the saved alias and leave the wrong - * default in place. + * Snapshots the prior coroutine Context value and stored migrator connection + * independently, then restores both directly in finally. The two values can + * differ, so routing restoration through setConnection() would collapse them. * * @template TReturn * diff --git a/tests/Database/DatabaseMigrationMigrateCommandTest.php b/tests/Database/DatabaseMigrationMigrateCommandTest.php index f63be0e152..82db23d6b0 100755 --- a/tests/Database/DatabaseMigrationMigrateCommandTest.php +++ b/tests/Database/DatabaseMigrationMigrateCommandTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Database; +use Closure; use Hypervel\Config\Repository; use Hypervel\Console\CommandMutex; use Hypervel\Contracts\Events\Dispatcher; @@ -12,48 +13,59 @@ use Hypervel\Database\Console\Migrations\MigrateCommand; use Hypervel\Database\Events\SchemaLoaded; use Hypervel\Database\Migrations\Migrator; +use Hypervel\Database\MySqlConnection; +use Hypervel\Database\PostgresConnection; use Hypervel\Database\Schema\SchemaState; +use Hypervel\Database\SQLiteDatabaseDoesNotExistException; use Hypervel\Foundation\Application; +use Hypervel\Prompts\ConfirmPrompt; +use Hypervel\Prompts\Prompt; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; +use PDOException; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\NullOutput; +use Throwable; class DatabaseMigrationMigrateCommandTest extends TestCase { - public function testBasicMigrationsCallMigratorWithProperArguments() + protected function tearDown(): void + { + RecordingMigrationMySqlConnection::reset(); + RecordingMigrationPostgresConnection::reset(); + + parent::tearDown(); + } + + public function testBasicMigrationsCallMigratorWithProperArguments(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => false]); $migrator->shouldReceive('getNotes')->andReturn([]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $this->runCommand($command); } - public function testMigrationsCanBeRunWithStoredSchema() + public function testMigrationsCanBeRunWithStoredSchema(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(false); $migrator->shouldReceive('resolveConnection')->andReturn($connection = m::mock(Connection::class)); $connection->shouldReceive('getName')->andReturn('mysql'); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('deleteRepository')->once(); $connection->shouldReceive('getSchemaState')->andReturn($schemaState = m::mock(SchemaState::class)); $schemaState->shouldReceive('handleOutputUsing')->andReturnSelf(); @@ -62,81 +74,64 @@ public function testMigrationsCanBeRunWithStoredSchema() $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => false]); $migrator->shouldReceive('getNotes')->andReturn([]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $this->runCommand($command, ['--schema-path' => __DIR__ . '/Fixtures/schema.sql']); } - public function testMigrationRepositoryCreatedWhenNecessary() + public function testMigrationRepositoryCreatedWhenNecessary(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $params = [$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]; $command = $this->getMockBuilder(MigrateCommand::class)->onlyMethods(['callSilent'])->setConstructorArgs($params)->getMock(); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator, repositoryExists: false); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => false]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(false); $command->expects($this->once())->method('callSilent')->with($this->equalTo('migrate:install'), $this->equalTo([])); $this->runCommand($command); } - public function testTheCommandMayBePretended() + public function testTheCommandMayBePretended(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => true, 'step' => false]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $this->runCommand($command, ['--pretend' => true]); } - public function testTheDatabaseMayBeSet() + public function testTheDatabaseMayBeSet(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator, 'foo'); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => false]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $this->runCommand($command, ['--database' => 'foo']); } - public function testStepMayBeSet() + public function testStepMayBeSet(): void { $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); $app->useDatabasePath(__DIR__); $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => true]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $this->runCommand($command, ['--step' => true]); } @@ -165,14 +160,10 @@ public function testSeedOptionRunsSeederAfterMigrations(): void ->setConstructorArgs([$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]) ->getMock(); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once()->with([__DIR__ . DIRECTORY_SEPARATOR . 'migrations'], ['pretend' => false, 'step' => false]); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $command->expects($this->once())->method('call')->with('db:seed', [ '--class' => 'Database\Seeders\CustomSeeder', '--force' => true, @@ -193,14 +184,10 @@ public function testSeedOptionForwardsDatabaseToSeedCommand(): void ->setConstructorArgs([$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]) ->getMock(); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator, 'pgsql-pooled'); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once(); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $command->expects($this->once())->method('call')->with('db:seed', [ '--database' => 'pgsql-pooled', '--class' => 'Database\Seeders\DatabaseSeeder', @@ -217,7 +204,7 @@ public function testMigrateWithSeedDoesNotLeakConnectionStateOrMutateConfig(): v // coroutine Context must not retain any connection-default override. // This catches regressions where MigrateCommand itself starts // mutating config or Context in a way that escapes the command boundary. - $config = new \Hypervel\Config\Repository(['database' => ['default' => 'pgsql']]); + $config = new Repository(['database' => ['default' => 'pgsql']]); $app = new ApplicationDatabaseMigrationStub([ 'path.database' => __DIR__, @@ -230,14 +217,10 @@ public function testMigrateWithSeedDoesNotLeakConnectionStateOrMutateConfig(): v ->setConstructorArgs([$migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)]) ->getMock(); $command->setHypervel($app); - $migrator->shouldReceive('paths')->once()->andReturn([]); + $this->expectMigrationPreflight($migrator, 'pgsql-pooled'); $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); $migrator->shouldReceive('run')->once(); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); $command->expects($this->once())->method('call'); $contextBefore = \Hypervel\Context\CoroutineContext::get( @@ -261,69 +244,277 @@ public function testMigrateWithSeedDoesNotLeakConnectionStateOrMutateConfig(): v ); } - public function testCreateMissingSqliteDatabaseWithForceOption(): void + public function testAllTargetsAreInspectedAndMissingSqliteDatabaseIsCreatedBeforeMigration(): void { $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); unlink($path); - $app = new ApplicationDatabaseMigrationStub; - $command = new TestableMigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); - $command->probeMode = 'sqlite'; - $command->sqlitePath = $path; + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command = new MigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $currentConnection = null; + $secondaryInspections = 0; - try { - $code = $this->runCommand($command, ['--force' => true]); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, null)->andReturn(['default', 'analytics']); + $migrator->shouldReceive('usingConnection')->times(4)->andReturnUsing( + function ($name, $callback) use (&$currentConnection) { + $previousConnection = $currentConnection; + $currentConnection = $name; + + try { + return $callback(); + } finally { + $currentConnection = $previousConnection; + } + } + ); + $migrator->shouldReceive('repositoryExists')->times(4)->andReturnUsing( + function () use (&$currentConnection, &$secondaryInspections, $path): bool { + if ($currentConnection === 'analytics') { + ++$secondaryInspections; + + if ($secondaryInspections === 1) { + throw new SQLiteDatabaseDoesNotExistException($path); + } - $this->assertSame(0, $code); - $this->assertFileExists($path); + $this->assertFileExists($path); + } + + return true; + } + ); + $migrator->shouldReceive('hasRunAnyMigrations')->once()->andReturn(true); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once()->with($paths, ['pretend' => false, 'step' => false])->andReturnUsing( + function () use ($path, &$secondaryInspections): array { + $this->assertFileExists($path); + $this->assertSame(2, $secondaryInspections); + + return []; + } + ); + + try { + $this->assertSame(0, $this->runCommand($command, ['--force' => true])); } finally { @unlink($path); } } - public function testCreateMissingMysqlDatabaseUsesParsedUrlConfiguration(): void + public function testPretendRefusesToCreateAMissingDatabase(): void { - $factory = m::mock(ConnectionFactory::class); - $adminConnection = m::mock(Connection::class); - $factory->shouldReceive('make')->once()->with(m::on(function (array $config): bool { - return $config['driver'] === 'mysql' - && $config['host'] === 'db' - && $config['username'] === 'root' - && $config['password'] === 'secret' - && array_key_exists('database', $config) - && $config['database'] === null; - }), 'mysql')->andReturn($adminConnection); - $adminConnection->shouldReceive('unprepared')->once()->with('CREATE DATABASE IF NOT EXISTS `missing_database`')->andReturn(true); - $adminConnection->shouldReceive('disconnect')->once(); + $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); + unlink($path); + $command = $this->makeCommandWithMissingSqliteTarget($path); + $exception = null; - $app = new ApplicationDatabaseMigrationStub([ - 'config' => new Repository([ - 'database' => [ - 'connections' => [ - 'mysql' => [ - 'url' => 'mysql://root:secret@db/missing_database', - ], - ], - ], - ]), - 'db.factory' => $factory, - ]); - $command = new TestableMigrateCommand($migrator = m::mock(Migrator::class), $dispatcher = m::mock(Dispatcher::class)); - $command->probeMode = 'mysql'; - $command->probeConnection = m::mock(Connection::class); - $command->probeConnection->shouldReceive('getName')->andReturn('mysql'); - $command->probeConnection->shouldReceive('getDatabaseName')->andReturn('missing_database'); - $command->probeConnection->shouldReceive('getDriverName')->andReturn('mysql'); + try { + $this->runCommand($command, ['--pretend' => true, '--force' => true]); + } catch (RuntimeException $throwable) { + $exception = $throwable; + } + + $this->assertSame( + 'Cannot pretend migrations because databases are missing for connections [analytics].', + $exception?->getMessage(), + ); + $this->assertFileDoesNotExist($path); + } + + public function testMissingDatabaseRequiresForceInNonInteractiveMode(): void + { + $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); + unlink($path); + $command = $this->makeCommandWithMissingSqliteTarget($path); + $exception = null; + + try { + $this->runCommand($command, ['--no-interaction' => true]); + } catch (RuntimeException $throwable) { + $exception = $throwable; + } + + $this->assertSame( + 'Missing databases for connections [analytics] cannot be created in non-interactive mode without --force.', + $exception?->getMessage(), + ); + $this->assertFileDoesNotExist($path); + } + + public function testDecliningMissingDatabaseCreationCreatesNothing(): void + { + $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); + unlink($path); + $command = $this->makeCommandWithMissingSqliteTarget($path); + $command->declineConfirmations = true; + $exception = null; + + try { + $this->runCommand($command); + } catch (RuntimeException $throwable) { + $exception = $throwable; + } + + $this->assertSame('Databases were not created. Aborting migration.', $exception?->getMessage()); + $this->assertFileDoesNotExist($path); + } + + public function testMissingDatabaseClassifierRecognizesOnlySupportedSignals(): void + { + $migrator = m::mock(Migrator::class); + $mysqlConnection = m::mock(Connection::class); + $mysqlConnection->shouldReceive('getDriverName')->andReturn('mysql'); + $postgresConnection = m::mock(Connection::class); + $postgresConnection->shouldReceive('getDriverName')->andReturn('pgsql'); + $postgresConnection->shouldReceive('getDatabaseName')->andReturn('analytics'); + $migrator->shouldReceive('resolveConnection')->andReturnUsing( + fn (string $name): Connection => $name === 'mysql' ? $mysqlConnection : $postgresConnection + ); + $command = new TestableMigrateCommand($migrator, m::mock(Dispatcher::class)); + + $sqliteCause = new SQLiteDatabaseDoesNotExistException('/missing.sqlite'); + $mysqlCause = new PDOException('Unknown database', 1049); + $postgresCause = new PDOException('database "analytics" does not exist'); + $postgresCause->errorInfo = ['08006']; + $otherPostgresCause = new PDOException('database "other" does not exist'); + $otherPostgresCause->errorInfo = ['08006']; + $authenticationCause = new PDOException('Access denied', 1045); + + $this->assertSame( + $sqliteCause, + $command->probeFindMissingDatabaseCause('sqlite', new RuntimeException('wrapped', 0, $sqliteCause)), + ); + $this->assertSame( + $mysqlCause, + $command->probeFindMissingDatabaseCause('mysql', new RuntimeException('wrapped', 0, $mysqlCause)), + ); + $this->assertSame($postgresCause, $command->probeFindMissingDatabaseCause('pgsql', $postgresCause)); + $this->assertNull($command->probeFindMissingDatabaseCause('pgsql', $otherPostgresCause)); + $this->assertNull($command->probeFindMissingDatabaseCause('pgsql', $mysqlCause)); + $this->assertNull($command->probeFindMissingDatabaseCause('mysql', $authenticationCause)); + $this->assertNull($command->probeFindMissingDatabaseCause('mysql', new RuntimeException('network failure'))); + } + + public function testMySqlAdminCreationUsesResolvedWriteConfigurationAndQuotesTheCompleteIdentifier(): void + { + $app = new ApplicationDatabaseMigrationStub; + $factory = new ConnectionFactory($app); + $app->instance('db.factory', $factory); + Connection::resolverFor( + 'mysql', + fn (PDO|Closure $pdo, string $database, string $prefix, array $config): RecordingMigrationMySqlConnection => new RecordingMigrationMySqlConnection($pdo, $database, $prefix, $config), + ); + $connection = $factory->make([ + 'url' => 'mysql://root:secret@url-host/top_database', + 'prefix' => 'top_', + 'read' => ['host' => 'read-host', 'database' => 'read_database'], + 'write' => ['host' => 'write-host', 'database' => 'tenant.with`quote', 'prefix' => 'write_'], + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'write', + ], 'mysql'); + $command = new TestableMigrateCommand(m::mock(Migrator::class), m::mock(Dispatcher::class)); $command->setHypervel($app); - $code = $this->runCommand($command, ['--force' => true]); + $command->probeCreateMissingServerDatabase($connection); + + $this->assertCount(2, RecordingMigrationMySqlConnection::$instances); + $adminConnection = RecordingMigrationMySqlConnection::$instances[1]; + $this->assertSame('', $adminConnection->getDatabaseName()); + $this->assertSame('write-host', $adminConnection->getConfig('host')); + $this->assertSame('root', $adminConnection->getConfig('username')); + $this->assertSame('secret', $adminConnection->getConfig('password')); + $this->assertSame('write_', $adminConnection->getTablePrefix()); + $this->assertSame('write', $adminConnection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame('CREATE DATABASE IF NOT EXISTS `tenant.with``quote`', $adminConnection->executedSql); + $this->assertTrue($adminConnection->disconnected); + } - $this->assertSame(0, $code); + public function testPostgresAdminCreationRemovesConnectViaDatabaseAndDisconnectsAfterFailure(): void + { + $app = new ApplicationDatabaseMigrationStub; + $factory = new ConnectionFactory($app); + $app->instance('db.factory', $factory); + Connection::resolverFor( + 'pgsql', + fn (PDO|Closure $pdo, string $database, string $prefix, array $config): RecordingMigrationPostgresConnection => new RecordingMigrationPostgresConnection($pdo, $database, $prefix, $config), + ); + $connection = $factory->make([ + 'driver' => 'pgsql', + 'database' => 'top_database', + 'host' => 'top-host', + 'prefix' => 'top_', + 'connect_via_database' => 'tenant.with"quote', + 'connect_via_port' => 6543, + 'read' => ['host' => 'read-host', 'database' => 'read_database'], + 'write' => ['host' => 'write-host', 'database' => 'tenant.with"quote', 'prefix' => 'write_'], + Connection::READ_WRITE_TYPE_CONFIG_KEY => 'write', + ], 'pgsql'); + $failure = new RuntimeException('create failed'); + RecordingMigrationPostgresConnection::$unpreparedException = $failure; + $command = new TestableMigrateCommand(m::mock(Migrator::class), m::mock(Dispatcher::class)); + $command->setHypervel($app); + $caught = null; + + try { + $command->probeCreateMissingServerDatabase($connection); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame($failure, $caught); + $this->assertCount(2, RecordingMigrationPostgresConnection::$instances); + $adminConnection = RecordingMigrationPostgresConnection::$instances[1]; + $this->assertSame('postgres', $adminConnection->getDatabaseName()); + $this->assertSame('write-host', $adminConnection->getConfig('host')); + $this->assertSame(6543, $adminConnection->getConfig('connect_via_port')); + $this->assertNull($adminConnection->getConfig('connect_via_database')); + $this->assertSame('write_', $adminConnection->getTablePrefix()); + $this->assertSame('write', $adminConnection->getConfig(Connection::READ_WRITE_TYPE_CONFIG_KEY)); + $this->assertSame('CREATE DATABASE "tenant.with""quote"', $adminConnection->executedSql); + $this->assertTrue($adminConnection->disconnected); + } + + private function expectMigrationPreflight( + Migrator $migrator, + ?string $database = null, + bool $repositoryExists = true, + ): void { + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, $database)->andReturn([$database ?? 'default']); + $migrator->shouldReceive('usingConnection')->twice()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->twice()->andReturn($repositoryExists); } - protected function runCommand($command, $input = []) + private function makeCommandWithMissingSqliteTarget(string $path): TestableMigrateCommand { + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command = new TestableMigrateCommand($migrator = m::mock(Migrator::class), m::mock(Dispatcher::class)); + $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, null)->andReturn(['analytics']); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->once()->andThrow(new SQLiteDatabaseDoesNotExistException($path)); + $migrator->shouldReceive('run')->never(); + + return $command; + } + + protected function runCommand(MigrateCommand $command, array $input = []): int + { + if (! $command->getDefinition()->hasOption('no-interaction')) { + $command->getDefinition()->addOption(new InputOption('no-interaction', 'n', InputOption::VALUE_NONE)); + } + return $command->run(new ArrayInput($input), new NullOutput); } } @@ -353,18 +544,83 @@ public function environment(...$environments): bool|string class TestableMigrateCommand extends MigrateCommand { - public string $probeMode = ''; + public bool $declineConfirmations = false; - public ?string $sqlitePath = null; + protected function configurePrompts(InputInterface $input): void + { + parent::configurePrompts($input); - public ?Connection $probeConnection = null; + if ($this->declineConfirmations) { + ConfirmPrompt::fallbackUsing(fn (ConfirmPrompt $prompt): bool => false); + Prompt::fallbackWhen(true); + } + } - public function handle(): int + public function probeFindMissingDatabaseCause(string $connectionName, Throwable $throwable): ?Throwable { - return match ($this->probeMode) { - 'sqlite' => $this->createMissingSqliteDatabase($this->sqlitePath) ? 0 : 1, - 'mysql' => $this->createMissingMySqlOrPgsqlDatabase($this->probeConnection) ? 0 : 1, - default => parent::handle(), - }; + return $this->findMissingDatabaseCause($connectionName, $throwable); + } + + public function probeCreateMissingServerDatabase(Connection $connection): void + { + $this->createMissingServerDatabase($connection); + } +} + +trait RecordsMigrationAdminConnection +{ + /** @var list */ + public static array $instances = []; + + public static ?RuntimeException $unpreparedException = null; + + public ?string $executedSql = null; + + public bool $disconnected = false; + + public function unprepared(string $query): bool + { + $this->executedSql = $query; + + if (static::$unpreparedException !== null) { + throw static::$unpreparedException; + } + + return true; + } + + public function disconnect(): void + { + $this->disconnected = true; + } + + public static function reset(): void + { + static::$instances = []; + static::$unpreparedException = null; + } +} + +class RecordingMigrationMySqlConnection extends MySqlConnection +{ + use RecordsMigrationAdminConnection; + + public function __construct(PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) + { + parent::__construct($pdo, $database, $tablePrefix, $config); + + static::$instances[] = $this; + } +} + +class RecordingMigrationPostgresConnection extends PostgresConnection +{ + use RecordsMigrationAdminConnection; + + public function __construct(PDO|Closure $pdo, string $database = '', string $tablePrefix = '', array $config = []) + { + parent::__construct($pdo, $database, $tablePrefix, $config); + + static::$instances[] = $this; } } diff --git a/tests/Database/DatabaseMigratorConnectionRoutingTest.php b/tests/Database/DatabaseMigratorConnectionRoutingTest.php index 94cf52b74e..426c2940ac 100644 --- a/tests/Database/DatabaseMigratorConnectionRoutingTest.php +++ b/tests/Database/DatabaseMigratorConnectionRoutingTest.php @@ -14,8 +14,11 @@ use Hypervel\Database\Migrations\Migrator; use Hypervel\Filesystem\Filesystem; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use RuntimeException; class DatabaseMigratorConnectionRoutingTest extends TestCase { @@ -29,7 +32,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testFlushStateRestoresStaticState() + public function testFlushStateRestoresStaticState(): void { $reflection = new ReflectionClass(Migrator::class); $reflection->setStaticPropertyValue('connectionResolverCallback', static fn () => null); @@ -43,14 +46,17 @@ public function testFlushStateRestoresStaticState() $this->assertSame([], $reflection->getStaticPropertyValue('withoutMigrations')); } - public function testResolveMigrationConnectionNameReturnsNullForNullInput() + public function testResolveMigrationConnectionNameRejectsMissingEffectiveDefault(): void { $this->bindConfig([]); - $this->assertNull(Migrator::resolveMigrationConnectionName(null)); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Migration connection name cannot be empty.'); + + Migrator::resolveMigrationConnectionName(null); } - public function testResolveMigrationConnectionNameReturnsOriginalWhenNoMigrationsConnectionConfigured() + public function testResolveMigrationConnectionNameReturnsOriginalWhenNoMigrationsConnectionConfigured(): void { $this->bindConfig([ 'pgsql' => ['driver' => 'pgsql'], @@ -59,7 +65,7 @@ public function testResolveMigrationConnectionNameReturnsOriginalWhenNoMigration $this->assertSame('pgsql', Migrator::resolveMigrationConnectionName('pgsql')); } - public function testResolveMigrationConnectionNameReturnsMigrationsConnectionWhenConfigured() + public function testResolveMigrationConnectionNameReturnsMigrationsConnectionWhenConfigured(): void { $this->bindConfig([ 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], @@ -67,9 +73,21 @@ public function testResolveMigrationConnectionNameReturnsMigrationsConnectionWhe ]); $this->assertSame('pgsql', Migrator::resolveMigrationConnectionName('pgsql-pooled')); + $this->assertSame('pgsql', Migrator::resolveMigrationConnectionName('pgsql')); + } + + public function testResolveMigrationConnectionNameAllowsATerminalSelfReference(): void + { + $this->bindConfig([ + 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], + 'pgsql' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], + ]); + + $this->assertSame('pgsql', Migrator::resolveMigrationConnectionName('pgsql-pooled')); + $this->assertSame('pgsql', Migrator::resolveMigrationConnectionName('pgsql')); } - public function testResolveMigrationConnectionNameIsDriverAgnostic() + public function testResolveMigrationConnectionNameIsDriverAgnostic(): void { $this->bindConfig([ 'mysql-pooled' => ['driver' => 'mysql', 'migrations_connection' => 'mysql'], @@ -79,7 +97,7 @@ public function testResolveMigrationConnectionNameIsDriverAgnostic() $this->assertSame('mysql', Migrator::resolveMigrationConnectionName('mysql-pooled')); } - public function testResolveMigrationConnectionNameReturnsOriginalWhenConfigBindingMissing() + public function testResolveMigrationConnectionNameReturnsOriginalWhenConfigBindingMissing(): void { // No container/config set up — helper should pass the name through // rather than throw. Protects unit tests that construct Migrator @@ -89,7 +107,7 @@ public function testResolveMigrationConnectionNameReturnsOriginalWhenConfigBindi $this->assertSame('pgsql-pooled', Migrator::resolveMigrationConnectionName('pgsql-pooled')); } - public function testResolveMigrationConnectionNameReturnsOriginalWhenTargetConnectionUnknown() + public function testResolveMigrationConnectionNameReturnsOriginalWhenTargetConnectionUnknown(): void { // If the named connection doesn't exist in config, we pass through; // the resolver (not our helper) surfaces the "not configured" error. @@ -98,7 +116,7 @@ public function testResolveMigrationConnectionNameReturnsOriginalWhenTargetConne $this->assertSame('ghost', Migrator::resolveMigrationConnectionName('ghost')); } - public function testResolveMigrationConnectionNameNullPrefersContextOverConfigDefault() + public function testResolveMigrationConnectionNameNullPrefersContextOverConfigDefault(): void { // Regression for the "effective default" fix. Scenario: // DB::usingConnection('tenant-pooled', fn () => Artisan::call('migrate')) @@ -127,7 +145,7 @@ public function testResolveMigrationConnectionNameNullPrefersContextOverConfigDe ); } - public function testResolveMigrationConnectionNameNullReturnsContextValueWhenNoMigrationsConnection() + public function testResolveMigrationConnectionNameNullReturnsContextValueWhenNoMigrationsConnection(): void { // Edge case: Context override is set, but that connection has no // migrations_connection key. Helper returns the Context value unchanged @@ -147,7 +165,7 @@ public function testResolveMigrationConnectionNameNullReturnsContextValueWhenNoM ); } - public function testResolveMigrationConnectionNameNullFallsBackToConfigWhenNoContext() + public function testResolveMigrationConnectionNameNullFallsBackToConfigWhenNoContext(): void { // Regression guard: the config-default fallback path still works when // no Context override is present. This is the CLI migration path @@ -170,7 +188,82 @@ public function testResolveMigrationConnectionNameNullFallsBackToConfigWhenNoCon ); } - public function testSetConnectionWritesContextRepositorySourceAndStoredName() + public function testResolveMigrationConnectionNameRejectsAnEmptySource(): void + { + $this->bindConfig([]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Migration connection name cannot be empty.'); + + Migrator::resolveMigrationConnectionName(''); + } + + public function testResolveMigrationConnectionNameRejectsAnEmptyTarget(): void + { + $this->bindConfig([ + 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => ''], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'The migrations_connection value for database connection [pgsql-pooled] cannot be empty.' + ); + + Migrator::resolveMigrationConnectionName('pgsql-pooled'); + } + + public function testResolveMigrationConnectionNameRejectsANonStringTarget(): void + { + $this->bindConfig([ + 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => ['pgsql']], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Configuration value for key [database.connections.pgsql-pooled.migrations_connection] must be a string, array given.' + ); + + Migrator::resolveMigrationConnectionName('pgsql-pooled'); + } + + #[DataProvider('nonTerminalMigrationConnectionRoutes')] + public function testResolveMigrationConnectionNameRejectsANonTerminalRoute( + array $connections, + string $message, + ): void { + $this->bindConfig($connections); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Migrator::resolveMigrationConnectionName('first'); + } + + /** + * Get non-terminal migration connection routes. + */ + public static function nonTerminalMigrationConnectionRoutes(): array + { + return [ + 'chain' => [ + [ + 'first' => ['driver' => 'pgsql', 'migrations_connection' => 'second'], + 'second' => ['driver' => 'pgsql', 'migrations_connection' => 'third'], + 'third' => ['driver' => 'pgsql'], + ], + 'Database connection [first] routes migrations to [second], but [second] routes migrations to [third]. Migration connections must resolve directly to a terminal connection.', + ], + 'cycle' => [ + [ + 'first' => ['driver' => 'pgsql', 'migrations_connection' => 'second'], + 'second' => ['driver' => 'pgsql', 'migrations_connection' => 'first'], + ], + 'Database connection [first] routes migrations to [second], but [second] routes migrations to [first]. Migration connections must resolve directly to a terminal connection.', + ], + ]; + } + + public function testSetConnectionWritesContextRepositorySourceAndStoredName(): void { $this->bindConfig([ 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], @@ -193,27 +286,23 @@ public function testSetConnectionWritesContextRepositorySourceAndStoredName() ); } - public function testSetConnectionWithNullAndNoEffectiveDefaultStoresNull() + public function testSetConnectionRejectsNullWithoutAnEffectiveDefault(): void { - // No Context override AND no database.default — there's nothing to - // fall back to, so setConnection(null) stores null and leaves Context - // untouched (already absent). $this->bindConfig([]); $resolver = m::mock(Resolver::class); $repository = m::mock(MigrationRepositoryInterface::class); - $repository->shouldReceive('setSource')->once()->with(null); + $repository->shouldNotReceive('setSource'); $migrator = new Migrator($repository, $resolver, new Filesystem); - $migrator->setConnection(null); - $this->assertNull($migrator->getConnection()); - $this->assertNull( - CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), - ); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Migration connection name cannot be empty.'); + + $migrator->setConnection(null); } - public function testSetConnectionDoesNotSwapWhenNoMigrationsConnectionKey() + public function testSetConnectionDoesNotSwapWhenNoMigrationsConnectionKey(): void { $this->bindConfig([ 'pgsql' => ['driver' => 'pgsql'], @@ -234,7 +323,7 @@ public function testSetConnectionDoesNotSwapWhenNoMigrationsConnectionKey() ); } - public function testResolveConnectionUsesStoredConnectionWhenArgumentIsNullOrEmpty() + public function testResolveConnectionUsesStoredConnectionWhenArgumentIsNullOrEmpty(): void { $this->bindConfig([ 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], @@ -276,7 +365,7 @@ public function testResolveConnectionPreservesExplicitZeroConnection(): void $this->assertSame($resolvedConnection, $migrator->resolveConnection('0')); } - public function testResolveConnectionSwapsPerMigrationConnectionOverride() + public function testResolveConnectionSwapsPerMigrationConnectionOverride(): void { // A migration file can return a specific connection name from its // getConnection(). If that connection has migrations_connection set, @@ -297,7 +386,7 @@ public function testResolveConnectionSwapsPerMigrationConnectionOverride() $this->assertSame($resolvedConnection, $migrator->resolveConnection('pgsql-pooled')); } - public function testResolveConnectionPassesSwappedNameToCustomCallback() + public function testResolveConnectionPassesSwappedNameToCustomCallback(): void { $this->bindConfig([ 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], @@ -321,7 +410,7 @@ public function testResolveConnectionPassesSwappedNameToCustomCallback() $this->assertSame($resolvedConnection, $result); } - public function testResolveConnectionPassesOriginalNameWhenNoMigrationsConnection() + public function testResolveConnectionPassesOriginalNameWhenNoMigrationsConnection(): void { $this->bindConfig([ 'pgsql' => ['driver' => 'pgsql'], @@ -338,7 +427,7 @@ public function testResolveConnectionPassesOriginalNameWhenNoMigrationsConnectio $this->assertSame($resolvedConnection, $migrator->resolveConnection('pgsql')); } - public function testSetConnectionWithNullAndPooledDefaultRoutesToDirect() + public function testSetConnectionWithNullAndPooledDefaultRoutesToDirect(): void { // Regression: null name must resolve via database.default. When the // app default is pooled, setConnection(null) should end up at the @@ -365,7 +454,7 @@ public function testSetConnectionWithNullAndPooledDefaultRoutesToDirect() ); } - public function testResolveConnectionWithNullAndPooledDefaultRoutesToDirect() + public function testResolveConnectionWithNullAndPooledDefaultRoutesToDirect(): void { // Regression: fresh Migrator (no setConnection called) handling a // per-migration override of null (or missing getConnection()). The @@ -390,47 +479,82 @@ public function testResolveConnectionWithNullAndPooledDefaultRoutesToDirect() $this->assertSame($resolvedConnection, $migrator->resolveConnection(null)); } - public function testUsingConnectionRestoresExactPriorState() + public function testUsingConnectionRestoresDistinctStoredAndContextState(): void { - // Regression for issue 2: usingConnection must restore the user-facing - // alias, not the swapped direct name. Simulate pre-existing state: a - // prior Context override of 'pgsql-pooled' (as if some outer scope - // had set it) and a stored migrator connection. $this->bindConfig( connections: [ - 'pgsql-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'pgsql'], - 'pgsql' => ['driver' => 'pgsql'], + 'stored' => ['driver' => 'pgsql'], + 'context-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'context-direct'], + 'context-direct' => ['driver' => 'pgsql'], + 'inner-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'inner-direct'], + 'inner-direct' => ['driver' => 'pgsql'], ], - default: 'pgsql-pooled', + default: 'stored', ); - CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'pgsql-pooled'); - $resolver = m::mock(Resolver::class); $repository = m::mock(MigrationRepositoryInterface::class); - - // setConnection inside usingConnection sets source to 'pgsql'. - // Finally restores source to null (the migrator's previous stored state). - $repository->shouldReceive('setSource')->once()->with('pgsql'); - $repository->shouldReceive('setSource')->once()->with(null); + $repository->shouldReceive('setSource')->twice()->with('stored'); + $repository->shouldReceive('setSource')->once()->with('inner-direct'); $migrator = new Migrator($repository, $resolver, new Filesystem); + $migrator->setConnection('stored'); + CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'context-pooled'); + $innerStored = null; $innerContext = null; - $migrator->usingConnection('pgsql-pooled', function () use (&$innerContext) { + $migrator->usingConnection('inner-pooled', function () use ($migrator, &$innerStored, &$innerContext): void { + $innerStored = $migrator->getConnection(); $innerContext = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); }); - $this->assertSame('pgsql', $innerContext, 'Inside the callback, Context should be the swapped name'); + $this->assertSame('inner-direct', $innerStored); + $this->assertSame('inner-direct', $innerContext); + $this->assertSame('stored', $migrator->getConnection()); + $this->assertSame( + 'context-pooled', + CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), + ); + } + + public function testUsingConnectionRestoresDistinctStoredAndContextStateAfterAnException(): void + { + $this->bindConfig( + connections: [ + 'stored' => ['driver' => 'pgsql'], + 'context-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'context-direct'], + 'context-direct' => ['driver' => 'pgsql'], + 'inner-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'inner-direct'], + 'inner-direct' => ['driver' => 'pgsql'], + ], + default: 'stored', + ); + + $resolver = m::mock(Resolver::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $repository->shouldReceive('setSource')->twice()->with('stored'); + $repository->shouldReceive('setSource')->once()->with('inner-direct'); + + $migrator = new Migrator($repository, $resolver, new Filesystem); + $migrator->setConnection('stored'); + CoroutineContext::set(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY, 'context-pooled'); + $failure = new RuntimeException('migration callback failed'); + + try { + $migrator->usingConnection('inner-pooled', static fn (): never => throw $failure); + $this->fail('Expected the migration callback to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame('stored', $migrator->getConnection()); $this->assertSame( - 'pgsql-pooled', + 'context-pooled', CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY), - 'After the callback, Context must be restored to the exact prior alias — not the swapped name', ); - $this->assertNull($migrator->getConnection(), 'Stored connection should be restored to the prior null value'); } - public function testUsingConnectionWithNullAndPooledDefaultRoutesAndRestores() + public function testUsingConnectionWithNullAndPooledDefaultRoutesAndRestores(): void { // Combined regression for issues 1 and 2: null input must route via // database.default, AND the restoration must bring back the exact @@ -467,7 +591,63 @@ public function testUsingConnectionWithNullAndPooledDefaultRoutesAndRestores() $this->assertNull($migrator->getConnection()); } - public function testNestedUsingConnectionPreservesEachLevelsState() + public function testGetMigrationConnectionsDiscoversNamedAnonymousAndContextDeclaredTargets(): void + { + $this->bindConfig( + connections: [ + 'default-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'default-direct'], + 'default-direct' => ['driver' => 'pgsql'], + 'analytics-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'analytics-direct'], + 'reporting-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'analytics-direct'], + 'analytics-direct' => ['driver' => 'pgsql'], + 'context-target' => ['driver' => 'pgsql'], + 'wrong-target' => ['driver' => 'pgsql'], + ], + default: 'default-pooled', + ); + + $resolver = m::mock(Resolver::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $repository->shouldReceive('setSource')->once()->with('default-direct'); + $repository->shouldReceive('setSource')->once()->with(null); + $migrator = new Migrator($repository, $resolver, new Filesystem); + + $connections = $migrator->getMigrationConnections([ + __DIR__ . '/migrations/one', + __DIR__ . '/migrations/connection_targets', + ]); + + $this->assertSame( + ['default-direct', 'analytics-direct', 'context-target'], + $connections, + ); + $this->assertNull($migrator->getConnection()); + $this->assertNull(CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY)); + } + + public function testGetMigrationConnectionsIncludesTheDefaultWithoutMigrationFiles(): void + { + $this->bindConfig( + connections: [ + 'default-pooled' => ['driver' => 'pgsql', 'migrations_connection' => 'default-direct'], + 'default-direct' => ['driver' => 'pgsql'], + ], + default: 'default-pooled', + ); + + $resolver = m::mock(Resolver::class); + $repository = m::mock(MigrationRepositoryInterface::class); + $repository->shouldReceive('setSource')->once()->with('default-direct'); + $repository->shouldReceive('setSource')->once()->with(null); + $migrator = new Migrator($repository, $resolver, new Filesystem); + + $this->assertSame( + ['default-direct'], + $migrator->getMigrationConnections(__DIR__ . '/migrations/missing'), + ); + } + + public function testNestedUsingConnectionPreservesEachLevelsState(): void { // Regression guard: each frame must snapshot and restore its own // prior state. Outer sets 'pgsql-pooled' → 'pgsql'. Inner sets diff --git a/tests/Database/migrations/connection_targets/2026_01_01_000000_create_analytics_probe.php b/tests/Database/migrations/connection_targets/2026_01_01_000000_create_analytics_probe.php new file mode 100644 index 0000000000..5b103fed49 --- /dev/null +++ b/tests/Database/migrations/connection_targets/2026_01_01_000000_create_analytics_probe.php @@ -0,0 +1,40 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('analytics_probe'); + } +}; diff --git a/tests/Database/migrations/connection_targets/2026_01_01_000001_create_reporting_probe.php b/tests/Database/migrations/connection_targets/2026_01_01_000001_create_reporting_probe.php new file mode 100644 index 0000000000..9f1bacfb5d --- /dev/null +++ b/tests/Database/migrations/connection_targets/2026_01_01_000001_create_reporting_probe.php @@ -0,0 +1,32 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reporting_probe'); + } +}; diff --git a/tests/Database/migrations/connection_targets/2026_01_01_000002_create_context_probe.php b/tests/Database/migrations/connection_targets/2026_01_01_000002_create_context_probe.php new file mode 100644 index 0000000000..98ce5ea096 --- /dev/null +++ b/tests/Database/migrations/connection_targets/2026_01_01_000002_create_context_probe.php @@ -0,0 +1,39 @@ +connection = CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY) === 'default-direct' + ? 'context-target' + : 'wrong-target'; + } + + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('context_probe', function (Blueprint $table) { + $table->id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('context_probe'); + } +}; From 1dd074d0a11d2987d77815e073de2243fcc39dc4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:38 +0000 Subject: [PATCH 05/18] feat(database): refresh every migration connection Have migrate:fresh discover the complete set of routed migration connections before destructive work, report the databases that will be wiped or created, and ask for confirmation only after the full scope is known. Create missing MySQL and PostgreSQL targets through driver-owned administration hooks, wipe each pre-existing target, and fail explicitly when wiping, migrating, or seeding fails. Preserve the selected write endpoint and connection baseline when constructing administrative connections. Exercise multi-connection refreshes, missing targets, split read/write configurations, failure propagation, confirmation ordering, seed behavior, and real SQLite routing through unit and integration coverage. --- .../src/Console/Migrations/FreshCommand.php | 72 +-- .../DatabaseMigrationFreshCommandTest.php | 460 ++++++++++++++++-- ...1_01_000000_create_primary_fresh_probe.php | 32 ++ ..._01_01_000001_create_other_fresh_probe.php | 32 ++ ...1_01_000002_create_missing_fresh_probe.php | 32 ++ .../MigrationsConnectionRoutingTest.php | 42 ++ 6 files changed, 600 insertions(+), 70 deletions(-) create mode 100644 tests/Integration/Database/Fixtures/Fresh/2026_01_01_000000_create_primary_fresh_probe.php create mode 100644 tests/Integration/Database/Fixtures/Fresh/2026_01_01_000001_create_other_fresh_probe.php create mode 100644 tests/Integration/Database/Fixtures/Fresh/2026_01_01_000002_create_missing_fresh_probe.php diff --git a/src/database/src/Console/Migrations/FreshCommand.php b/src/database/src/Console/Migrations/FreshCommand.php index 3c318a7ec3..ff78cd8adb 100644 --- a/src/database/src/Console/Migrations/FreshCommand.php +++ b/src/database/src/Console/Migrations/FreshCommand.php @@ -10,12 +10,12 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Events\DatabaseRefreshed; use Hypervel\Database\Migrations\Migrator; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; -use Throwable; #[AsCommand(name: 'migrate:fresh')] -class FreshCommand extends Command +class FreshCommand extends BaseCommand { use ConfirmableTrait; use Prohibitable; @@ -28,12 +28,7 @@ class FreshCommand extends Command /** * The console command description. */ - protected string $description = 'Drop all tables and re-run all migrations'; - - /** - * The migrator instance. - */ - protected Migrator $migrator; + protected string $description = 'Drop all tables from migration connections and re-run all migrations'; /** * Create a new fresh command instance. @@ -50,42 +45,59 @@ public function __construct(Migrator $migrator) */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { + if ($this->isProhibited()) { return Command::FAILURE; } $database = $this->input->getOption('database'); + $paths = $this->getMigrationPaths(); + $connections = $this->migrator->getMigrationConnections($paths, $database); + $missingDatabases = $this->inspectMigrationConnections($connections); + $preExistingConnections = array_values(array_diff($connections, array_keys($missingDatabases))); + + if ($preExistingConnections !== []) { + $this->components->warn('The following database connections will be wiped:'); + $this->components->bulletList($preExistingConnections); + } + + if ($missingDatabases !== []) { + $this->components->warn('The following database connections will be created:'); + $this->components->bulletList(array_keys($missingDatabases)); + } - $this->migrator->usingConnection($database, function () use ($database) { - try { - $repositoryExists = $this->migrator->repositoryExists(); - } catch (Throwable) { - $repositoryExists = false; - } + if (! $this->confirmToProceed()) { + return Command::FAILURE; + } - if ($repositoryExists) { - $this->newLine(); + $this->createMissingDatabases($missingDatabases); - $this->components->task('Dropping all tables', fn () => $this->callSilent('db:wipe', array_filter([ - '--database' => $database, + foreach ($preExistingConnections as $connection) { + $this->components->task("Dropping all tables on [{$connection}]", function () use ($connection): bool { + if ($this->callSilent('db:wipe', array_filter([ + '--database' => $connection, '--drop-views' => $this->option('drop-views'), '--drop-types' => $this->option('drop-types'), '--force' => true, - ])) === 0); - } - }); + ])) !== Command::SUCCESS) { + throw new RuntimeException("Database wipe failed for connection [{$connection}]."); + } + + return true; + }); + } $this->newLine(); - $this->call('migrate', array_filter([ + if ($this->call('migrate', array_filter([ '--database' => $database, '--path' => $this->input->getOption('path'), '--realpath' => $this->input->getOption('realpath'), '--schema-path' => $this->input->getOption('schema-path'), '--force' => true, '--step' => $this->option('step'), - ])); + ])) !== Command::SUCCESS) { + throw new RuntimeException('Migration command failed while refreshing the databases.'); + } if ($this->hypervel->bound(Dispatcher::class)) { $this->hypervel->make(Dispatcher::class)->dispatch( @@ -97,7 +109,7 @@ public function handle(): int $this->runSeeder($database); } - return 0; + return Command::SUCCESS; } /** @@ -113,11 +125,13 @@ protected function needsSeeding(): bool */ protected function runSeeder(?string $database): void { - $this->call('db:seed', array_filter([ + if ($this->call('db:seed', array_filter([ '--database' => $database, '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', '--force' => true, - ])); + ])) !== Command::SUCCESS) { + throw new RuntimeException('Database seeding failed after the databases were refreshed.'); + } } /** @@ -126,7 +140,7 @@ protected function runSeeder(?string $database): void protected function getOptions(): array { return [ - ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'], + ['database', null, InputOption::VALUE_OPTIONAL, 'The default database connection to use'], ['drop-views', null, InputOption::VALUE_NONE, 'Drop all tables and views'], ['drop-types', null, InputOption::VALUE_NONE, 'Drop all tables and types (Postgres only)'], ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], diff --git a/tests/Database/DatabaseMigrationFreshCommandTest.php b/tests/Database/DatabaseMigrationFreshCommandTest.php index 617d76cce6..f79cdeaa21 100644 --- a/tests/Database/DatabaseMigrationFreshCommandTest.php +++ b/tests/Database/DatabaseMigrationFreshCommandTest.php @@ -4,95 +4,468 @@ namespace Hypervel\Tests\Database; -use Hypervel\Console\CommandMutex; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Console\Migrations\FreshCommand; use Hypervel\Database\Events\DatabaseRefreshed; use Hypervel\Database\Migrations\Migrator; +use Hypervel\Database\SQLiteDatabaseDoesNotExistException; use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; class DatabaseMigrationFreshCommandTest extends TestCase { - public function testFreshCommandDropsTablesMigratesAndSeeds() + public function testFreshWipesEveryPreExistingTargetThenMigratesDispatchesAndSeeds(): void { - $app = new ApplicationDatabaseFreshStub; - $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)->shouldIgnoreMissing()); + $app = $this->makeApplication(); + $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->byDefault()->andReturn(false); $command = $this->getMockBuilder(FreshCommand::class) ->onlyMethods(['call', 'callSilent']) ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) ->getMock(); $command->setHypervel($app); - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { - return $callback(); - }); - $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); - $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); + $this->expectPreExistingMigrationTargets($migrator, ['sqlite', 'analytics'], registeredPaths: ['/registered']); + $operations = []; + + $command->expects($this->exactly(2))->method('callSilent')->willReturnCallback( + function (string $name, array $arguments) use (&$operations): int { + $this->assertSame('db:wipe', $name); + $connection = count($operations) === 0 ? 'sqlite' : 'analytics'; + $this->assertSame([ + '--database' => $connection, + '--drop-views' => true, + '--drop-types' => true, + '--force' => true, + ], $arguments); + $operations[] = "wipe:{$connection}"; + + return 0; + } + ); + $command->expects($this->exactly(2))->method('call')->willReturnCallback( + function (string $name, array $arguments) use (&$operations): int { + if ($name === 'migrate') { + $this->assertSame([ + '--database' => 'sqlite', + '--force' => true, + ], $arguments); + $operations[] = 'migrate'; - $callCount = 0; - $command->expects($this->exactly(2))->method('call')->willReturnCallback(function (string $name, array $arguments) use (&$callCount) { - ++$callCount; + return 0; + } - if ($callCount === 1) { - $this->assertSame('migrate', $name); + $this->assertSame('db:seed', $name); $this->assertSame([ '--database' => 'sqlite', + '--class' => 'Database\Seeders\CustomSeeder', '--force' => true, ], $arguments); + $operations[] = 'seed'; return 0; } + ); + $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class))->andReturnUsing( + function (DatabaseRefreshed $event) use (&$operations): void { + $this->assertSame('sqlite', $event->database); + $this->assertTrue($event->seeding); + $operations[] = 'event'; + } + ); - $this->assertSame('db:seed', $name); - $this->assertSame([ - '--database' => 'sqlite', - '--class' => 'Database\Seeders\CustomSeeder', - '--force' => true, - ], $arguments); + $code = $this->runCommand($command, [ + '--database' => 'sqlite', + '--drop-views' => true, + '--drop-types' => true, + '--seed' => true, + '--seeder' => 'Database\Seeders\CustomSeeder', + ]); + + $this->assertSame(0, $code); + $this->assertSame(['wipe:sqlite', 'wipe:analytics', 'migrate', 'event', 'seed'], $operations); + } + + public function testReachableDatabaseWithoutMigrationRepositoryIsStillWiped(): void + { + $app = $this->makeApplication(); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $this->expectPreExistingMigrationTargets($migrator, ['sqlite'], repositoryExists: false); + $command->expects($this->once())->method('callSilent')->with('db:wipe', [ + '--database' => 'sqlite', + '--force' => true, + ])->willReturn(0); + $command->expects($this->once())->method('call')->with('migrate', [ + '--database' => 'sqlite', + '--force' => true, + ])->willReturn(0); + + $this->assertSame(0, $this->runCommand($command, ['--database' => 'sqlite'])); + } + + public function testMissingTargetIsCreatedAndVerifiedButNeverWiped(): void + { + $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); + unlink($path); + $app = $this->makeApplication(); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $currentConnection = null; + $analyticsInspections = 0; + + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, 'sqlite')->andReturn(['sqlite', 'analytics']); + $migrator->shouldReceive('usingConnection')->times(3)->andReturnUsing( + function ($name, $callback) use (&$currentConnection) { + $currentConnection = $name; + + try { + return $callback(); + } finally { + $currentConnection = null; + } + } + ); + $migrator->shouldReceive('repositoryExists')->times(3)->andReturnUsing( + function () use (&$currentConnection, &$analyticsInspections, $path): bool { + if ($currentConnection === 'analytics') { + ++$analyticsInspections; + + if ($analyticsInspections === 1) { + throw new SQLiteDatabaseDoesNotExistException($path); + } + + $this->assertFileExists($path); + } + + return true; + } + ); + $command->expects($this->once())->method('callSilent')->with('db:wipe', [ + '--database' => 'sqlite', + '--force' => true, + ])->willReturnCallback(function () use ($path, &$analyticsInspections): int { + $this->assertFileExists($path); + $this->assertSame(2, $analyticsInspections); return 0; }); - $command->expects($this->once())->method('callSilent')->with('db:wipe', [ + $command->expects($this->once())->method('call')->with('migrate', [ '--database' => 'sqlite', - '--drop-views' => true, '--force' => true, ])->willReturn(0); + $output = new BufferedOutput; - $this->runCommand($command, ['--database' => 'sqlite', '--drop-views' => true, '--seed' => true, '--seeder' => 'Database\Seeders\CustomSeeder']); + try { + $this->assertSame(0, $this->runCommand($command, ['--database' => 'sqlite'], $output)); + $content = $output->fetch(); + $this->assertStringContainsString('will be wiped', $content); + $this->assertStringContainsString('will be created', $content); + $this->assertStringContainsString('sqlite', $content); + $this->assertStringContainsString('analytics', $content); + $this->assertFileExists($path); + } finally { + @unlink($path); + } } - public function testFreshCommandRunsMigrationsWhenRepositoryLookupFails(): void + public function testDecliningProductionConfirmationCreatesAndWipesNothing(): void { - $app = new ApplicationDatabaseFreshStub; - $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)->shouldIgnoreMissing()); + $path = tempnam(sys_get_temp_dir(), 'hypervel-sqlite-'); + unlink($path); + $app = $this->makeApplication('production'); $command = $this->getMockBuilder(FreshCommand::class) ->onlyMethods(['call', 'callSilent']) ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) ->getMock(); $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $inspection = 0; + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, 'sqlite')->andReturn(['sqlite', 'analytics']); + $migrator->shouldReceive('usingConnection')->twice()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->twice()->andReturnUsing( + function () use (&$inspection, $path): bool { + if (++$inspection === 2) { + throw new SQLiteDatabaseDoesNotExistException($path); + } - $migrator->shouldReceive('usingConnection')->once()->andReturnUsing( - static fn ($name, $callback) => $callback() + return true; + } ); - $migrator->shouldReceive('repositoryExists')->once()->andThrow(new RuntimeException('Database does not exist.')); - $dispatcher->shouldReceive('dispatch')->once()->with(m::type(DatabaseRefreshed::class)); + $command->expects($this->never())->method('callSilent'); + $command->expects($this->never())->method('call'); + + try { + $this->assertSame(1, $this->runCommand($command, ['--database' => 'sqlite'])); + $this->assertFileDoesNotExist($path); + } finally { + @unlink($path); + } + } + public function testCreationFailurePreventsEveryWipeAndMigration(): void + { + $path = '/missing/database.sqlite'; + $app = $this->makeApplication(); + $command = $this->getMockBuilder(TestableFreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->creationFailure = $failure = new RuntimeException('create failed'); + $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $inspection = 0; + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, 'sqlite')->andReturn(['sqlite', 'analytics']); + $migrator->shouldReceive('usingConnection')->twice()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->twice()->andReturnUsing( + function () use (&$inspection, $path): bool { + if (++$inspection === 2) { + throw new SQLiteDatabaseDoesNotExistException($path); + } + + return true; + } + ); $command->expects($this->never())->method('callSilent'); + $command->expects($this->never())->method('call'); + $caught = null; + + try { + $this->runCommand($command, ['--database' => 'sqlite']); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame($failure, $caught); + } + + public function testWipeFailureAbortsBeforeMigration(): void + { + $app = $this->makeApplication(); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $this->expectPreExistingMigrationTargets($migrator, ['analytics']); + $command->expects($this->once())->method('callSilent')->willReturn(1); + $command->expects($this->never())->method('call'); + $caught = null; + + try { + $this->runCommand($command, ['--database' => 'sqlite']); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame('Database wipe failed for connection [analytics].', $caught?->getMessage()); + } + + public function testMigrationFailureAbortsBeforeEventAndSeeding(): void + { + $app = $this->makeApplication(); + $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->byDefault()->andReturn(false); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $this->expectPreExistingMigrationTargets($migrator, ['sqlite']); + $command->expects($this->once())->method('callSilent')->willReturn(0); $command->expects($this->once())->method('call')->with('migrate', [ - '--database' => 'missing', + '--database' => 'sqlite', '--force' => true, - ])->willReturn(0); + ])->willReturn(1); + $dispatcher->shouldReceive('dispatch')->never(); + $caught = null; + + try { + $this->runCommand($command, ['--database' => 'sqlite', '--seed' => true]); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame('Migration command failed while refreshing the databases.', $caught?->getMessage()); + } + + public function testSeedFailureOccursAfterDatabaseRefreshedEventAndFailsTheCommand(): void + { + $app = $this->makeApplication(); + $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->byDefault()->andReturn(false); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $this->expectPreExistingMigrationTargets($migrator, ['sqlite']); + $operations = []; + $command->expects($this->once())->method('callSilent')->willReturn(0); + $command->expects($this->exactly(2))->method('call')->willReturnCallback( + function (string $name) use (&$operations): int { + $operations[] = $name; + + return $name === 'migrate' ? 0 : 1; + } + ); + $dispatcher->shouldReceive('dispatch')->once()->andReturnUsing(function () use (&$operations): void { + $operations[] = 'event'; + }); + $caught = null; + + try { + $this->runCommand($command, ['--database' => 'sqlite', '--seed' => true]); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame('Database seeding failed after the databases were refreshed.', $caught?->getMessage()); + $this->assertSame(['migrate', 'event', 'db:seed'], $operations); + } + + public function testUnclassifiedInspectionFailurePropagatesBeforeMutation(): void + { + $app = $this->makeApplication(); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $paths = [__DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + $failure = new RuntimeException('Authentication failed.'); + $migrator->shouldReceive('paths')->once()->andReturn([]); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, 'sqlite')->andReturn(['sqlite']); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->once()->andThrow($failure); + $command->expects($this->never())->method('callSilent'); + $command->expects($this->never())->method('call'); + $caught = null; + + try { + $this->runCommand($command, ['--database' => 'sqlite']); + } catch (RuntimeException $throwable) { + $caught = $throwable; + } + + $this->assertSame($failure, $caught); + } + + #[DataProvider('migrationPathProvider')] + public function testFreshUsesTheSamePathOptionsForDiscoveryAndNestedMigrate( + array $input, + array $discoveryPaths, + array $migrateArguments, + ): void { + $app = $this->makeApplication(); + $command = $this->getMockBuilder(FreshCommand::class) + ->onlyMethods(['call', 'callSilent']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class)]) + ->getMock(); + $command->setHypervel($app); + $migrator->shouldReceive('paths')->never(); + $migrator->shouldReceive('getMigrationConnections')->once()->with($discoveryPaths, 'sqlite')->andReturn(['sqlite']); + $migrator->shouldReceive('usingConnection')->once()->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->once()->andReturn(true); + $command->expects($this->once())->method('callSilent')->willReturn(0); + $command->expects($this->once())->method('call')->with('migrate', $migrateArguments)->willReturn(0); + + $this->assertSame(0, $this->runCommand($command, ['--database' => 'sqlite', ...$input])); + } + + public static function migrationPathProvider(): array + { + return [ + 'relative path' => [ + ['--path' => ['custom/migrations']], + [__DIR__ . '/custom/migrations'], + [ + '--database' => 'sqlite', + '--path' => ['custom/migrations'], + '--force' => true, + ], + ], + 'real path' => [ + ['--path' => ['/absolute/migrations'], '--realpath' => true], + ['/absolute/migrations'], + [ + '--database' => 'sqlite', + '--path' => ['/absolute/migrations'], + '--realpath' => true, + '--force' => true, + ], + ], + ]; + } - $this->assertSame(0, $this->runCommand($command, ['--database' => 'missing'])); + public function testProhibitedFreshReturnsBeforeDiscovery(): void + { + $app = $this->makeApplication(); + $command = new FreshCommand($migrator = m::mock(Migrator::class)); + $command->setHypervel($app); + $migrator->shouldNotReceive('getMigrationConnections'); + FreshCommand::prohibit(); + + $this->assertSame(1, $this->runCommand($command, ['--database' => 'sqlite'])); } - protected function runCommand($command, array $input = []): int + private function expectPreExistingMigrationTargets( + Migrator $migrator, + array $connections, + ?string $database = 'sqlite', + bool $repositoryExists = true, + array $registeredPaths = [], + ): void { + $paths = [...$registeredPaths, __DIR__ . DIRECTORY_SEPARATOR . 'migrations']; + + $migrator->shouldReceive('paths')->once()->andReturn($registeredPaths); + $migrator->shouldReceive('getMigrationConnections')->once()->with($paths, $database)->andReturn($connections); + $migrator->shouldReceive('usingConnection')->times(count($connections))->andReturnUsing(function ($name, $callback) { + return $callback(); + }); + $migrator->shouldReceive('repositoryExists')->times(count($connections))->andReturn($repositoryExists); + } + + private function makeApplication(string $environment = 'development'): ApplicationDatabaseFreshStub { - return $command->run(new ArrayInput($input), new NullOutput); + $app = new ApplicationDatabaseFreshStub(['env' => $environment]); + $app->setBasePath(__DIR__); + $app->useDatabasePath(__DIR__); + + return $app; + } + + protected function runCommand( + FreshCommand $command, + array $input = [], + ?OutputInterface $output = null, + ): int { + return $command->run(new ArrayInput($input), $output ?? new NullOutput); } } @@ -100,10 +473,6 @@ class ApplicationDatabaseFreshStub extends Application { public function __construct(array $data = []) { - $mutex = m::mock(CommandMutex::class); - $mutex->shouldReceive('create')->andReturn(true); - $mutex->shouldReceive('release')->andReturn(true); - $this->instance(CommandMutex::class, $mutex); $this->instance('env', 'development'); foreach ($data as $abstract => $instance) { @@ -112,9 +481,18 @@ public function __construct(array $data = []) static::setInstance($this); } +} + +class TestableFreshCommand extends FreshCommand +{ + public ?RuntimeException $creationFailure = null; - public function environment(...$environments): bool|string + protected function createMissingDatabase(string $connectionName, Throwable $cause): void { - return 'development'; + if ($this->creationFailure !== null) { + throw $this->creationFailure; + } + + parent::createMissingDatabase($connectionName, $cause); } } diff --git a/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000000_create_primary_fresh_probe.php b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000000_create_primary_fresh_probe.php new file mode 100644 index 0000000000..9f25a31397 --- /dev/null +++ b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000000_create_primary_fresh_probe.php @@ -0,0 +1,32 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('primary_fresh_probe'); + } +}; diff --git a/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000001_create_other_fresh_probe.php b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000001_create_other_fresh_probe.php new file mode 100644 index 0000000000..848801a174 --- /dev/null +++ b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000001_create_other_fresh_probe.php @@ -0,0 +1,32 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('other_fresh_probe'); + } +}; diff --git a/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000002_create_missing_fresh_probe.php b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000002_create_missing_fresh_probe.php new file mode 100644 index 0000000000..b444e3bf53 --- /dev/null +++ b/tests/Integration/Database/Fixtures/Fresh/2026_01_01_000002_create_missing_fresh_probe.php @@ -0,0 +1,32 @@ +id(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('missing_fresh_probe'); + } +}; diff --git a/tests/Integration/Database/MigrationsConnectionRoutingTest.php b/tests/Integration/Database/MigrationsConnectionRoutingTest.php index 5deaef517e..8883574d70 100644 --- a/tests/Integration/Database/MigrationsConnectionRoutingTest.php +++ b/tests/Integration/Database/MigrationsConnectionRoutingTest.php @@ -30,6 +30,8 @@ class MigrationsConnectionRoutingTest extends TestCase protected string $otherPath; + protected string $missingPath; + protected function defineEnvironment(ApplicationContract $app): void { // Paths resolve inside testbench's disposable cloned workspace so the @@ -38,6 +40,7 @@ protected function defineEnvironment(ApplicationContract $app): void $this->primaryPath = $app->databasePath('primary.sqlite'); $this->migrationsPath = $app->databasePath('primary-migrations.sqlite'); $this->otherPath = $app->databasePath('other.sqlite'); + $this->missingPath = $app->databasePath('missing-secondary.sqlite'); // SQLite requires the file to exist — Hypervel (like Laravel) refuses // to auto-create missing database files. touch() creates an empty @@ -75,6 +78,13 @@ protected function defineEnvironment(ApplicationContract $app): void 'prefix' => '', 'foreign_key_constraints' => false, ]); + + $config->set('database.connections.missing-secondary', [ + 'driver' => 'sqlite', + 'database' => $this->missingPath, + 'prefix' => '', + 'foreign_key_constraints' => false, + ]); } protected function tearDown(): void @@ -84,12 +94,14 @@ protected function tearDown(): void $db->purge('primary'); $db->purge('primary-migrations'); $db->purge('other'); + $db->purge('missing-secondary'); CoroutineContext::forget(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY); File::delete($this->primaryPath); File::delete($this->migrationsPath); File::delete($this->otherPath); + File::delete($this->missingPath); parent::tearDown(); } @@ -256,4 +268,34 @@ public function testScopedDefaultOverrideRoutesMigrationsToContextSibling(): voi $this->app->make('config')->set('database.default', 'primary'); } } + + public function testFreshWipesEveryDeclaredTargetAndCreatesMissingSecondaryDatabase(): void + { + /** @var DatabaseManager $db */ + $db = $this->app->make('db'); + + $db->connection('primary-migrations')->getSchemaBuilder()->create('stale_primary', function (Blueprint $table) { + $table->id(); + }); + $db->connection('other')->getSchemaBuilder()->create('stale_other', function (Blueprint $table) { + $table->id(); + }); + + $this->assertFileDoesNotExist($this->missingPath); + + $this->artisan('migrate:fresh', [ + '--database' => 'primary', + '--path' => [__DIR__ . '/Fixtures/Fresh'], + '--realpath' => true, + '--force' => true, + ])->assertExitCode(0); + + $this->assertFalse($db->connection('primary-migrations')->getSchemaBuilder()->hasTable('stale_primary')); + $this->assertFalse($db->connection('other')->getSchemaBuilder()->hasTable('stale_other')); + $this->assertTrue($db->connection('primary-migrations')->getSchemaBuilder()->hasTable('primary_fresh_probe')); + $this->assertTrue($db->connection('other')->getSchemaBuilder()->hasTable('other_fresh_probe')); + $this->assertFileExists($this->missingPath); + $this->assertTrue($db->connection('missing-secondary')->getSchemaBuilder()->hasTable('missing_fresh_probe')); + $this->assertFalse($db->connection('primary')->getSchemaBuilder()->hasTable('primary_fresh_probe')); + } } From 9b61b3d2f2f440333ef41e685666dc62f3af22f2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:58:51 +0000 Subject: [PATCH 06/18] fix(database): keep wrappers after database wipes Disconnect the active connection after db:wipe instead of purging the manager entry. This releases driver resources while preserving wrapper identity for testing lifecycle state and lazy reconnects. Add regressions for direct command behavior, file-backed lazy refreshes, and migrations without the testing pool so subsequent queries reconnect through the existing wrapper rather than retaining stale lifecycle references. --- src/database/src/Console/WipeCommand.php | 8 +- tests/Database/DatabaseWipeCommandTest.php | 28 +++--- ...azilyRefreshDatabaseFileConnectionTest.php | 89 +++++++++++++++++++ ...pervelMigrationsWithoutTestingPoolTest.php | 57 ++++++++++++ 4 files changed, 163 insertions(+), 19 deletions(-) create mode 100644 tests/Testbench/Databases/LazilyRefreshDatabaseFileConnectionTest.php create mode 100644 tests/Testbench/Databases/MigrateWithHypervelMigrationsWithoutTestingPoolTest.php diff --git a/src/database/src/Console/WipeCommand.php b/src/database/src/Console/WipeCommand.php index cb3b9bf61f..d022f2a74f 100644 --- a/src/database/src/Console/WipeCommand.php +++ b/src/database/src/Console/WipeCommand.php @@ -94,16 +94,10 @@ protected function dropAllTypes(?string $database): void /** * Flush the given database connection. - * - * Uses purge() instead of disconnect() because Hypervel's pooled connection - * architecture caches connection wrappers. disconnect() only nulls the PDO - * on the cached wrapper, leaving it in place — the next query reuses the - * disconnected wrapper and triggers a reconnect. purge() fully resets the - * connection including pool and resolver caches. */ protected function flushDatabaseConnection(?string $database): void { - $this->hypervel->make('db')->purge($database); + $this->hypervel->make('db')->connection($database)->disconnect(); } /** diff --git a/tests/Database/DatabaseWipeCommandTest.php b/tests/Database/DatabaseWipeCommandTest.php index 3537ab75cd..cb2c9eed12 100644 --- a/tests/Database/DatabaseWipeCommandTest.php +++ b/tests/Database/DatabaseWipeCommandTest.php @@ -24,7 +24,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testWipeCommandDropsSchemaObjectsAndPurgesConnection() + public function testWipeCommandDropsSchemaObjectsAndDisconnectsConnection(): void { $schemaBuilder = m::mock(); $schemaBuilder->shouldReceive('dropAllViews')->once(); @@ -33,10 +33,11 @@ public function testWipeCommandDropsSchemaObjectsAndPurgesConnection() $connection = m::mock(); $connection->shouldReceive('getSchemaBuilder')->times(3)->andReturn($schemaBuilder); + $connection->shouldReceive('disconnect')->once(); $db = m::mock(); - $db->shouldReceive('connection')->times(3)->with('pgsql')->andReturn($connection); - $db->shouldReceive('purge')->once()->with('pgsql'); + $db->shouldReceive('connection')->times(4)->with('pgsql')->andReturn($connection); + $db->shouldNotReceive('purge'); $app = new ApplicationDatabaseWipeStub([ 'db' => $db, @@ -54,20 +55,21 @@ public function testWipeCommandDropsSchemaObjectsAndPurgesConnection() $this->assertSame(0, $code); } - public function testWipeCommandRoutesToMigrationsConnection() + public function testWipeCommandRoutesToMigrationsConnection(): void { $schemaBuilder = m::mock(); $schemaBuilder->shouldReceive('dropAllTables')->once(); $connection = m::mock(); $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($schemaBuilder); + $connection->shouldReceive('disconnect')->once(); $db = m::mock(); // db:wipe --database=pgsql-pooled should route to 'pgsql' because // pgsql-pooled has migrations_connection => 'pgsql'. Schema drops // need a direct (unpooled) connection. - $db->shouldReceive('connection')->once()->with('pgsql')->andReturn($connection); - $db->shouldReceive('purge')->once()->with('pgsql'); + $db->shouldReceive('connection')->twice()->with('pgsql')->andReturn($connection); + $db->shouldNotReceive('purge'); $app = new ApplicationDatabaseWipeStub([ 'db' => $db, @@ -91,7 +93,7 @@ public function testWipeCommandRoutesToMigrationsConnection() $this->assertSame(0, $code); } - public function testWipeCommandRoutesThroughDefaultWhenNoDatabaseOptionGiven() + public function testWipeCommandRoutesThroughDefaultWhenNoDatabaseOptionGiven(): void { // Regression for the null-handling fix: db:wipe with no --database // should use the configured default — and if that default has a @@ -102,10 +104,11 @@ public function testWipeCommandRoutesThroughDefaultWhenNoDatabaseOptionGiven() $connection = m::mock(); $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($schemaBuilder); + $connection->shouldReceive('disconnect')->once(); $db = m::mock(); - $db->shouldReceive('connection')->once()->with('pgsql')->andReturn($connection); - $db->shouldReceive('purge')->once()->with('pgsql'); + $db->shouldReceive('connection')->twice()->with('pgsql')->andReturn($connection); + $db->shouldNotReceive('purge'); $app = new ApplicationDatabaseWipeStub([ 'db' => $db, @@ -128,7 +131,7 @@ public function testWipeCommandRoutesThroughDefaultWhenNoDatabaseOptionGiven() $this->assertSame(0, $code); } - public function testWipeCommandHonorsContextOverrideWhenNoDatabaseOptionGiven() + public function testWipeCommandHonorsContextOverrideWhenNoDatabaseOptionGiven(): void { // End-to-end regression for "effective default" at the command level: // when an outer scope has set Context (e.g. via DB::usingConnection), @@ -139,10 +142,11 @@ public function testWipeCommandHonorsContextOverrideWhenNoDatabaseOptionGiven() $connection = m::mock(); $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($schemaBuilder); + $connection->shouldReceive('disconnect')->once(); $db = m::mock(); - $db->shouldReceive('connection')->once()->with('tenant-direct')->andReturn($connection); - $db->shouldReceive('purge')->once()->with('tenant-direct'); + $db->shouldReceive('connection')->twice()->with('tenant-direct')->andReturn($connection); + $db->shouldNotReceive('purge'); $app = new ApplicationDatabaseWipeStub([ 'db' => $db, diff --git a/tests/Testbench/Databases/LazilyRefreshDatabaseFileConnectionTest.php b/tests/Testbench/Databases/LazilyRefreshDatabaseFileConnectionTest.php new file mode 100644 index 0000000000..ece6626b60 --- /dev/null +++ b/tests/Testbench/Databases/LazilyRefreshDatabaseFileConnectionTest.php @@ -0,0 +1,89 @@ +deleteDirectory(static::$databaseDirectory); + $filesystem->ensureDirectoryExists(static::$databaseDirectory); + + static::$databasePath = static::$databaseDirectory . '/database.sqlite'; + touch(static::$databasePath); + } + + public static function tearDownAfterClass(): void + { + (new Filesystem)->deleteDirectory(static::$databaseDirectory); + + parent::tearDownAfterClass(); + } + + #[Override] + protected function defineEnvironment(ApplicationContract $app): void + { + parent::defineEnvironment($app); + + $app->make('config')->set('database.connections.testing.database', static::$databasePath); + } + + #[Test] + public function itRunsTheTriggeringStatementInsideTheLazyTransaction(): void + { + $now = CarbonImmutable::now(); + + DB::table('users')->insert([ + 'name' => 'Orchestra', + 'email' => 'lazy-refresh@example.com', + 'password' => 'secret', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $this->assertSame( + 1, + DB::table('users')->where('email', 'lazy-refresh@example.com')->count(), + ); + } + + #[Test] + #[Depends('itRunsTheTriggeringStatementInsideTheLazyTransaction')] + public function itRollsBackTheStatementThatTriggeredLazyRefresh(): void + { + $this->assertSame( + 0, + DB::table('users')->where('email', 'lazy-refresh@example.com')->count(), + ); + } +} diff --git a/tests/Testbench/Databases/MigrateWithHypervelMigrationsWithoutTestingPoolTest.php b/tests/Testbench/Databases/MigrateWithHypervelMigrationsWithoutTestingPoolTest.php new file mode 100644 index 0000000000..b10a898294 --- /dev/null +++ b/tests/Testbench/Databases/MigrateWithHypervelMigrationsWithoutTestingPoolTest.php @@ -0,0 +1,57 @@ +insert([ + 'name' => 'Orchestra', + 'email' => 'crynobone@gmail.com', + 'password' => Hash::make('456'), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $users = DB::table('users')->where('id', '=', 1)->first(); + + $this->assertEquals('crynobone@gmail.com', $users->email); + $this->assertTrue(Hash::check('456', $users->password)); + } + + #[Test] + public function itStartsTheFirstUserTransactionInsideTheLazyTestTransaction(): void + { + $connection = DB::connection(); + + $this->assertSame(0, $connection->transactionLevel()); + + $connection->beginTransaction(); + + $this->assertSame(2, $connection->transactionLevel()); + + $connection->rollBack(); + + $this->assertSame(1, $connection->transactionLevel()); + } +} From d888a52ab48498d8c77f86910bb1b853a3dd8dd8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:17:15 +0000 Subject: [PATCH 07/18] docs: clarify framework porting workflow Reserve the Hyperf porting guide for the rare package or package-update ports that still require it, and describe Laravel work as porting packages and updates. Add a repository rule to preserve primary source files when splitting or relocating implementations by copying them first and then adapting the copy. This reduces the risk of silently dropping behavior, comments, or structure during architectural refactors. --- AGENTS.md | 5 +++-- docs/ai/porting-hyperf.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b2a28810e..addfd829cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Hypervel is a standalone Laravel-style Swoole framework. The public API should s Laravel is the main API reference. Hyperf is a historical and architectural reference for some lower-level Swoole/coroutine packages, but Hypervel code should follow current Hypervel patterns rather than copying Hyperf structure mechanically. -Most work in this repo today is framework bug fixing and enhancement, or porting Laravel packages. Hyperf-to-Hypervel porting is largely done — the conversion guide lives in `docs/ai/porting-hyperf.md`, read when maintaining previously ported code or doing the occasional remaining port. +Most work in this repo today is framework bug fixes and enhancements, or porting Laravel packages and updates. `docs/ai/porting-hyperf.md` applies only to the rare Hyperf package or update port. This file is intentionally detailed because agents trained on Laravel will otherwise assume Laravel's request lifecycle and miss Hypervel's Swoole/coroutine constraints. @@ -125,6 +125,7 @@ The Working rules and the Avoid overengineering rules apply to all work in this - **One file at a time** — never work on multiple files simultaneously. This governs manual editing; package-manager and formatter runs may touch multiple files. - **Never use Write to overwrite files** — always use Edit for targeted updates. - **Always use `cp` to copy files and `mv` to move/rename** — never read → write new version → delete old version. +- **Copy before splitting** — When a new file or class is primarily extracted from existing code, use `cp` to copy the primary source first and then update the copy rather than rebuilding it from individual pieces. Copy any additional blocks, including comments and docblocks, into the destination before removing them from their source. - **Grep broadly — never assume a subdir** — when searching for any symbol, class, method, or pattern, grep across the whole `src/` (or `tests/`) tree, not a specific package subdir. Assumptions about where something lives produce false negatives. - **Read the source before describing behavior** — never state how code behaves from memory or Laravel assumptions. Hypervel's coroutine runtime breaks many Laravel assumptions; if you haven't read the relevant source, read it first. - **Treat past owner decisions as context, not constraints** — Previous owner approvals and completed plans explain history but do not determine the best design today. Never retain or reject a design merely because it was previously approved; decide from current requirements, code, and evidence. @@ -758,7 +759,7 @@ Hyperf is a historical reference rather than an ongoing merge target. For the ra When working on a package, check its README for the upstream reference before making changes. Most Hypervel packages are ports of Laravel first-party or third-party ecosystem packages, such as Spatie packages. Most low-level Swoole infrastructure packages were originally ported from Hyperf, and a few packages are Hypervel-specific. -Before porting Hyperf code or modifying a Hyperf-ported package, read `docs/ai/porting-hyperf.md` — it covers the conversion mechanics: container calls, ConfigProvider migration, listener/event conversion, and Hyperf test porting. +Read `docs/ai/porting-hyperf.md` only when porting a Hyperf package or update. ### Source workflow diff --git a/docs/ai/porting-hyperf.md b/docs/ai/porting-hyperf.md index 0f828e6bb5..90eecf0769 100644 --- a/docs/ai/porting-hyperf.md +++ b/docs/ai/porting-hyperf.md @@ -1,6 +1,6 @@ # Porting Hyperf Code to Hypervel -Read this before porting Hyperf code or modifying a Hyperf-ported package. It covers the Hyperf side of the conversion: container calls, ConfigProviders, listeners/events, and tests. Hypervel's own container semantics, binding patterns, and alias rules live in the Container section of `AGENTS.md` — this doc assumes you have read them. +Read this guide only when porting a Hyperf package or update. It covers the Hyperf side of the conversion: container calls, ConfigProviders, listeners/events, and tests. Hypervel's own container semantics, binding patterns, and alias rules live in the Container section of `AGENTS.md` — this doc assumes you have read them. Hyperf ports do not aim for upstream fidelity. The preserve-upstream rules under Porting Packages in `AGENTS.md` exist for upstreams we keep merging from — Laravel first-party and Laravel-ecosystem packages — and Hyperf is neither: it's a historical reference. Adapt ported code fully to Hypervel structure, style, and naming, including cleaning up variable and method names, following this guide. From ae776610944dbb484f981f2ed8bb687bd619e708 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:23:02 +0000 Subject: [PATCH 08/18] docs(context): explain copied value ownership Document that context copying shares ordinary objects, replicates values implementing ReplicableContext, and omits values implementing NonCopyableContext. Apply the same contract to the Parallel and Waiter APIs, coroutine helper PHPDoc, and the context and concurrency guides so callers can choose copying behavior without accidentally sharing coroutine-owned resources. --- src/coroutine/src/Parallel.php | 5 +++-- src/coroutine/src/Waiter.php | 4 +++- src/coroutine/src/functions.php | 16 ++++++++++++---- src/docs/concurrency.md | 4 +++- src/docs/context.md | 2 +- src/docs/coroutine-context.md | 31 ++++++++++++++++++++++--------- src/docs/coroutines.md | 4 ++-- 7 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/coroutine/src/Parallel.php b/src/coroutine/src/Parallel.php index faa3d156d2..84fc8e0921 100644 --- a/src/coroutine/src/Parallel.php +++ b/src/coroutine/src/Parallel.php @@ -33,8 +33,9 @@ class Parallel * @param int $concurrent Maximum concurrent coroutines (0 = unlimited) * @param array|bool $copyContext When set, parent coroutine context is copied to each child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values from the parent context are shared by reference; values implementing - * Hypervel\Context\ReplicableContext are deep-copied via replicate(). + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. */ public function __construct( int $concurrent = 0, diff --git a/src/coroutine/src/Waiter.php b/src/coroutine/src/Waiter.php index 907dc6c05e..7e16339469 100644 --- a/src/coroutine/src/Waiter.php +++ b/src/coroutine/src/Waiter.php @@ -38,7 +38,9 @@ public function __construct(float $timeout = self::DEFAULT_POP_TIMEOUT_SECONDS) * @param null|float $timeout Timeout in seconds (null uses default) * @param array|bool $copyContext When set, parent coroutine context is copied to the child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. * @param bool $waitForChildTermination Wait without a limit when a cancelled child exceeds the cleanup allowance * @return TReturn * @throws WaitTimeoutException When the wait times out diff --git a/src/coroutine/src/functions.php b/src/coroutine/src/functions.php index 14e81ffa52..c2a318c2f7 100644 --- a/src/coroutine/src/functions.php +++ b/src/coroutine/src/functions.php @@ -16,7 +16,9 @@ * @param int $concurrent if $concurrent is equal to 0, that means unlimited * @param array|bool $copyContext When set, parent coroutine context is copied to each child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. */ function parallel(array $callables, int $concurrent = 0, bool|array $copyContext = false): array { @@ -33,7 +35,9 @@ function parallel(array $callables, int $concurrent = 0, bool|array $copyContext * @param Closure():TReturn $closure * @param array|bool $copyContext When set, parent coroutine context is copied to the child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. * @param bool $waitForChildTermination Wait without a limit when a cancelled child exceeds the cleanup allowance * @return TReturn * @throws WaitTimeoutException When the wait times out @@ -53,7 +57,9 @@ function wait( /** * @param array|bool $copyContext When set, parent coroutine context is copied to the child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. */ function co(callable $callable, bool|array $copyContext = false): int { @@ -70,7 +76,9 @@ function co(callable $callable, bool|array $copyContext = false): int /** * @param array|bool $copyContext When set, parent coroutine context is copied to the child. * false = fresh context (default), true or empty array = copy all keys, non-empty array = copy listed keys only. - * Object values are shared by reference unless they implement Hypervel\Context\ReplicableContext. + * Objects stored directly in context are shared by reference by default. Values implementing + * Hypervel\Context\ReplicableContext are copied via replicate(), while values implementing + * Hypervel\Context\NonCopyableContext are omitted. */ function go(callable $callable, bool|array $copyContext = false): int { diff --git a/src/docs/concurrency.md b/src/docs/concurrency.md index bf3ee340fb..6b521c9be0 100644 --- a/src/docs/concurrency.md +++ b/src/docs/concurrency.md @@ -56,7 +56,9 @@ $results['users']; $results['orders']; ``` -Each task receives a copy of the parent coroutine context map. Adding or replacing values in that map does not affect sibling tasks or the parent coroutine. When a copied value is an object, the object reference is shared unless the object implements `Hypervel\Context\ReplicableContext`. See the [coroutine context](/docs/{{version}}/coroutine-context) documentation for more information. +When using the `coroutine` driver, each task receives a copy of the parent coroutine context. Adding or replacing values in that context does not affect sibling tasks or the parent coroutine. Objects stored directly as context values are shared by default. Values that implement `Hypervel\Context\ReplicableContext` are copied independently, while values that implement `Hypervel\Context\NonCopyableContext` are omitted. Hypervel does not inspect objects nested within arrays or other objects. + +For example, a task receives the parent's default database connection name, but it borrows its own database or Redis connection when needed. See the [coroutine context](/docs/{{version}}/coroutine-context) documentation for more information. To use a specific driver, you may use the `driver` method: diff --git a/src/docs/context.md b/src/docs/context.md index 7ded4b445f..dd01e72ecb 100644 --- a/src/docs/context.md +++ b/src/docs/context.md @@ -465,7 +465,7 @@ CoroutineContext::set('tenant_id', 123); $tenantId = CoroutineContext::get('tenant_id'); ``` -Plain child coroutines do not automatically inherit the parent's context. For example, `Hypervel\Coroutine\Coroutine::fork` snapshots the parent's context when invoked and installs it in the child; values implementing `Hypervel\Context\ReplicableContext`, such as the context repository, are copied independently so changes made inside the child coroutine do not mutate the parent's context. +Plain child coroutines do not automatically inherit the parent's context. For example, `Hypervel\Coroutine\Coroutine::fork` copies the parent's context when invoked and installs it in the child. Objects stored directly as context values are shared by default. Values implementing `Hypervel\Context\ReplicableContext`, such as the context repository, are copied independently, while values implementing `Hypervel\Context\NonCopyableContext`, such as borrowed database and Redis connections, are omitted. Hypervel does not inspect objects nested within arrays or other objects. ## Events diff --git a/src/docs/coroutine-context.md b/src/docs/coroutine-context.md index 3188ca149e..165f86a67f 100644 --- a/src/docs/coroutine-context.md +++ b/src/docs/coroutine-context.md @@ -18,7 +18,7 @@ - [Copying From Non-Coroutine Context](#copying-from-non-coroutine-context) - [Copying To Non-Coroutine Context](#copying-to-non-coroutine-context) - [Reading Non-Coroutine Context](#reading-non-coroutine-context) - - [Replicable Context Values](#replicable-context-values) + - [Copied Context Values](#copied-context-values) - [Context Containers](#context-containers) - [Typed Context Helpers](#typed-context-helpers) - [Request Context](#request-context) @@ -298,9 +298,7 @@ $capturedContext = CoroutineContext::captureFrom( ); ``` -When a captured value implements `ReplicableContext`, Hypervel calls its `replicate` method. The `copyFrom` method captures every value before changing the current context, so a replication failure leaves the current context unchanged. - -The returned array is not serialized. If you install it in another coroutine, objects that do not implement `ReplicableContext` remain shared between the coroutines. +Captured values follow the rules described in [Copied Context Values](#copied-context-values). Hypervel prepares the entire captured array before returning it, so a replication failure does not produce a partial result. > [!NOTE] > Most application code should use `Coroutine::fork` or the `copyContext` argument provided by `go`, `co`, and `parallel`. Use `captureFrom` when you need to capture context values now and install them in another coroutine later. @@ -370,10 +368,10 @@ CoroutineContext::clearFromNonCoroutine(['test_state']); > [!WARNING] > `clearFromNonCoroutine` changes storage shared by every coroutine in the worker. Use it only for controlled test lifecycle cleanup. - -### Replicable Context Values + +### Copied Context Values -When context is copied between coroutines, its objects are shared by default. If each coroutine needs its own copy of an object, implement the `Hypervel\Context\ReplicableContext` interface: +When context is copied, objects stored directly as context values are shared by default. If each coroutine needs its own copy of an object, implement the `Hypervel\Context\ReplicableContext` interface: ```php use Hypervel\Context\ReplicableContext; @@ -393,7 +391,22 @@ class RequestState implements ReplicableContext } ``` -When `Coroutine::fork`, `CoroutineContext::captureFrom`, `CoroutineContext::copyFrom`, or `CoroutineContext::copyFromNonCoroutine` encounters one of these objects, Hypervel copies it using the `replicate` method. +If an object owns a resource that cannot be shared safely, you may implement the `Hypervel\Context\NonCopyableContext` interface. Hypervel will omit the value from the copied context: + +```php +use Hypervel\Context\NonCopyableContext; + +class RequestResource implements NonCopyableContext +{ + // ... +} +``` + +These rules apply to `Coroutine::fork`, `CoroutineContext::captureFrom`, `CoroutineContext::copyFrom`, `CoroutineContext::copyFromNonCoroutine`, and `CoroutineContext::copyToNonCoroutine`. If an object implements both interfaces, Hypervel omits it without calling its `replicate` method. Hypervel prepares every value before changing the destination, so a replication failure leaves the destination unchanged. + +Only objects stored directly as context values receive this treatment. Hypervel does not inspect objects nested within arrays or other objects. + +Hypervel's borrowed database and Redis connections are non-copyable. A child coroutine still receives copyable values such as the default database connection name, but it borrows its own connection when it first uses the database. ## Context Containers @@ -466,7 +479,7 @@ Values set outside a coroutine are stored in the shared non-coroutine context. T Values stored in one coroutine are not visible inside another unless you copy them. Use `Coroutine::fork`, `go(..., copyContext: true)`, `parallel(..., copyContext: true)`, or `CoroutineContext::copyFrom(...)` when a child needs values from its parent. -Objects remain shared when context is copied unless they implement `ReplicableContext`. Avoid copying mutable request-specific objects when shared changes would be unsafe. +Objects stored directly in context remain shared when context is copied unless they implement `ReplicableContext` or `NonCopyableContext`. Avoid copying mutable request-specific objects when shared changes would be unsafe. ## Credits diff --git a/src/docs/coroutines.md b/src/docs/coroutines.md index 3bb51d0682..07a692df2d 100644 --- a/src/docs/coroutines.md +++ b/src/docs/coroutines.md @@ -231,7 +231,7 @@ go(function () { }); ``` -When the copied value is an object, the object reference is shared unless the object implements `Hypervel\Context\ReplicableContext`. See the [coroutine context](/docs/{{version}}/coroutine-context) documentation for more information. +Objects stored directly as context values are shared by default. Values that implement `Hypervel\Context\ReplicableContext` are copied independently, while values that implement `Hypervel\Context\NonCopyableContext` are omitted. Hypervel does not inspect objects nested within arrays or other objects. See the [coroutine context](/docs/{{version}}/coroutine-context) documentation for more information. ### Nested Coroutines @@ -451,7 +451,7 @@ $result = wait(function () { }, copyContext: ['request_id']); ``` -Copied object values follow the same replication behavior as [`go` and `Coroutine::fork`](#copying-coroutine-context). +Copied values follow the same rules as [`go` and `Coroutine::fork`](#copying-coroutine-context). If the closure throws an exception, `wait` rethrows it in the waiting coroutine after the child's deferred callbacks have finished. From 9bd82e0a4f050a5b965cc5f0217843f1c1233ae6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:23:11 +0000 Subject: [PATCH 09/18] docs(database): describe driver-neutral connections Explain when a database driver should extend Connection or PdoConnection, how drivers provide resource lifecycle and pool health behavior, and how PDO session configuration remains available to SQL drivers. Document migration connection routing, multi-target migrate:fresh behavior, Laravel porting differences, and the concise package-level divergences future database work must preserve. --- src/database/README.md | 2 + src/docs/database.md | 77 +++++++++++++++++++++++++++----- src/docs/migrations.md | 18 ++++++-- src/docs/pools.md | 2 + src/docs/porting-from-laravel.md | 6 +++ 5 files changed, 91 insertions(+), 14 deletions(-) diff --git a/src/database/README.md b/src/database/README.md index 5b2067031b..4b389af7ee 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -8,6 +8,8 @@ Documentation: https://hypervel.org/docs/database ## Differences From Laravel - Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix. +- Laravel exposes PDO methods on its base connection class. Hypervel's base `Connection` is driver-neutral, while PDO-backed drivers extend `PdoConnection`. Code that requires direct PDO access should accept or narrow to `PdoConnection`. +- Laravel's `migrate:fresh` command wipes only its selected connection. Hypervel discovers the connection declared by each migration and wipes every resolved target before rebuilding the schema. - Laravel's deprecated database-inspection forwarding helpers are intentionally not ported. Extensions can call `ConnectionInterface::getDriverTitle()` and `threadCount()` directly. - Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. - Laravel's Capsule manager exposes a `setFetchMode()` method that writes configuration its connections do not read. Hypervel omits this ineffective connection-wide setter; use `Query\Builder::fetchUsing()` for each query that needs a custom row shape. diff --git a/src/docs/database.md b/src/docs/database.md index 50604ac579..3f4d01526a 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -6,6 +6,7 @@ - [Read and Write Connections](#read-and-write-connections) - [Connection Pooling](#connection-pooling) - [Configuring Database Session State](#configuring-database-session-state) + - [Extending Database Connections](#extending-database-connections) - [Static Analysis](#static-analysis) - [Running SQL Queries](#running-queries) - [Using Multiple Database Connections](#using-multiple-database-connections) @@ -188,7 +189,7 @@ Each connection may define its own `pool` configuration: ], ``` -The `min_connections` option controls how far trimming excess idle connections may reduce the total managed connection count. It is not an idle-count invariant or a guaranteed total minimum, and it does not prewarm or automatically replenish the pool. The caller that first needs each new connection pays its connection-establishment cost, and the pool may have zero idle connections under load. Lifecycle-expired or unhealthy connections and explicit discards can reduce the managed count below `min_connections`; failed connection creation can leave it below that value. None is automatically replenished. The `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection. The `wait_timeout` option controls how long a coroutine may wait for an available connection when the pool is exhausted. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel checks retained idle connections with a raw `SELECT 1` ping that does not fire query events, query logs, or query duration handlers. The `heartbeat_timeout` option controls how long a heartbeat ping may run before the connection is discarded. The `max_idle_time` option controls how long an idle connection may remain in the pool while the total managed count is above `min_connections`. The `max_lifetime` option controls the upper bound for how long a pooled connection generation may live before it is recycled while idle or before it is reused; Hypervel assigns each generation an effective lifetime between 90-100% of this value to avoid synchronized reconnects. Set this value to `-1` to disable lifetime recycling. +The `min_connections` option controls how far trimming excess idle connections may reduce the total managed connection count. It is not an idle-count invariant or a guaranteed total minimum, and it does not prewarm or automatically replenish the pool. The caller that first needs each new connection pays its connection-establishment cost, and the pool may have zero idle connections under load. Lifecycle-expired or unhealthy connections and explicit discards can reduce the managed count below `min_connections`; failed connection creation can leave it below that value. None is automatically replenished. The `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection. The `wait_timeout` option controls how long a coroutine may wait for an available connection when the pool is exhausted. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel asks the database driver to check each retained idle connection without firing query events, query logs, or query duration handlers. Hypervel's PDO drivers use a raw `SELECT 1` query, while native and HTTP drivers may use their own protocol. The `heartbeat_timeout` option controls how long a heartbeat check may run before the connection is discarded. The `max_idle_time` option controls how long an idle connection may remain in the pool while the total managed count is above `min_connections`. The `max_lifetime` option controls the upper bound for how long a pooled connection generation may live before it is recycled while idle or before it is reused; Hypervel assigns each generation an effective lifetime between 90-100% of this value to avoid synchronized reconnects. Set this value to `-1` to disable lifetime recycling. 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. @@ -205,7 +206,7 @@ When you need a direct connection for migrations or schema operations, configure ### Configuring Database Session State -Database connections may stay open and be reused across requests and jobs. If your application uses a database session setting that can change between requests or jobs, you may use a session configurator to keep it up to date. +PDO database connections may stay open and be reused across requests and jobs. If your application uses a PDO session setting that can change between requests or jobs, you may use a session configurator to keep it up to date. Native and HTTP database drivers should configure their own client sessions through their connection implementation. Session configurators implement the `SessionConfigurator` contract. The following example keeps PostgreSQL's `application_name` setting up to date: @@ -216,7 +217,7 @@ declare(strict_types=1); namespace App\Database; -use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; use Hypervel\Database\SessionConfigurator; use PDO; @@ -227,14 +228,14 @@ final class ApplicationNameConfigurator implements SessionConfigurator ) { } - public function state(Connection $connection): ?string + public function state(PdoConnection $connection): ?string { return $connection->getDriverName() === 'pgsql' ? $this->applicationName : null; } - public function apply(PDO $pdo, string $state, Connection $connection): void + public function apply(PDO $pdo, string $state, PdoConnection $connection): void { $statement = $pdo->prepare( "select set_config('application_name', ?, false)" @@ -249,9 +250,9 @@ You should register the configurator in the `boot` method of a service provider: ```php use App\Database\ApplicationNameConfigurator; -use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; -Connection::configureSessionUsing( +PdoConnection::configureSessionUsing( $this->app->make(ApplicationNameConfigurator::class) ); ``` @@ -266,7 +267,7 @@ The `apply` method receives the PDO that needs to be configured. Use this PDO di The `apply` method must apply all the settings represented by the state string to the database session. When using PostgreSQL, use session-level `SET` or `set_config(..., false)`. Do not use `SET LOCAL` or `set_config(..., true)`, since those settings last only for the current transaction. -Hypervel tracks the applied state separately for read and write connections. It applies the settings when a connection is first used, when the state changes, after a reconnect, and after a rollback that may have undone a setting. Returning a connection to the pool or committing a transaction does not cause unchanged settings to be applied again. If configuration fails, Hypervel will not run the application query, and a pooled connection will not be returned as healthy. +Hypervel tracks the applied state separately for the read and write PDOs. It applies the settings when a PDO is first used, when the state changes, after a reconnect, and after a rollback that may have undone a setting. Returning a connection to the pool or committing a transaction does not cause unchanged settings to be applied again. If configuration fails, Hypervel will not run the application query, and a pooled connection will not be returned as healthy. When the state has not changed, Hypervel runs no configuration SQL. The `state` method is still called, so it should remain quick. If you do not register a configurator, Hypervel stores no session state and runs no configuration SQL. @@ -278,6 +279,52 @@ The `getPdo` and `getReadPdo` methods configure the PDO before returning it. The > [!WARNING] > If a setting must persist across queries and your application connects through a database proxy or connection pooler, use a mode that keeps the same database session. For example, PgBouncer must use session pooling. Transaction and statement pooling may send consecutive queries to different database sessions, so the setting may be missing from the next query. Hypervel cannot detect the pooler's mode for you. Direct database connections are not affected. + +### Extending Database Connections + +Hypervel provides separate extension points for PDO drivers and drivers that use another transport. + +If your driver uses PDO, register a connection resolver during application boot. The resolver receives the lazy PDO connection, database name, table prefix, and normalized configuration. It should return a `PdoConnection` instance: + +```php +use App\Database\TenantAwareMySqlConnection; +use Closure; +use Hypervel\Database\Connection; +use Hypervel\Database\PdoConnection; +use PDO; + +Connection::resolverFor('mysql', function ( + PDO|Closure $pdo, + string $database, + string $prefix, + array $config, +): PdoConnection { + return new TenantAwareMySqlConnection( + $pdo, $database, $prefix, $config + ); +}); +``` + +If your driver uses an HTTP client, native extension, or another non-PDO transport, extend the driver-neutral `Connection` class and register it during application boot using the `DB::extend` method: + +```php +use App\Database\ClickHouseConnection; +use Hypervel\Database\Connection; +use Hypervel\Support\Facades\DB; + +DB::extend('clickhouse', function (array $config, ?string $name): Connection { + return new ClickHouseConnection( + database: $config['database'] ?? '', + tablePrefix: $config['prefix'], + config: $config, + ); +}); +``` + +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. + +Custom connections implement their own query execution, transactions, escaping, health check, reconnection, and cleanup behavior. Hypervel's database pool calls those connection methods without assuming PDO, so a native or HTTP driver does not need to create a fake PDO instance. + ### Static Analysis @@ -454,13 +501,21 @@ $users = DB::connection('sqlite')->select(/* ... */); Hypervel applications should define database connections in the configuration file before the worker boots. Runtime connection configuration is not supported because database pools are worker-level resources and configuration mutation would affect concurrent coroutines in the same worker. -You may access the underlying PDO instance of a connection using the `getPdo` method. Hypervel applies any registered session configuration before returning the PDO. +Hypervel's built-in MariaDB, MySQL, PostgreSQL, and SQLite connections extend `PdoConnection`. You may access their underlying PDO instance using the `getPdo` method. Hypervel applies any registered session configuration before returning the PDO: ```php -$pdo = DB::connection()->getPdo(); +use Hypervel\Database\PdoConnection; + +$connection = DB::connection(); + +if ($connection instanceof PdoConnection) { + $pdo = $connection->getPdo(); +} ``` -If you are building a low-level framework extension, you may use `getRawPdo` to access the connection parameter without resolving or configuring it. This value may be a PDO, a lazy connection closure, or `null`. Applications should use `getPdo` or Hypervel's query APIs instead. +The driver-neutral `Connection` class does not expose PDO methods because native and HTTP drivers do not have a PDO instance. Code that requires direct PDO access should accept or narrow to `PdoConnection` instead of a generic connection. + +If you are building a low-level PDO extension, you may use `getRawPdo` to access the connection parameter without resolving or configuring it. This value may be a PDO, a lazy connection closure, or `null`. Applications should use `getPdo` or Hypervel's query APIs instead. ### Listening for Query Events diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 520b7bc6de..026ed8f621 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -153,6 +153,8 @@ If one of your application's database connections should use a different connect ], ``` +The migration connection must be a terminal target. The target connection may omit `migrations_connection` or point to itself, but connection chains such as `primary` to `schema` to `admin` are not supported. + #### Skipping Migrations @@ -179,6 +181,8 @@ To run all of your outstanding migrations, execute the `migrate` Artisan command php artisan migrate ``` +Before running any migration, Hypervel inspects the default connection and every connection declared by the migration files. If a supported MySQL, MariaDB, PostgreSQL, or SQLite database does not exist, Hypervel offers to create it before any migration is executed. The `--force` option allows creation without another prompt, while non-interactive commands must provide `--force`. The `--pretend` option never creates a database and will fail if a required database is missing. + If you would like to see which migrations have already run and which are still pending, you may use the `migrate:status` Artisan command: ```shell @@ -286,7 +290,7 @@ php artisan migrate:refresh --step=5 #### Drop All Tables and Migrate -The `migrate:fresh` command will drop all tables from the database and then execute the `migrate` command: +The `migrate:fresh` command will drop all tables from the databases managed by your migration files and then execute the `migrate` command: ```shell php artisan migrate:fresh @@ -294,14 +298,22 @@ php artisan migrate:fresh php artisan migrate:fresh --seed ``` -By default, the `migrate:fresh` command only drops tables from the default database connection. However, you may use the `--database` option to specify the database connection that should be migrated. The database connection name should correspond to a connection defined in your application's `database` [configuration file](/docs/{{version}}/configuration): +Hypervel reads each migration's `$connection` property or `getConnection` method before making any changes. It resolves `migrations_connection` for those connections, creates supported databases that do not exist, and wipes every declared database that already exists. A reachable database is wiped even when it does not contain a migrations table. + +Before making any changes, the command lists the connections that will be created and the connections that will be wiped. In production, declining the confirmation leaves every database unchanged. + +The `--database` option selects the default migration connection. Migrations that declare another connection are still included. The connection name should correspond to a connection defined in your application's `database` [configuration file](/docs/{{version}}/configuration): ```shell php artisan migrate:fresh --database=admin ``` +A migration's connection should remain stable for that migration. A migration is included in the reset even when its `shouldRun` method currently returns `false`, since it may have created tables during an earlier run. + +Hypervel cannot discover a connection that is only selected by calling `Schema::connection('other')` inside the `up` method. Split cross-database work into separate migrations and declare the connection on each migration so `migrate:fresh` can reset every database before rebuilding it. + > [!WARNING] -> The `migrate:fresh` command will drop all database tables regardless of their prefix. This command should be used with caution when developing on a database that is shared with other applications. +> The `migrate:fresh` command will drop all database tables from every declared migration connection, regardless of their prefix. This command should be used with caution when developing on databases that are shared with other applications. ## Tables diff --git a/src/docs/pools.md b/src/docs/pools.md index d7eae951b7..30c7893353 100644 --- a/src/docs/pools.md +++ b/src/docs/pools.md @@ -221,6 +221,8 @@ If two custom whole-driver disks may safely share a pool despite having differen The `Hypervel\Pool` component provides the lower-level foundation used by Hypervel's database and Redis connection pools. It is also available to package authors who need to manage another connection type. +Hypervel's database pool owns borrowing, deadlines, heartbeat cancellation, and idle connection recycling. Each database connection owns its protocol-specific health check, reconnection, cleanup, and reuse rules. Therefore, PDO, native, and HTTP database drivers can use the same pool without exposing their underlying client to the pool component. + ### Defining a Connection Pool diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 9359fcc552..208392a656 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -512,6 +512,12 @@ Hypervel supports MySQL, MariaDB, PostgreSQL, and SQLite database connections. S Database connections are persistent, pooled worker resources. Define every connection in `config/database.php` before the application boots. Dynamic connection creation through `DB::build()` and `DB::connectUsing()` is not supported. Review pool sizing and any database session state against the [database documentation](/docs/{{version}}/database#connection-pooling). +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. + +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. + +Hypervel's `migrate:fresh` command discovers the connection declared by each migration and resets every resolved target before rebuilding the schema. Keep each migration's connection stable, and split manual cross-connection schema work into separate migrations with explicit connection declarations. See [drop all tables and migrate](/docs/{{version}}/migrations#drop-all-tables-migrate) for details. + ### Redis From 003fdcad924c70fe7c434758a054aa6e08ca4600 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:23:21 +0000 Subject: [PATCH 10/18] docs(plan): record driver-neutral database design Capture the final architecture, implementation boundaries, Laravel compatibility decisions, migration routing behavior, performance constraints, and verification strategy for the driver-neutral database work. Record the source inventory and required regression coverage so future maintenance can understand why PDO lives in PdoConnection and how non-PDO drivers participate without compatibility shims. --- ...00-driver-neutral-database-architecture.md | 878 ++++++++++++++++++ 1 file changed, 878 insertions(+) create mode 100644 docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md diff --git a/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md b/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md new file mode 100644 index 0000000000..2ccef6eceb --- /dev/null +++ b/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md @@ -0,0 +1,878 @@ +# Driver-Neutral Database Architecture, Context Ownership, and Migration Reset Correctness + +## Status + +Signed-off implementation plan reviewed against the current Hypervel `0.4` worktree and the local Laravel framework, Telescope, and `laravel-clickhouse` references. Re-audit every inventory and caller against implementation-time HEAD. + +The work is intentionally independent of ClickHouse. ClickHouse exposed several weak boundaries, but every accepted change is justified by the framework's own correctness, extensibility, diagnostics, or public API. If the ClickHouse package disappeared, Hypervel would still want the final architecture described here. + +## Objective + +Make Hypervel's database layer a first-class home for both PDO-backed SQL drivers and non-PDO drivers while preserving the complete PDO experience for MySQL, MariaDB, PostgreSQL, SQLite, and future PDO drivers. + +The finished codebase must also: + +- prevent live pooled Database and Redis resources in their framework-owned context slots from crossing coroutine-context copy boundaries; +- make every context-copy direction apply the same atomic replication and omission rules; +- make Telescope quote bindings through the connection instead of reaching into PDO; +- make database-pool health, refresh, disconnect, and reuse decisions driver-owned without changing the generic Pool package; +- make `migrate:fresh` reset every statically declared migration connection it is about to rebuild; +- preserve Laravel-shaped query, grammar, schema, event, and Eloquent APIs where those APIs are transport-neutral; +- keep PDO-only APIs strongly typed on a PDO-specific class instead of pretending every connection has PDO; +- remove every superseded helper, stale PDO-specific description, temporary adapter, and obsolete test assumption in the same implementation. + +This is pre-release architecture work. Compatibility with earlier Hypervel 0.4 development snapshots and the amount of source churn do not constrain the design. Correct supported Laravel call shapes and named arguments remain important because they are the basis for future ports. + +## Source-proven findings + +### 1. Copied coroutine context can violate pool-slot exclusivity + +`CoroutineContext::captureFrom()` copies every selected context value by reference unless it implements `ReplicableContext`. `copyFromNonCoroutine()` has a separate replication loop, while `copyToNonCoroutine()` currently does not honor `ReplicableContext` at all. + +The live pool resources stored in context are objects: + +- Database stores a borrowed `Connection` under `__database.connection.{name}`. +- Redis stores a borrowed `RedisConnection` under `__redis.connection.{pool}`. + +With `parallel(..., copyContext: true)`, sibling coroutines therefore receive the same live object and can use one physical slot concurrently. A detached child can also retain the object after the parent's defer has released its wrapper to the pool. The child did not borrow the slot, so it did not register an owning defer. + +This is a driver-independent correctness defect. PDO connections must not be shared concurrently, and a returned pool slot must not remain usable through a copied reference. Native coroutine clients make the defect more visible because concurrent socket use may terminate the worker, but they are not the reason for the fix. + +### 2. Telescope's query watcher is not connection-neutral + +`Telescope\Watchers\QueryWatcher::quoteStringBinding()` calls `getPdo()->quote()`, catches only `PDOException`, and falls back to MySQL-style escaping. A non-PDO connection can throw a different exception, and PostgreSQL must not be formatted with MySQL escape conventions. + +`Database\Connection::escape()` is already the public connection-owned quoting API. The watcher should not select a transport or dialect itself. + +### 3. The generic Pool package is already driver-neutral + +`Hypervel\Contracts\Pool\ConnectionInterface` owns only generic pool lifecycle operations. The PDO coupling is confined to `Database\Pool\PooledConnection` and `Database\Pool\DbPool`: + +- heartbeat enumerates raw PDO handles and executes `SELECT 1` itself; +- refresh creates a fresh `Connection`, extracts its PDO handles, and transplants them; +- release reaches into PDO session-state knowledge; +- `DbPool::configureConnectTimeout()` switches on MySQL, MariaDB, and PostgreSQL. + +The existing timeout and cancellation harness is sound: heartbeat runs its probe in a child coroutine, waits through a `Channel`, and cancels the child when the deadline expires. That harness stays in `PooledConnection`; only the actual probe becomes driver-owned. The child boundary must continue converting an ordinary probe throwable to `false` while allowing `CanceledException` to terminate quietly. + +### 4. The base `Connection` is only partially abstracted from PDO + +PDO appears in the constructor, resource properties, fetch mode, select and statement execution, value binding, string escaping, reconnect/disconnect, server version lookup, session synchronization, transactions, statement events, the base insert-ID processor, test database restoration, and pool refresh. + +Important indirect dependencies include: + +- `ConnectionInterface::getPdo(): PDO`; +- `Concerns\ManagesTransactions::performRollBack(..., PDO $pdo)`; +- `Query\Processors\Processor::processInsertGetId()`; +- `Foundation\Testing\RefreshDatabase`; +- `Events\StatementPrepared`; +- `SessionConfigurator` and `PhysicalSessionState`; +- `DatabaseManager::refreshPdoConnections()`; +- `ConnectionFactory::createSingleConnection()`, whose Laravel-compatible `Connection::resolverFor()` callback receives a PDO closure and therefore cannot be the non-PDO extension seam. + +The current insert-ID paths also have real type defects. Generic `Processor::processInsertGetId()` lets `PDO::lastInsertId()` return `false`, which then violates the processor's declared `int|string` return type. `MySqlConnection::insert()` assigns that same `false` to a `string|int|null` property under `strict_types=1`; the assignment throws a `TypeError` inside the `run()` callback before its specialized processor can run. `runQueryCallback()` catches `Exception`, not `Throwable`, so this `TypeError` currently escapes directly rather than being wrapped as a `QueryException`. + +### 5. `migrate:fresh` can leave secondary schemas intact + +`FreshCommand` wipes only its selected/default connection. Migration objects can independently declare another connection through `Migration::getConnection()`, and `Migrator` correctly routes each migration to that connection, including `migrations_connection` aliases. The subsequent migrate can therefore recreate multiple schemas after only one was wiped. + +The current repository-presence gate is also too broad. A reachable database without a migrations table may contain application tables that still need to be wiped. Conversely, a genuinely missing physical database must retain the current behavior where `migrate` creates it. Today a migration declaring a missing secondary connection can fail after earlier migrations have already run, so target preparation belongs to the shared migration-command path rather than Fresh alone. Repository absence and database absence are different states and must not be conflated. + +## Desired final architecture + +| Concern | Final owner | +|---|---| +| Context values that must never cross copy boundaries | Methodless `Hypervel\Context\NonCopyableContext` marker | +| Context snapshot transformation | One private `CoroutineContext` helper used by every copy direction | +| Query binding quoting | `Connection::escape()` and the concrete driver's `escapeString()` | +| Query/grammar/schema/logging/event orchestration | Driver-neutral abstract `Database\Connection` | +| PDO resources, prepared statements, fetch modes, session state, and PDO transaction hooks | `Database\PdoConnection` | +| MySQL/MariaDB/PostgreSQL/SQLite dialects | Existing dialect classes extending `PdoConnection` | +| Non-PDO driver construction | Config-first `ConnectionFactory::extend()` / `DatabaseManager::extend()` resolver | +| Laravel-compatible PDO connection-class resolver | Existing `Connection::resolverFor()`, explicitly limited to the PDO construction path | +| Pool deadlines and cancellation | Existing `Database\Pool\PooledConnection` harness | +| Health probe, resource replacement, disconnect, and reuse truth | Concrete `Connection` implementation | +| Pool connect deadline exposure | Generic normalized config; each connector/extension maps it to its native client | +| Declared migration reset targets | `Migrator::getMigrationConnections()` | +| Migration path semantics | Existing `BaseCommand::getMigrationPaths()` shared by `migrate` and `migrate:fresh` | +| Missing database classification and creation | Shared migration-command helpers used by both `migrate` and `migrate:fresh` for every declared target | + +No execution-strategy object is introduced. The current hierarchy already uses concrete connection subclasses as the extension point, and no demonstrated consumer needs the same SQL dialect over multiple transport families. Protected driver hooks provide the required lifecycle boundary without creating a second extension axis. + +## Implementation order + +Treat context safety, Telescope escaping, the Connection/PDO plus pool split, and migration/Fresh behavior as four coherent work units. They may ship in one branch, but each unit must have its own focused green tests and leave no temporary compatibility seam. The migration unit is deliberately last and does not gate the driver-neutral class hierarchy. + +### 1. Make context copies resource-safe and atomic + +#### Production changes + +Add `src/context/src/NonCopyableContext.php` as a methodless marker. The name describes the operation being prohibited; ordinary object references are still non-replicated values, so `NonReplicableContext` would be ambiguous. + +```php +namespace Hypervel\Context; + +interface NonCopyableContext +{ +} +``` + +Refactor `CoroutineContext` so `captureFrom()`, `copyFromNonCoroutine()`, and `copyToNonCoroutine()` all build a complete source map and pass it through one private transformation helper before changing the destination: + +```php +private static function prepareForCopy(array $values): array +{ + foreach ($values as $key => $value) { + if ($value instanceof NonCopyableContext) { + unset($values[$key]); + + continue; + } + + if ($value instanceof ReplicableContext) { + $values[$key] = $value->replicate(); + } + } + + return $values; +} +``` + +The ordering is part of the contract: if an object implements both markers, omission wins and `replicate()` is never called. + +All three destination writes must be atomic with respect to replication failure. Transform the whole map first, then merge it. Do not mutate a destination one value at a time while replication is still in progress. Omitted keys do not erase a destination's existing value; they behave as if the source did not contain that key. + +Apply the transformation only to values stored directly in the context map. Do not recursively walk arrays or object graphs: framework-owned Database and Redis resources are direct context values, while nested application values keep normal PHP copy/reference behavior. Recursive traversal would need cycle/reference handling without solving another verified framework ownership path. + +`copyFrom()` continues to delegate to `captureFrom()` and needs no second filter. Preserve all current all-key/selected-key, merge, destroyed-coroutine, integer-key normalization, and non-coroutine storage behavior. Make selected-key copies use key-existence semantics, not `isset()`, so an explicitly stored `null` is treated consistently with an all-key copy. + +Implement `NonCopyableContext` directly on: + +- `Hypervel\Database\Connection`; +- `Hypervel\Redis\RedisConnection`. + +Do not put the marker on `Hypervel\Pool\Connection`. The generic pool package has no context dependency, and some future pool consumer may have a safely replicable logical wrapper. Database and Redis own the proven unsafe objects and already depend on Context. + +Continue copying scalar ownership metadata. In particular: + +- `ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY` remains copyable so a child borrows its own slot for the same configured database; +- Redis's deferred-release owner coroutine ID remains copyable, but the different child coroutine ID forces the child to register its own defer when it creates its own pin. + +#### Documentation changes + +Update every current public description discovered by the `copyContext` / `ReplicableContext` documentation sweep, especially: + +- `src/docs/coroutine-context.md`; +- `src/docs/coroutines.md`; +- `src/docs/concurrency.md`; +- `src/docs/context.md`; +- the docblocks in `src/coroutine/src/Parallel.php`, `Waiter.php`, and `functions.php`. + +State the three exact behaviors for objects stored directly as context values: ordinary objects remain shared references, `ReplicableContext` objects are independently replicated, and `NonCopyableContext` objects are omitted. Explain that the rule is not recursive and that a copied default connection name does not copy a borrowed connection. Keep `src/docs/cache.md`'s statement about object references nested inside copied array-cache values because that behavior does not change. + +#### Tests + +Extend `tests/Context/ContextCoroutineTest.php` and `ContextTest.php` with: + +- all-key and selected-key omission; +- each direction: coroutine to coroutine, non-coroutine to coroutine, coroutine to non-coroutine; +- atomic destination behavior when a later `ReplicableContext::replicate()` throws; +- an object implementing both markers, proving omission precedence; +- preservation of unrelated destination keys and of a destination value whose same-named source value was omitted; +- identical handling of explicitly stored `null` in all-key and selected-key copies; +- no invocation of `replicate()` on a doubly marked object. + +Add realistic ownership regressions: + +- Database parent/child and concurrent sibling tests with `copyContext: true`, asserting different `Connection` instances and independent releases; +- a detached Database child that continues after the parent exits, proving the parent's released slot is not retained by the child; +- Redis siblings using stateful/pinned operations, asserting distinct `RedisConnection` instances and one correct child-owned deferred release; +- scalar default database and Redis defer-owner behavior. + +Use channels to force the failure interleavings. A test that happens to execute sequentially is insufficient. + +### 2. Make Telescope use the connection's escaping contract + +Replace the entire PDO branch and MySQL-style fallback in `QueryWatcher::quoteStringBinding()` with delegation to the observed connection. Because Telescope is an observer invoked synchronously from `Connection::logQuery()`, an expected runtime failure to represent a binding must not fail the user's already-executed query. Catch only that failure for the individual string binding and substitute one stable redaction marker: + +```php +try { + return $event->connection->escape($binding); +} catch (RuntimeException) { + return '[REDACTED: UNESCAPABLE BINDING]'; +} +``` + +Remove the now-unused `PDO` and `PDOException` imports and add the `RuntimeException` import. Catch only the documented representability/driver runtime failure from `escape()` for the individual string binding; do not catch `Throwable`, because a driver `TypeError`, assertion, or other programming failure must remain visible. Do not catch around the whole event, invent another quoting algorithm, or expose the original bytes in the marker. Other failures in record construction remain visible; only a value that cannot be represented safely in SQL text is redacted. + +Correct two locally verified bugs inherited from `laravel/telescope` in the same method: + +- pass replacements through `preg_replace_callback()` so `$1`, backslashes, and other replacement-language bytes in an already escaped binding remain literal; +- `preg_quote()` named keys and require a parameter-name boundary so `:id` cannot replace the prefix of `:id2`. + +Preserve the existing positional replacement limit and repeated named-parameter behavior. These are upstream defects, not intentional Hypervel differences. Report the defects and an upstream-ready patch summary to the owner in the implementation handoff; submitting an external Telescope PR is a separate user-authorized action and is not an implementation acceptance criterion. + +Update `tests/Telescope/Watchers/QueryWatcherTest.php` so the nonstandard connection overrides `escape()` instead of manufacturing `PDOException` code `IM001`. Add coverage proving: + +- a non-PDO connection never receives `getPdo()`; +- the connection's exact dialect-specific quoted string is used; +- quotes, backslashes, Unicode, dollar/backreference-like bytes, and named bindings still substitute literally; +- a named `:id` binding does not corrupt `:id2`, while repeated exact `:id` placeholders are all replaced; +- null-byte and invalid-UTF-8 bindings record an entry with the exact redaction marker without throwing from the query listener or being reformatted as MySQL SQL. +- a `TypeError` or other non-`RuntimeException` from a broken driver remains visible instead of being redacted. + +This change lands before the class split so Telescope is already on the permanent neutral seam. + +### 3. Split neutral connection orchestration from PDO mechanics + +#### `Connection` and `PdoConnection` + +Implement the split copy-first. Copy the intact `Connection.php` to `PdoConnection.php` and reduce the child to PDO-owned members before removing those members from `Connection`. Copy the complete transaction and pool-owned blocks, including comments and docblocks, into `PdoConnection` before changing their sources. Compare each moved block with its source and make every intentional difference explicit. + +Make `Hypervel\Database\Connection` an abstract driver-neutral class. Its constructor becomes: + +```php +public function __construct( + string $database = '', + string $tablePrefix = '', + array $config = [], +) +``` + +It retains the behavior that is independent of a physical client: + +- grammar, schema builder, processor, query-builder creation; +- `selectOne`, `scalar`, `selectFromWriteConnection`, and insert/update/delete delegation; +- binding preparation for dates and booleans; +- `run()`, exception wrapping, lost-connection policy extension points, query logging, duration handlers, and events; +- pretend mode and before-execution callbacks; +- modified-record and read/write routing state; +- transaction-level orchestration, transaction manager, callbacks, and transaction events; +- database/name/config/table-prefix metadata; +- reconnector registration and invocation; +- macros, driver class resolvers, and static cleanup. + +Move all PDO state and mechanics to a new concrete `Hypervel\Database\PdoConnection`: + +- PDO/write/read resolver properties and the Laravel-shaped `PDO|Closure` constructor; +- fetch mode and prepared-statement configuration; +- `select`, `selectResultSets`, `cursor`, `statement`, `affectingStatement`, and `unprepared` implementations; +- `bindValues()` and `getPdoForSelect()`; +- `getPdo`, `getRawPdo`, `getReadPdo`, `getRawReadPdo`, resolver, and setter methods; +- read-PDO resolver/setter mechanics and synchronized physical handle selection; +- `SessionConfigurator`, `PhysicalSessionState`, unknown-state replacement, and the static PDO session map; +- PDO string quoting and server-version lookup; +- physical begin/savepoint/commit/rollback and physical transaction inspection; +- PDO resource disconnect, health probe, replacement, and reuse checks. + +`MySqlConnection`, `PostgresConnection`, and `SQLiteConnection` extend `PdoConnection`; `MariaDbConnection` remains a `MySqlConnection` subclass. Their grammars, processors, schema builders, unique-constraint parsing, binary/bool escaping, SQLite transaction mode, and MariaDB version handling remain where they are. + +Keep `QueryException extends PDOException`, `DeadlockException extends PDOException`, and `UniqueConstraintViolationException extends QueryException`. `QueryException` already accepts any `Throwable` and copies `errorInfo` only from an actual `PDOException`, so a non-PDO driver needs no fake PDO exception. This isolated Laravel-compatible exception taxonomy does not put PDO mechanics back into the neutral connection. Keep the DB facade's PDO getter annotations for built-in connections, while documenting that code holding an explicitly neutral `Connection` must narrow to `PdoConnection` before direct PDO access. + +Keep logical read/write routing and diagnostics neutral. Rename `$latestPdoTypeRetrieved` to `$latestReadWriteTypeRetrieved` and `$readPdoConfig` to `$readConnectionConfig` on `Connection`, and remove stale PDO terminology from error details and comments. Keep both properties protected: `PdoConnection` updates them when resolving write/read handles, its Laravel-compatible `setReadPdoConfig()` writes the neutral config property, and a native or HTTP subclass can publish the same state directly. Do not add one-line publisher helpers with no separate invariant. + +Make PDO string escaping use the effective physical session that executed the most recent query. `PdoConnection::escapeString()` selects `getPdo()` when `latestReadWriteTypeUsed()` is `write` and `getReadPdo()` otherwise. The explicit branch is clearer than reusing the select-named helper and needs no role parameter or Telescope-specific hook. This is a correctness invariant, not only an optimization: quoting can depend on physical session configuration, while synchronizing the other endpoint during synchronous query diagnostics can apply configurators, reconnect unknown state, or turn that other endpoint's runtime failure into Telescope redaction. It also prevents a split base or explicit `::write` wrapper from opening and configuring its lazy read endpoint solely to format a binding. A fresh connection with no prior role keeps Laravel's read default. Pretend mode also keeps that default because it deliberately executes no query and therefore has no truthful physical role to record; do not add pretend-only role machinery. + +Make explicitly requested roles unambiguous on `DatabaseManager`'s direct/non-pooled cache path. Before factory construction, stamp `READ_WRITE_TYPE_CONFIG_KEY` with the parsed `::read` or `::write` role; keep the existing `configForRead()` endpoint selection when a separate read configuration exists, and otherwise preserve today's base/fallback and write-forced resource behavior. The manager's default reconnector can then reconstruct the exact requested configuration from the invoking wrapper's base name plus neutral role metadata without adding requested-name state or scanning the cache by object identity. Do not change `PoolFactory`'s intentional ownership rule: pooled `::write` continues sharing the base pool, and `::read` gets a separate pool only when a separate read configuration exists. + +`PdoConnection` remains directly constructible as the generic PDO-backed connection used by framework tests and custom PDO drivers. Replace every direct `new Connection($pdo, ...)` in the repository with `new PdoConnection($pdo, ...)`, except tests that deliberately use a minimal non-PDO test connection. + +Split the current monolithic connection tests by the same ownership rule as production code. Keep transport-neutral orchestration in `tests/Database/DatabaseConnectionTest.php`; its transaction orchestration cases may continue using a working `PdoConnection` fixture when PDO is only the mechanism and the assertions concern logical depth, retry, manager publication, event order, or exception precedence. Use a small concrete neutral test connection for neutral driver contracts and hook routing. Move PDO resource, statement, fetch, binding, session, and physical transaction cases to `DatabasePdoConnectionTest.php`. Repoint PDO session fixtures such as `TestSessionConnection` to `PdoConnection`. Preserve each moved Laravel-derived test's relative upstream order and do not duplicate coverage between the files. + +#### Explicit neutral driver operations + +The base class must make transport requirements visible instead of inheriting hidden PDO behavior. Require concrete implementations for: + +- `select`, `cursor`, `statement`, `affectingStatement`, and `unprepared`; +- `escapeString`; +- `getServerVersion`; +- `ping(): bool`; +- `inTransaction(): bool`, reporting physical transaction truth for testing and lifecycle checks; +- driver-resource presence, disconnect, and replacement hooks. + +Keep precise unsupported defaults on the neutral base for PDO-shaped optional capabilities: `selectResultSets()` throws because a generic driver cannot promise multiple wire result sets, `getLastInsertId()` throws because many stores have no generated-ID concept, and `getSchemaState()` retains its existing unsupported-driver exception. `PdoConnection` overrides the first two; the built-in dialect connections override `getSchemaState()` where a schema dumper exists. This avoids making every HTTP/native driver repeat identical throwing methods while keeping failures explicit. + +Place the neutral `getLastInsertId()` immediately after `insert()`, matching `ConnectionInterface` while keeping Laravel's escape-method block contiguous. Keep the PDO and MySQL overrides with their respective PDO-resource and captured-ID invariants rather than forcing every subclass into interface order. + +Add `escape(mixed $value, bool $binary = false): string`, `getLastInsertId(?string $sequence = null): int|string`, and `inTransaction(): bool` to `ConnectionInterface`. Each passes the contract rule that every implementation must supply the behavior: query/event/testing consumers need connection-owned escaping, `Query\Builder` exposes only `ConnectionInterface`, the generic `Processor::processInsertGetId()` requires generated-ID access, and framework lifecycle/testing code requires physical transaction truth. The neutral base throws for unsupported generated IDs; `inTransaction()` remains abstract, and a deliberately non-transactional implementation returns `false` while its transaction entry points throw. This also removes `MySqlProcessor`'s current `method.notFound` suppression and lets `InteractsWithDatabase::castAsJson()` use the contract without narrowing or suppression. + +The neutral base's precise unsupported exception is itself the required `getLastInsertId()` behavior every implementation supplies; support is not optional or silently absent merely because the default is a failure. + +Keep Laravel's public `$useReadPdo` parameter names on select/cursor APIs. They are supported named-argument shapes even though a non-PDO driver interprets the boolean as its read/write routing choice. + +Use one narrow resource replacement entry point, not a strategy object: + +```php +/** + * Refresh the driver resources from a fresh connection. + * + * @internal + */ +final public function refreshFrom(Connection $fresh): void +{ + if ($fresh::class !== static::class || $fresh->getName() !== $this->getName()) { + throw new LogicException(sprintf( + 'Cannot refresh connection [%s] of type [%s] from connection [%s] of type [%s].', + $this->getName() ?? '', + static::class, + $fresh->getName() ?? '', + $fresh::class, + )); + } + + $this->replaceDriverResources($fresh); +} + +abstract protected function replaceDriverResources(Connection $fresh): void; +``` + +The public method owns the common connection-class and configured-name identity invariant, which also makes the protected hook an earned implementation seam rather than a one-line extraction. Replacement has one eager semantic: each implementation validates and captures a complete replacement set that the discarded fresh wrapper cannot close before disturbing the current resources. The hook's docblock must define that set as both driver resources and resource-associated metadata, including the configured database and table-prefix baselines described below. Every operation that can reject the replacement must occur before old-resource teardown. Once teardown begins, the old generation is gone, so the prepared generation is adopted in a `finally`; the wrapper always ends with a complete generation at logical depth zero and the original teardown throwable propagates unchanged. This is safe because `disconnectDriverResources()` must forget the current resources through `forgetDriverResources()` in its own `finally`. `ConnectionEstablished` is not dispatched when teardown throws because both reconnect owners dispatch only after `refreshFrom()` returns. For PDO, preparation calls both `getPdo()` and `getReadPdo()` to validate the effective write/read paths, then transfers the fresh wrapper's post-resolution raw properties so a connection with no explicit read handle preserves its normal `null`-means-write fallback rather than acquiring a synthetic second role. Do not add a lazy/eager flag: `DB::reconnect()` succeeding should mean the replacement is usable, and this path is not a query hot path. + +Treat resource-associated metadata as part of that generation. The neutral constructor stores protected `configuredDatabase` and `configuredTablePrefix` baselines from its normalized constructor arguments as well as the mutable current values. PDO replacement explicitly captures and adopts the fresh wrapper's two baselines, current database, current table prefix, selected write config, read connection config, and configured single-role read/write type alongside its handles, then resets the last selected role. Capture every value before teardown and assign it only inside the adoption `finally`: validation failure retains the complete old generation, while teardown failure still installs the complete prepared generation. This keeps failover diagnostics and future grammar/config reads aligned with the actual endpoints. Retain wrapper-owned event/transaction managers, reconnector, query log, duration handlers, callbacks, macros, and logical routing/modification state; those belong to the long-lived wrapper, not the disposable factory result. Native drivers apply the same ownership rule to their endpoint metadata. + +`resetForPool()` restores the mutable database and table prefix from those configured baselines and clears the last selected read/write role together with its existing per-borrow routing reset. This prevents public `setDatabaseName()` or `setTablePrefix()` calls, and the previous borrow's physical role, from leaking to the next coroutine. The configured single-role `readWriteType` remains unchanged. Keep the reset in the neutral base so a config-first native driver that derives its logical database or prefix from a DSN needs no special override. `MySqlConnection::resetForPool()` continues calling the parent before clearing its captured insert ID. Clone handling needs no special branch because scalar baselines copy with the wrapper. + +Tighten `Connection::getConfig()`'s docblock to `@return ($option is null ? array : mixed)` so no-argument consumers such as the migration admin creator receive the truthful array shape without local narrowing. + +PDO references need no artificial detach step because destroying the temporary wrapper does not close PDO objects retained by the long-lived wrapper. A native driver whose temporary wrapper or destructor closes owned resources must detach or otherwise transfer that ownership before adoption. A failed factory or validation call still leaves the complete old generation intact because teardown has not begun. + +The neutral base owns reconnect orchestration through the existing callback. `reconnectIfMissingConnection()` consults a protected `hasDriverResources(): bool` hook. `disconnect()` invokes a protected `disconnectDriverResources(): void` hook and logical transaction-manager cleanup while preserving the earliest throwable. Each driver implements graceful cleanup in that hook and must call its `forgetDriverResources(): void` hook in a `finally`, so cleanup failure cannot leave stale resources attached. The forget hook drops a known-dead resource generation without attempting physical I/O; it is also used after a lost transaction operation. These hooks are protected because callers should use `reconnectIfMissingConnection()`, `disconnect()`, and `refreshFrom()` rather than manipulating transport state. + +For PDO, `hasDriverResources()` is true when the write property contains either a PDO or its lazy closure; the optional read property may validly fall back to write and is not a second presence requirement. This preserves lazy first use. PDO disconnect preserves the current physical rollback behavior: a lost-connection throwable is suppressed, while the earliest other physical or logical-cleanup throwable wins. Its `finally` calls `forgetDriverResources()`, whose PDO implementation clears both properties through `setPdo(null)->setReadPdo(null)`, never through direct assignment. `setPdo()` resets the logical level before the neutral base publishes the level-zero rollback to the transaction manager, which is behaviorally equivalent to the current teardown order and also lets MySQL clear its captured insert ID through one setter override. + +Add a generic `isReusable(): bool` hook. The neutral default returns `true`; `PdoConnection` returns `false` for unknown physical session state. A non-PDO transport can report an ambiguous or poisoned client without teaching the pool about that transport. Mark `ping()`, `isReusable()`, and `refreshFrom()` as framework-internal lifecycle methods even though they must be public for the separate pool/manager collaborators. Remove the superseded public `hasUnknownSessionState()` helper and update pool, testing-resolver, and tests to consume `isReusable()`; PDO may keep only a private/protected predicate needed to implement the generic result. + +Do not add `supportsTransactions()`. The neutral transaction algorithm calls protected physical hooks. Default unsupported hooks throw a precise `LogicException`; PDO implements them. A deliberately non-transactional driver may override the public transaction entry points to provide one consistent domain message, as the ClickHouse bridge does. + +#### Insert-ID correction + +Add `getLastInsertId(?string $sequence = null): int|string` to the neutral connection contract used by the processor. Neutral `Connection::getLastInsertId()` throws a `LogicException` because unsupported generated IDs are a driver capability fact. `PdoConnection` calls PDO and throws a `RuntimeException` when PDO returns `false`; it never normalizes failure into a sentinel. `Processor::processInsertGetId()` calls the connection method instead of PDO and retains its existing numeric-string-to-int conversion. Keep its existing separate insert and ID handouts; do not add MySQL-style capture state to SQLite or another generic PDO path without a demonstrated need. + +Widen `MySqlConnection::getLastInsertId()` with the compatible optional sequence parameter and continue returning its captured post-insert ID. `PdoConnection` centralizes `PDO::lastInsertId() === false` handling in a protected `getLastInsertIdFrom(PDO $pdo, ?string $sequence = null): int|string` helper. Its public getter calls that helper with `getPdo()`. `MySqlConnection::insert()` retains its raw `$pdo->prepare($query)` path and passes the same already-synchronized PDO that executed the statement to the helper; do not route inserts through `prepared()` or dispatch `StatementPrepared`. Add a short comment at the capture call explaining that the ID must come from the session that executed the insert. This preserves physical-handle identity and avoids a second `SessionConfigurator::state()` pass when configurators are registered. The `false` path inside the query callback becomes a `QueryException` whose previous exception is the `RuntimeException`. + +`MySqlConnection::getLastInsertId()` throws a `RuntimeException` if no ID has been captured rather than falling back to a fresh/stale PDO value or returning its current `null`; its sequence argument is intentionally ignored because MySQL captured the ID during insert execution. Do not include the sequence in either runtime message. Do not add new `#[Override]` attributes to the rewritten MySQL methods; the dialect classes currently reserve that attribute for their established `getSchemaState()` pattern. + +Add one concise inline comment in the MySQL getter saying that the sequence is intentionally ignored because the ID was captured from the insert session. The existing insert comment owns the fuller same-session explanation. + +Clear the captured property whenever MySQL's write generation changes. Override `MySqlConnection::setPdo()` so every public write-resource mutation clears it, and override `resetForPool()` to call `parent::resetForPool()` before clearing it. The PDO disconnect and replacement paths must use `setPdo()` rather than assigning the property directly; do not add duplicate ad hoc clearing branches or a base-class last-ID hook for one dialect-owned property. Add regressions for `false`, direct getter before insert, pool release/reborrow, direct `setPdo()`, disconnect, and refresh. + +Keep `MySqlProcessor`'s `arguments.count` PHPStan suppression because only `MySqlConnection::insert()` accepts the sequence; adding that dialect-only parameter to `ConnectionInterface::insert()` would make every driver API worse. Remove only the `method.notFound` suppression once the neutral contract owns `getLastInsertId()`. Do not return `0`, an empty string, or `false` as a compatibility sentinel. + +#### Transactions and testing + +Refactor `Concerns\ManagesTransactions` so it contains no `PDO` type or call. Keep logical levels, attempts, manager publication, event ordering, exception precedence, and retry orchestration unchanged. Delegate these physical operations to protected driver hooks: + +- begin the outer transaction; +- create a savepoint; +- commit the physical transaction; +- roll back to zero or a savepoint. + +The nested concurrency branch also needs one protected `invalidateCurrentSessionState()` hook. The neutral default is a no-op because it owns no framework-managed session memo; `PdoConnection` performs the exact existing `invalidateSessionState($this->resolvePdo())` call. Do not substitute `markCurrentSessionStateUnknown()`: nested driver-owned rollback must clear remembered configurator state without issuing another rollback or poisoning the retained session. + +Have the four default unsupported physical transaction hooks call one private `throwUnsupportedTransactionException(): never` helper. The shared helper owns their identical exception and message; concrete drivers continue overriding only the hooks they support. + +Move the current PDO implementations, including their complete comments, docblocks, and session invalidation rules, to `PdoConnection` before changing the trait. PDO rollback absorbs the current PDO resolution, non-lost unknown marking, and final state invalidation before rethrowing to the neutral handler. This moves final invalidation before the failure handler, but the end state is unchanged: invalidation only clears applied state, while marking unknown also clears applied state, so the operations are order-independent. Preserve the deliberate asymmetry where commit does not mark lost or concurrency failures unknown, while rollback omits only lost failures. + +Replace the mixed `terminateTransactionState()` helper with two precise neutral pieces. `resetTransactionState()` owns the shared depth-zero assignment and transaction-manager rollback publication used by both normal `disconnect()` and lost cleanup. `forgetLostConnection()` uses `try { $this->resetTransactionState(); } finally { $this->forgetDriverResources(); }`, so a known-dead transport is dropped without calling it again even when manager cleanup fails. Both lost commit/rollback handlers keep their outer cleanup swallow so the original physical failure remains primary. Normal `disconnect()` still calls `disconnectDriverResources()` and therefore retains graceful physical rollback and unknown-state marking for a failed handle another wrapper may still reference. `PdoConnection::disconnectDriverResources()` calls `forgetDriverResources()` in `finally`; native drivers own the same graceful-disconnect versus hard-forget distinction. + +`PdoConnection::inTransaction()` inspects only an already-resolved raw write PDO and returns `false` for a lazy resolver or missing handle; lifecycle inspection must not open a connection. A non-transactional driver also returns `false` while its transaction entry points remain loud. Add a neutral counting-connection regression proving nested concurrency handling calls `invalidateCurrentSessionState()` exactly once and does not call the rollback hook. Add explicit PDO call counters proving lost commit cleanup performs no subsequent `inTransaction()` or rollback call, while lost rollback cleanup performs only its initial one of each and never re-enters the dead handle. + +Replace `Foundation\Testing\RefreshDatabase`'s PDO assumptions with honest connection APIs: + +- cache/restore in-memory resources only for an in-memory SQLite `PdoConnection`; +- replace direct `getPdo()->inTransaction()` with `inTransaction()`; +- keep explicit non-transactional entries in `connectionsToTransact()` loud at `beginTransaction()`; +- retain the existing coroutine set-up/tear-down ordering and the lockstep relationship between the migrated flag and cached in-memory resources. + +Do not add a flag that silently removes a connection from the test transaction list. + +Keep `Schema\SchemaState` itself typed to neutral `Connection`: its process/configuration behavior does not require PDO. Narrow only the operation that actually accesses PDO. In particular, `SqliteSchemaState::load()` must require or validate `PdoConnection` before the in-memory `exec()` path; MySQL/PostgreSQL process-based dumpers do not gain a false PDO dependency. + +#### Internal physical-session maintenance + +Schema builders already have internal statements that intentionally bypass `statement()` so physical-session maintenance does not run query callbacks, logging, or user-facing query events. Preserve that hardening without exposing PDO: + +```php +/** + * Mark the current physical session state as unknown. + * + * @internal + */ +public function markCurrentSessionStateUnknown(): void +{ + // Neutral drivers have no framework-managed session memo by default. +} + +/** + * Execute an internal physical-session statement. + * + * @internal + */ +public function executeSessionStatement(string $sql): void +{ + throw new LogicException(sprintf( + 'Database driver [%s] does not support physical session statements.', + $this->getDriverName(), + )); +} +``` + +`PdoConnection` overrides both methods: it executes through the current write PDO without query callbacks/logging and marks that physical session unknown on any failure. `Schema\Builder::executeSessionStatement()` and every SQLite physical-session call delegate to this connection method rather than calling `getPdo()` or duplicating invalidation. Keep these narrowly named methods out of `ConnectionInterface`; they are framework-internal schema/session mechanics, not behavior every consumer-facing connection contract must expose. + +#### PDO-only events and configuration + +`StatementPrepared` remains PDO-specific. Move its dispatch to `PdoConnection::prepared()` and type its connection property as `PdoConnection`. Do not invent a mixed generic statement event; there is no common statement object or demonstrated listener contract. + +Move the static session configurator registry and `configureSessionUsing()` to `PdoConnection`, because its public contract explicitly receives PDO. Type both `SessionConfigurator::state()` and `apply()` with `PdoConnection`, not neutral `Connection`. Update `src/docs/database.md`, tests, facade annotations, and static test cleanup accordingly. `Connection::flushState()` retains neutral resolver/macro cleanup. `PdoConnection::flushState()` calls it first, then clears both its session-configurator list and physical-session `WeakMap`; `AfterEachTestSubscriber` calls `PdoConnection::flushState()` as the single authoritative database cleanup. Add a subscriber regression that registers a configurator, creates tracked PDO session state, runs cleanup, and proves both PDO-owned static collections plus the neutral state are empty so this cannot silently become parent-only cleanup. + +#### Factory seams + +Keep `ConnectionFactory::make()` config-first: + +```php +if (isset($this->extensions[$name])) { + return ($this->extensions[$name])($config, $name); +} + +if (isset($this->extensions[$driver])) { + return ($this->extensions[$driver])($config, $name); +} + +return $this->createPdoConnectionFromConfig($config); +``` + +The existing name/driver `extend()` callbacks are the documented non-PDO seam and already run first in `ConnectionFactory::make()`; preserve that ordering and validate only that they return neutral `Connection` instances. Do not claim that `Connection::resolverFor()` can run before a PDO resolver exists: its Laravel-compatible callback signature receives the lazy PDO closure as its first argument. + +Make the canonical `DB::extend()` example construct the neutral base shape with `database: $config['database'] ?? ''`, `tablePrefix: $config['prefix']`, and `config: $config`. `parseConfig()` guarantees the prefix and embeds the configured name, but it does not guarantee a database key. + +Retain `Connection::resolverFor()` for the Laravel-compatible custom PDO connection-class use case. Rename/refactor the built-in path to `createPdoConnectionFromConfig()`, build the lazy closure there, consult the resolver there, and validate that it returns a `PdoConnection`. Building that closure establishes no connection and costs no network operation. Do not present `resolverFor()` as the non-PDO registration API. Update the factory tests and canonical database documentation to make the two seams unambiguous; `DatabaseServiceProvider` needs no artificial adapter or registration change because it already exposes the singleton factory through `DatabaseManager::extend()`. + +This keeps future Laravel PDO driver ports straightforward: port the connector, `PdoConnection` dialect subclass, query grammar, schema grammar/builder, and processor using the same structure as MySQL/PostgreSQL/SQLite. HTTP/native drivers extend neutral `Connection` and register through `extend()` without a fake PDO closure. + +#### Laravel's direct endpoint + +Preserve Hypervel's existing, documented replacement for Laravel's current nested `direct` endpoint and `::direct` suffix. A direct endpoint remains a normal named connection referenced by `migrations_connection`, with its own complete pool and connection configuration. This is cleaner in Hypervel's pooled runtime than adding a third PDO role inside one wrapper, and it works for PDO and non-PDO transports alike. Keep the rejection in `ConnectionName`, the source/test omission markers required by the porting policy, and the README difference. Do not add Laravel's `directPdo` properties or methods to `Connection` or `PdoConnection` during the split. + +### 4. Make Database pool lifecycle driver-neutral + +Implement this in the same architectural work unit as the class split so no temporary PDO-shaped hook lands and is immediately replaced. + +Refactor `Database\Pool\PooledConnection` to depend only on neutral `Connection` operations: + +- heartbeat's child coroutine calls `$connection->ping()`; +- `PooledConnection` retains the current channel deadline, cancellation, error containment, and last-use timestamp behavior; the child catches ordinary `Throwable` and reports `false`, while `CanceledException` exits without publishing a result; +- refresh asks the factory for a complete fresh connection and calls `$connection->refreshFrom($fresh)`; +- close calls driver-neutral `disconnect()`; +- release resets logical wrapper state, rolls back declared logical transactions, evaluates `isReusable()`, and returns/discards the wrapper as today; +- remove PDO imports, `getOpenPdos()`, `pingPdos()`, raw PDO access, and PDO transplantation. + +The existing shared in-memory SQLite branch still asks `DbPool` for its retained PDO and asks `ConnectionFactory::makeSqliteFromSharedPdo()` for a complete fresh `PdoConnection`. It then uses the same `refreshFrom()` path as every other driver; `PooledConnection` must not extract or set raw handles itself. Tighten the factory method's return type and validation to `PdoConnection`, while continuing to allow custom SQLite subclasses of `PdoConnection`. + +A matching config-first name or `extend('sqlite', ...)` callback cannot participate in pooled in-memory SQLite: it receives config rather than the retained shared PDO, so the factory cannot replay it without changing the extension API or letting it open a separate empty database. The incompatibility must be rejected before creating the pool's initially retained handle, not only during a later refresh. Add `makeSharedInMemorySqliteConnection(array $config, ?string $name = null): PdoConnection` for that initial construction; it parses/selects the write config, validates the extension constraint, and constructs through the ordinary PDO path. `DbPool::createSharedInMemorySqlitePdo()` obtains its retained handle from that validated connection. `makeSqliteFromSharedPdo()` applies the same validation before constructing around the retained handle. Both public methods call one private `ensureNoSharedInMemorySqliteExtension()` guard so the matching rule and precise exception pointing to `Connection::resolverFor('sqlite', ...)` cannot drift. Both then route through the same `createConnection()` / `Connection::resolverFor('sqlite', ...)` seam, so a Laravel-compatible custom PDO resolver returns the identical concrete subclass on initial creation and refresh; that is the load-bearing invariant required by `refreshFrom()`'s exact-class check. + +Keep the initial method specific to pooled in-memory SQLite rather than exposing a general extension-bypassing factory API. The factory already owns its extension map, so do not expose a new `hasExtension()` API, add a second callback signature/config sentinel, or create a shared-resource strategy for this one built-in case. + +`PdoConnection::ping()` preserves current behavior exactly: inspect only already-resolved distinct write/read PDOs, execute raw `SELECT 1`, close cursors, fire no query events/logs, and return `true` without opening a lazy PDO when no handle has been used. A non-PDO driver decides whether an unused client is healthy and how to probe an opened client. + +Replace `DbPool::configureConnectTimeout()`'s driver switch with generic normalization. Expose the validated pool deadline to the connection config, without rounding away fractional precision: + +```php +$this->config['connect_timeout'] ??= $this->option->getConnectTimeout(); +``` + +Then: + +- MySQL/MariaDB connectors map the top-level value to `PDO::ATTR_TIMEOUT` with the native integer/ceiling rule unless the user supplied that PDO option directly; +- PostgreSQL applies `(int) ceil()` to whatever top-level `connect_timeout` value is present—pool-derived or explicitly configured—before placing it in the DSN, because libpq rejects fractional seconds; +- SQLite ignores it; +- custom extensions receive the normalized value and map it to their native client. + +While editing `MySqlConnector`, correct the adjacent raw identifier interpolation in its `USE` statement: double embedded backticks in the configured database name before execution. Keep this direct and local; a one-use quoting service is not justified. Add a regression with a database name containing a backtick so legitimate configured identifiers cannot produce malformed SQL. + +Keep SQLite's Laravel-style application-relative path fallback without assuming that a loaded `base_path()` helper has a usable application root. After direct `realpath()` fails, call `base_path($database)` only when the helper exists and either `BASE_PATH` is defined or the global container has the Foundation application contract. A standalone Capsule has no application root, so missing absolute and relative paths must reach the connector's existing `SQLiteDatabaseDoesNotExistException` instead of the Foundation helper's `RuntimeException`. Keep this decision local to `SQLiteConnector`; changing the helper, catching its internal exception, or adding a path-resolution strategy would weaken another boundary or add needless machinery. + +Keep the current in-memory SQLite resource retention and single-owner capacity in `DbPool`. It is a proven built-in driver requirement, not a reason to create a generic shared-resource strategy. Rename only descriptions that incorrectly imply every pool resource is PDO. `src/pool` receives no source change. + +Replace `DatabaseManager::refreshPdoConnections()` with one driver-neutral helper used by the manager's default reconnector. Given the invoking wrapper, derive its requested name from its base `getName()` plus the normalized `READ_WRITE_TYPE_CONFIG_KEY`, build and configure a fresh wrapper for that exact requested configuration, then call `refreshFrom()` on the invoking wrapper. The manager must not look up a different cached wrapper or copy a resource from the return value of `reconnect()`. + +`DatabaseManager::reconnect($name)` removes its unconditional leading `disconnect($name)`. When the requested pooled/context wrapper exists, it delegates to that wrapper's reconnector, whose `PooledConnection::refresh()` owns replacement. When the requested non-pooled cache entry exists, it likewise calls that wrapper's `reconnect()`, whose manager-installed callback invokes the helper above. Thus query-triggered and explicit reconnect share one non-pooled refresh implementation; the public manager method does not independently build or replace resources. With `::read`/`::write` role normalization, the callback refreshes the invoking derived wrapper in place without creating or touching a base-name cache entry. `getName()` intentionally remains the base configured name on both old and fresh wrappers, so `refreshFrom()`'s exact-name invariant passes and must not be loosened. Do not pre-resolve resources in the manager; the concrete replacement hook owns eager validation. + +Dispatch `ConnectionEstablished` exactly once and only after complete successful replacement. The component that performs replacement owns the event: the manager's non-pooled refresh helper dispatches after `refreshFrom()`, while `PooledConnection::refresh()` dispatches for its callback path. `DatabaseManager::reconnect()` must not dispatch again after invoking either reconnector. Update pooled and non-pooled reconnect documentation and tests, including event count, eager failure timing, derived-role identity, and read/write validation. + +#### Current-HEAD consumer audit + +Update behavior, types, comments, and tests at every current caller rather than stopping after the class split: + +- the DatabaseManager reconnector uses neutral reconnect/refresh and never copies a raw PDO back into the wrapper; +- `Queue\DatabaseQueue::getLockForPopping()` resolves its connection once, uses `getDriverName()` and `getServerVersion()` instead of PDO attributes, preserves the `getConfig('version') ?? getServerVersion()` short circuit, and removes the unsupported SQL Server lock branch plus the unused PDO import. Today it resolves the connection twice when a configured version exists and three times otherwise, so the single local removes one or two complete resolver round trips from every queue pop. Keep reachable MariaDB, Vitess, and PlanetScale behavior unchanged; +- `Foundation\Testing\DatabaseTruncation` and `RefreshDatabase` narrow only retained in-memory SQLite resources to `PdoConnection` and otherwise use neutral lifecycle/transaction APIs; +- `Foundation\Testing\DatabaseConnectionResolver` resets and evaluates cached connections through neutral lifecycle methods while keeping any SQLite PDO retention explicitly narrowed; +- `Foundation\Testing\Concerns\InteractsWithDatabase::castAsJson()` uses the connection's `escape()` contract rather than `getPdo()->quote()`; +- schema builders use the internal session-statement seam above; +- `QueryException` and `Events\QueryExecuted` retain Laravel's public `readWriteType` name while changing PDO-specific descriptions to connection-role wording; +- `StatementPrepared`, `SessionConfigurator`, `PhysicalSessionState`, and PDO session tests narrow to `PdoConnection`; +- all PDO-requiring direct constructions/imports in source and tests use `PdoConnection`; minimal neutral test doubles extend `Connection` and implement only the required transport hooks. + +`DatabaseManager::availableDrivers()` may retain its Laravel-compatible `PDO::getAvailableDrivers()` check because it reports availability of Hypervel's built-in PDO drivers; custom `extend()` drivers have never been enumerated by that API. Do not contort this diagnostic API or imply that its result lists registered extension drivers. + +### 5. Make `migrate:fresh` reset all declared migration connections + +First make `resolveMigrationConnectionName()` idempotent without adding alias-chain machinery. Resolve one `migrations_connection` hop, then inspect the target's own value. A missing target key or explicit self-reference is terminal; any different second target is invalid configuration and throws a precise exception naming the attempted route. `Repository::string()` rejects non-strings but accepts `''`, so reject an empty effective source after resolving a `null` input to the configured/context default, and reject an empty first-hop target before inspecting it; do not trim or add broader normalization. Preserve the current no-config-container passthrough used by isolated unit tests, where there is no repository from which to resolve or validate an alias. + +Document the terminal rule and `InvalidArgumentException` on the resolver itself. Give its docblock the conditional return type `($name is null ? null|string : string)`. `getMigrationConnections()` must retain its first null/empty guard because the no-config passthrough can genuinely return `null` for a null argument; after that validated default is substituted for empty migration declarations, the second post-resolution guard is unreachable and must not remain. + +The supported shape is therefore `pooled -> direct`, not `a -> b -> c`. Valid targets remain unchanged when `setConnection()`, `resolveConnection()`, `migrate:install`, and `db:wipe` resolve them again, while chains and cycles fail before mutation. A traversal and visited set would support an unverified configuration need and is intentionally not added. + +Re-audit `Migrator::usingConnection()` after that change. Keep its direct `finally` restoration, but replace the now-stale alias-workaround explanation: the method captures the migrator's stored connection and the coroutine's effective default independently, and those values can differ. Calling `setConnection($previousStored)` would overwrite both with one value and fail to restore the prior coroutine context. Add a regression that starts with distinct stored/context values, enters an aliased target, and proves both original values and the repository source are restored after success and after an exception. Idempotent alias resolution removes double-resolution hazards; it does not collapse these two state owners. + +Add this purpose-specific API to `Migrator`: + +```php +/** + * Get the distinct connections declared by the migrations at the given paths. + * + * @return list + */ +public function getMigrationConnections( + array|string $paths, + ?string $defaultConnection = null, +): array; +``` + +Implementation rules: + +1. Use `getMigrationFiles()` so path ordering and duplicate migration-name behavior match `run()`. +2. Resolve each file through protected `resolvePath()`, not public `resolve()`, so anonymous returned migrations, named migrations, real paths, and the required-path cache all behave exactly as execution does. +3. Read `Migration::getConnection()` without calling `shouldRun()`. Use the same valid-migration contract already required by `runMigration()`; do not invent a fallback connection for an invalid resolved object. +4. Resolve a non-empty effective default first, then run the file-resolution loop inside `usingConnection($effectiveDefault, ...)` so constructors and `getConnection()` observe the same coroutine default as execution and all migrator/repository/context state is restored afterward. +5. Treat `null` and `''` declarations as that effective default and resolve every result through the same one-hop terminal `migrations_connection` validation as migration execution. +6. Include the effective default even when there are no migration files, because schema dumps and the traditional fresh behavior still target it. +7. Deduplicate final targets while preserving deterministic first-seen order. +8. Do not execute `up()`, `down()`, `shouldRun()`, or other application methods, and do not inspect arbitrary PHP source; construction plus the framework's `getConnection()` contract is the only application code discovery invokes. + +Ignoring `shouldRun()` is required: a migration may have created tables during an earlier run even when its current runtime predicate is false. Fresh is resetting the schema the migration set can own, not predicting only the current up calls. + +#### Share target inspection and creation + +Move the existing MySQL/MariaDB, PostgreSQL, and SQLite missing-database classification/creation routines from `MigrateCommand` to protected helpers on `BaseCommand`, using resolved target names for inspection and the resolved connection for server-database creation. Keep authorization in each owning command rather than hiding prompts inside the creator. Both commands use the same two-phase flow: + +1. inspect every target read-only by entering `Migrator::usingConnection($target, ...)` and calling `repositoryExists()`; +2. a normal `true` or `false` result means the physical database pre-existed; repository absence is not database absence; +3. when inspection throws, walk the throwable chain and classify only the existing supported signals: SQLite's missing-file exception, MySQL/MariaDB error 1049, and PostgreSQL SQLSTATE `08006` whose message names the configured database; +4. retain each classified cause in a simple `array` keyed by target so creation can occur later; do not add a DTO, registry, or policy object; +5. immediately propagate invalid connection names, non-PDO driver errors, authentication/network/permission failures, and every unclassified throwable; +6. after the owning command has authorized its complete set, create only the classified targets, then verify all creations before any later migration or wipe. + +SQLite creation uses the classified path. MySQL/MariaDB/PostgreSQL creation retains Hypervel's copied-config one-off admin connection rather than mutating process-global configuration, but its copy must come from the resolved connection's already parsed and write-merged `getConfig()` rather than re-reading the raw named config. The raw path is incorrect for split endpoints: a different `write.database` both trips the current mismatch guard and would overwrite a top-level admin database during the factory's second merge. Remove that guard and the redundant `ConfigurationUrlParser` pass. This deliberately creates the database that the resolved connection actually tried to open, including when public `setDatabaseName()` changed it. + +Set the copied admin database to `''` for MySQL/MariaDB and `'postgres'` for PostgreSQL; the current MySQL `null` value violates the strict factory's `string $database` path. In the PostgreSQL arm, also remove `connect_via_database` because `PostgresConnector` gives it priority over `database`; retaining it can make the admin wrapper reopen the missing application database. A different pooler alias remains unclassified because its error does not name the configured application database, so the framework must not invent a database creation for it. Preserve `connect_via_port`, inert single `write` and `read_write_type` values, and the factory's normal host selection. + +Quote the complete target returned by the resolved connection's `getDatabaseName()` through that connection's query grammar `wrapIdentifier()` method; do not use `wrap()`, which splits dots as qualified-name separators, and never interpolate a configured name between raw backticks or quotes. Build the small dialect statement around that quoted identifier, preserve MySQL/MariaDB's idempotent `IF NOT EXISTS` behavior, and always disconnect the temporary admin connection in `finally`. + +`migrate --pretend` never creates a database; a missing target remains loud because pretending cannot inspect a nonexistent migration repository. Remove the old retry callback and selected-connection-only creation path after the shared preflight owns the behavior—there must be one classifier and one creator. + +#### Standalone `migrate` + +After its existing production confirmation, `MigrateCommand` computes paths and declared targets and performs the shared read-only inspection. `BaseCommand` is the single owner of the migrator property used by its shared helpers, so its subclasses must not redeclare that property. If databases are missing, authorize the complete set before creation: `--force` proceeds without another prompt, `--no-interaction` without force fails, and an interactive run prints every missing target and asks one Laravel-style confirmation. Declining creates nothing. Then create and verify every missing target before preparing the central migration repository or executing any migration. A secondary target does not receive its own migration repository; migration records remain on the command-selected repository exactly as today. + +This prevents a first migration from mutating the default database before a later migration discovers that its declared secondary database is absent. It also makes the existing missing-default convenience consistent across every statically declared target. + +#### `migrate:fresh` + +Change `FreshCommand` to extend the existing migration `BaseCommand`. Remove its duplicate migrator property and use `getMigrationPaths()` so discovery exactly matches `migrate` for registered paths, application paths, relative `--path`, and `--realpath`. + +`FreshCommand::handle()` runs in this exact order: + +1. return immediately if the command is prohibited; +2. compute paths and distinct resolved targets; +3. perform the shared read-only inspection and classify verified-missing targets, propagating every other failure before any mutation; +4. print the complete target list and clearly identify which targets would be created and which pre-existing targets would be wiped; +5. call `confirmToProceed()`; declining creates and wipes nothing; +6. create and verify every classified missing target using the already-confirmed forced behavior; +7. call `db:wipe` once for every target that pre-existed, regardless of whether its migration repository exists, with the existing views/types/force options; Wipe must disconnect that resolved connection's physical driver resources without purging its wrapper, resolver ownership, or pool membership, because a lazy-refresh callback resumes on the wrapper that triggered it; +8. never wipe a database created by this command because it is already empty; +9. run the existing single nested `migrate --force` only after every creation and wipe succeeds; +10. after a successful migrate, preserve Laravel's event-before-seed order: dispatch `DatabaseRefreshed` with the command-selected connection and seeding flag, then run the requested seeder. + +"Pre-existing" is decided from the initial read-only inspection, not re-checked after creation. No wipe occurs unless all required creations have succeeded. The nested `migrate` deliberately repeats the now-idempotent preflight; do not add a skip flag. Treat non-zero creation, wipe, migrate, or seeder results as command failure. A seed failure occurs after `DatabaseRefreshed` by Laravel's established ordering; the event reports that the database was refreshed and its `seeding` flag announces the following seed phase, so do not reorder it as a generic command-success event. + +Use one task per target so output identifies failures. The confirmation must precede database creation as well as wiping; creating a database before an operator declines Fresh would be an unauthorized mutation. + +Document the declarative discovery boundary: a migration's `getConnection()` result defines its fresh target and should be stable for that migration. A migration that manually calls `Schema::connection('other')` inside `up()` cannot be discovered without executing arbitrary code. Such cross-connection work should be split into migrations with explicit connection declarations. Do not add a repeatable CLI target option, parser, reset policy registry, or source-code scanner. + +## File-level checklist + +### Context and coroutine + +- `src/context/src/NonCopyableContext.php` — add marker. +- `src/context/src/CoroutineContext.php` — one atomic copy transformation for all directions. +- `src/database/src/Connection.php` — implement marker while performing the neutral split. +- `src/redis/src/RedisConnection.php` — implement marker. +- `src/coroutine/src/Parallel.php`, `Waiter.php`, `functions.php` — correct copy semantics in API docs. +- `src/docs/coroutine-context.md`, `coroutines.md`, `concurrency.md`, and `context.md` — document top-level omission wherever direct context values are described. +- `tests/Context/*`, `tests/Coroutine/*`, `tests/Integration/Database/ConnectionCoroutineSafetyTest.php`, `tests/Redis/RedisProxyTest.php`, and `tests/Integration/Redis/RedisProxyIntegrationTest.php` — add copy and ownership regressions. + +### Telescope + +- `src/telescope/src/Watchers/QueryWatcher.php` — delegate escaping. +- `tests/Telescope/Watchers/QueryWatcherTest.php` — replace PDO fallback fixture and add neutral/dialect, literal replacement, named-boundary, and redaction cases. +- implementation handoff — report the two locally verified upstream Telescope defects and an upstream-ready patch summary without performing an unauthorized external submission. + +### Database architecture and pool + +- `src/database/src/Connection.php` — neutral abstract orchestration and resource/transaction hooks. +- `src/database/src/PdoConnection.php` — all PDO mechanics. +- `src/database/src/ConnectionInterface.php` — remove `getPdo`, add `escape()`, `getLastInsertId()`, and `inTransaction()`, and remove PDO wording. +- `src/database/src/Concerns/ManagesTransactions.php` — remove PDO types/calls. +- `src/database/src/MySqlConnection.php`, `PostgresConnection.php`, `SQLiteConnection.php`, `MariaDbConnection.php` — repoint inheritance and retain dialect behavior. +- `src/database/src/Connectors/ConnectionFactory.php` — separate config-first extensions from PDO construction and give initial plus replacement shared in-memory SQLite construction explicit validated `PdoConnection` paths with one private extension guard. +- `src/database/src/Connectors/MySqlConnector.php`, `MariaDbConnector.php`, `PostgresConnector.php` — native timeout mapping and the MySQL `USE` identifier correction. +- `src/database/src/Pool/PooledConnection.php`, `DbPool.php` — neutral lifecycle delegation and generic timeout normalization. +- `src/database/src/DatabaseManager.php` — one invoking-wrapper refresh path, direct-cache role metadata, and exactly-once reconnect events. +- `src/queue/src/DatabaseQueue.php` — use connection driver/version APIs instead of PDO attributes. +- `src/database/src/Query/Processors/Processor.php` — neutral insert-ID call and false normalization at PDO owner. +- `src/database/src/Schema/Builder.php`, `SQLiteBuilder.php` — delegate internal physical-session statements and invalidation through neutral connection methods. +- `src/database/src/Schema/SchemaState.php`, `MySqlSchemaState.php`, `PostgresSchemaState.php`, `SqliteSchemaState.php` — keep the base neutral and narrow only the in-memory SQLite PDO load operation; neutral `Connection::getSchemaState()` keeps its precise unsupported default while built-in dialect connections return concrete states. +- `src/database/src/Events/StatementPrepared.php` — PDO-specific connection type. +- `src/database/src/Events/QueryExecuted.php`, `QueryException.php`, and `Query/Builder.php` — preserve public read/write API names while removing incorrect PDO-only descriptions from neutral routing state. +- `src/database/src/SessionConfigurator.php`, `PhysicalSessionState.php` — retain PDO contract under `PdoConnection` ownership. +- `src/foundation/src/Testing/RefreshDatabase.php` — honest resource and physical-transaction APIs. +- `src/foundation/src/Testing/DatabaseTruncation.php`, `DatabaseConnectionResolver.php`, and `Concerns/InteractsWithDatabase.php` — narrow retained SQLite PDOs and otherwise use neutral lifecycle/escaping APIs. +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` — call `PdoConnection::flushState()` once so neutral state plus the PDO configurator list/session map are cleared. +- `src/support/src/Facades/DB.php` — correct annotations. +- `src/docs/database.md`, `src/docs/pools.md`, `src/docs/porting-from-laravel.md`, and the minimal `src/database/README.md` — PDO/non-PDO extension paths, pool probes, session configuration, and concise lasting public differences. +- `docs/upstream-sync/laravel-framework.md`, referenced by `docs/upstream-sync/sync.yaml` — durable hunk-routing guidance for future Laravel `Connection.php` syncs. +- Every source/test import and direct construction found by a final broad content sweep — use `PdoConnection` when the test or consumer needs PDO. +- `tests/Database/DatabaseConnectionTest.php` and `DatabasePdoConnectionTest.php` — separate neutral orchestration from PDO mechanics without duplicate cases. + +### Migrations + +- `src/database/src/Migrations/Migrator.php` — one-hop terminal alias validation, independent `usingConnection()` state restoration, and declared target discovery. +- `src/database/src/Console/Migrations/BaseCommand.php` — shared paths plus all-target read-only inspection, missing-database classification, and creation. +- `src/database/src/Console/Migrations/MigrateCommand.php` — preflight/create every declared target before repository preparation or migration execution. +- `src/database/src/Console/Migrations/FreshCommand.php` — multi-target wipe and explicit failure handling. +- `src/database/src/Console/WipeCommand.php` — disconnect physical resources without invalidating the logical wrapper or its pool ownership. +- `tests/Database/DatabaseWipeCommandTest.php` — resolved-target disconnect behavior without manager purge. +- `tests/Database/DatabaseMigrationFreshCommandTest.php` — command behavior. +- `tests/Database/DatabaseMigrationMigrateCommandTest.php` — all-target missing-database preflight and creation. +- `tests/Database/DatabaseMigratorConnectionRoutingTest.php` and new migration fixtures — named/anonymous discovery and alias resolution. +- `tests/Integration/Database/MigrationsConnectionRoutingTest.php` plus SQLite fresh coverage — end-to-end mixed connections and missing database behavior. +- `src/docs/migrations.md`, `src/docs/porting-from-laravel.md`, the minimal database README difference, and console help text — declared target semantics and the deliberate Fresh divergence. + +### Documentation and future Laravel syncs + +Keep one user-documentation source in `src/docs/`. Explain the PDO/non-PDO extension choice in `database.md`, pooling lifecycle in `pools.md`, context-copy omission wherever context copying is described, and multi-target migration behavior in `migrations.md`. Add only concise, action-oriented entries to `porting-from-laravel.md` for differences a Laravel application/package porter must account for: direct PDO access requires `PdoConnection`; Laravel's nested `direct` endpoint and `::direct` suffix map to a normal named Hypervel connection plus `migrations_connection`; and Fresh discovers/resets declared migration connections. The database README remains a minimal link/upstream/difference surface and must not duplicate those guides. + +Create `docs/upstream-sync/laravel-framework.md` because `docs/upstream-sync/README.md` explicitly owns persistent per-package divergence notes. Reference it from the existing `laravel/framework` `notes:` value in `sync.yaml` while preserving that entry's current instruction to scan direct-to-branch commits. Keep the note short and operational: + +| Laravel change location/concern | Hypervel destination | +|---|---| +| query/grammar/logging/reconnect orchestration independent of a client | `Connection` | +| PDO handles/resolvers, prepared statements, binding, execution, session state, physical transactions, quoting, server version | `PdoConnection` | +| Laravel `escapeString()` changes | preserve Hypervel's effective-last-session routing in `PdoConnection`; no prior execution role still defaults to read | +| MySQL/MariaDB/PostgreSQL/SQLite behavior | matching dialect subclass/grammar/builder/processor | +| Laravel's nested `direct` PDO handle, `::direct` role, and migration/schema consumers | keep Hypervel's documented normal named connection plus `migrations_connection`; do not add `directPdo` state to either class | +| Laravel `DatabaseConnectionTest` changes | route neutral cases to `DatabaseConnectionTest` and PDO cases to `DatabasePdoConnectionTest`, preserving upstream-relative order | + +Preserve Laravel relative constant/property/method order within each destination class. Do not duplicate this maintainer mapping in the public README, add source comments to every moved method, or annotate ordinary adapted code as a divergence; the class boundary plus this one sync note is sufficient. + +## Testing plan + +After editing or creating any test file, run that file immediately. The commands and matrices below are the minimum focused coverage, not permission to defer another changed test file until the full gate. + +### Context safety + +Run each changed test class immediately: + +```bash +./vendor/bin/phpunit --no-progress tests/Context/ContextCoroutineTest.php +./vendor/bin/phpunit --no-progress tests/Context/ContextTest.php +./vendor/bin/phpunit --no-progress tests/Coroutine/ParallelTest.php +./vendor/bin/phpunit --no-progress tests/Coroutine/WaiterTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/ConnectionCoroutineSafetyTest.php +``` + +Run the Redis proxy unit and integration classes that receive the new pinned-resource cases: + +```bash +./vendor/bin/phpunit --no-progress tests/Redis/RedisProxyTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisProxyIntegrationTest.php +``` + +The unit test uses pool doubles to assert exact object identity and release ownership. The integration case forces siblings to overlap on a stateful operation and proves post-parent child use without sharing a physical client. + +### Telescope + +```bash +./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/QueryWatcherTest.php +``` + +### Connection/PDO split and pool lifecycle + +Run the affected unit classes one at a time, including: + +```bash +./vendor/bin/phpunit --no-progress tests/Database/DatabaseConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabasePdoConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseSessionConfiguratorTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseConnectionFactoryTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseManagerTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseProcessorTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseConnectorTest.php +./vendor/bin/phpunit --no-progress tests/Database/PoolFactoryTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseSchemaBuilderTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseSQLiteBuilderTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseQueryExceptionTest.php +./vendor/bin/phpunit --no-progress tests/Database/QueryDurationThresholdTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Testing/DatabaseConnectionResolverTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Testing/DatabaseTruncationTest.php +./vendor/bin/phpunit --no-progress tests/Foundation/Testing/Concerns/InteractsWithDatabaseTest.php +./vendor/bin/phpunit --no-progress tests/Queue/QueueDatabaseQueueUnitTest.php +./vendor/bin/phpunit --no-progress tests/Testing/PHPUnit/AfterEachTestSubscriberTest.php +``` + +Add a focused neutral connection test that proves: + +- the base public contract contains no PDO getter/setter; +- a config-first custom non-PDO driver is created without constructing or invoking a PDO connector; +- query logging, events, grammars, pretend mode, modified-record state, reconnect, pool reset, and insert-ID errors remain coherent; +- `ConnectionInterface::escape()`, `getLastInsertId()`, and `inTransaction()` support generic event/testing, processor, and lifecycle callers without narrowing or PHPStan suppressions; +- `selectResultSets()`, `getLastInsertId()`, and `getSchemaState()` use the neutral base's precise unsupported defaults while PDO/dialect connections override only the capabilities they provide; +- a PDO `lastInsertId() === false` becomes the specified direct or query-wrapped runtime failure and never a `TypeError` or sentinel; +- `DatabaseProcessorTest` mocks `getLastInsertId('id')` directly and contains no PDO import, PDO setup, or dead PDO stub; +- `DatabasePdoConnectionTest` owns the MySQL single-handout regression and keeps exact `stateCalls === 1` and `applyCalls === 1` assertions; +- MySQL's `false` result is a `QueryException` whose previous exception is the precise `RuntimeException`, while the generic processor path returns its raw `RuntimeException` after `insert()` completes; +- MySQL captured insert IDs never survive pool reset, disconnect, or refresh and an uncaptured getter never falls back to PDO state; +- neutral nested concurrency handling invokes `invalidateCurrentSessionState()` once without invoking the rollback hook; +- PDO escaping after write, read, write-forced read, and sticky read execution quotes through that exact effective session without resolving the opposite endpoint; +- an explicit `::write` wrapper over split configuration leaves its lazy read resolver unopened when escaping, while a fresh wrapper with no prior execution role preserves the read default; +- pool reset restores constructor-derived database and table-prefix baselines even when raw config omits them, clears the last selected role, and preserves the configured single-role `readWriteType`; +- a pooled `::read` wrapper restores its selected read endpoint's configured database and prefix, while MySQL's override still clears its captured insert ID after the neutral reset; +- lost commit cleanup performs no subsequent physical transaction inspection or rollback, while lost rollback cleanup performs only its initial inspection and rollback attempt; +- `SchemaState` remains neutral while the in-memory SQLite load path rejects a non-PDO connection precisely; +- `AfterEachTestSubscriber` reaches `PdoConnection::flushState()` and empties neutral state, the PDO configurator list, and the PDO session `WeakMap`; +- an unsupported transaction fails explicitly; +- `StatementPrepared` is emitted only by PDO execution. + +Run pool and session integration classes: + +```bash +./vendor/bin/phpunit --no-progress tests/Integration/Database/PooledConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/DbPoolTeardownLifecycleTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/SessionConfiguratorTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Sqlite/DbPoolHeartbeatTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Sqlite/InMemorySqliteSharedPdoTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Postgres/PooledConnectionStateTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Postgres/SessionConfiguratorTest.php +``` + +The heartbeat timeout regression must feed the normal pool harness through a name-specific config-first factory extension that returns a connection subclass with a slow `ping()`. Preserve its explicit static coroutine-ID reset, raw interruptible `usleep()`, child-termination assertion, and no-late-requeue assertion. Remove the old test-only `PooledConnection::getOpenPdos()` fixture with the production PDO helpers; overriding `PooledConnection::ping()` would bypass the behavior under test. + +Run the MySQL, MariaDB, PostgreSQL, and SQLite integration matrix through the existing database workflow/environment. Verify read/write lazy resources, sticky routing, session configurators, transaction cleanup, heartbeat timeouts/cancellation, idle/lifetime recycling, shared in-memory SQLite, and native connect-timeout mapping. + +Add focused reconnect tests proving a cached non-pooled connection retains its complete old resource/metadata generation when fresh eager validation fails, eagerly validates both read/write resources before replacement, adopts newly selected write/read endpoint metadata on success while preserving wrapper-owned state, and dispatches `ConnectionEstablished` exactly once only after success on both pooled and non-pooled paths. Cover both teardown failure sources: a transaction-manager rollback callback failure after successful physical cleanup, and a non-lost physical rollback failure that poisons only the old PDO. In both cases the exact original throwable propagates, logical depth reaches zero, and the complete clean prepared generation plus metadata is adopted without an establishment event. The prepare-failure regression must prove the old configured database/prefix baselines survive; the teardown-failure regressions must reset after adoption and prove the fresh baselines survive. Cover cached `::read` and `::write` wrappers explicitly: each refreshes itself from its own selected-role configuration, retains the base `getName()`, and neither creates nor mutates a second base-name cache entry. Add connector tests proving MySQL/MariaDB option precedence and PostgreSQL ceiling conversion for both pool-derived and explicit fractional `connect_timeout` values. + +Run missing absolute and relative SQLite path cases in a fresh PHP subprocess that proves neither `BASE_PATH` nor a Foundation application binding exists, then assert the exact `SQLiteDatabaseDoesNotExistException` and original path. An in-process negative case is insufficient because Testbench permanently defines `BASE_PATH` for its PHPUnit worker. Add an in-process positive case with a real application root proving that an existing relative database path still resolves through `base_path()`. + +For shared in-memory SQLite, add a custom `Connection::resolverFor('sqlite', ...)` callback returning a `PdoConnection` subclass and prove initial construction plus shared-PDO refresh produce that same subclass and refresh successfully. Add negative cases for a non-PDO class resolver and config-first `extend('sqlite', ...)`: each fails with the precise configuration/identity exception before any current connection releases a resource. + +### Multi-connection fresh + +```bash +./vendor/bin/phpunit --no-progress tests/Database/DatabaseMigrationFreshCommandTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseMigrationMigrateCommandTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseMigratorConnectionRoutingTest.php +./vendor/bin/phpunit --no-progress tests/Database/DatabaseWipeCommandTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/MigrationsConnectionRoutingTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Database/Sqlite/Console/MigrateFreshCommandWithJournalModeWalTest.php +./vendor/bin/phpunit --no-progress tests/Testbench/Databases/MigrateWithHypervelMigrationsTest.php +./vendor/bin/phpunit --no-progress tests/Testbench/Databases/MigrateWithHypervelMigrationsWithoutTestingPoolTest.php +./vendor/bin/phpunit --no-progress tests/Testbench/Databases/LazilyRefreshDatabaseFileConnectionTest.php +``` + +Required cases: + +- default plus one and several explicitly declared connections; +- named and anonymous migrations; +- registered paths, application path, relative `--path`, and `--realpath`; +- duplicate declarations and two aliases resolving to one target; +- one-hop aliases resolving idempotently, with empty/non-string targets, nested chains, and cycles failing before confirmation/mutation; +- absent migrations repository with unrelated tables still present; +- genuinely absent default and secondary SQLite/MySQL/PostgreSQL databases being discovered read-only and created by both commands before migration/destruction; +- MySQL/MariaDB/PostgreSQL creation from the resolved write configuration, including split `write.database`, explicit-role metadata, URL-derived credentials, and PostgreSQL `connect_via_database`; type-safe server-level admin database values; quoting a complete target identifier containing dots and dialect quote characters; retaining MySQL/MariaDB `IF NOT EXISTS`; and disconnecting the one-off admin connection after success or failure; +- `migrate --pretend` refusing to create a missing target; +- standalone `migrate` authorizing the full missing-target set before mutation, with `--force`, interactive decline, and `--no-interaction` behavior; +- declining Fresh after its target report creating and wiping nothing; +- Fresh never wiping a target it just created, and never wiping any target when a required creation fails; +- standalone migrate performing all-target preflight before the first migration so a missing later target cannot produce a partial run; +- invalid connection declaration, authentication failure, and network failure remaining loud; +- a `shouldRun() === false` migration still contributing its declared target; +- schema-path/default target with no migration files; +- in-memory SQLite; +- wipe or migrate failure aborting before seed/event publication, and seed failure producing command failure; +- `DatabaseRefreshed` dispatching after migrate but before the requested seeder, including the documented seed-failure result; +- lazy refresh preserving the triggering wrapper on both testing resolver paths, and rolling back the triggering write on a persistent SQLite database before the next test; +- deterministic task output and one final migrate invocation. + +### Full gates + +After focused tests are green: + +```bash +composer fix +``` + +`composer fix` already runs formatting, PHPStan, the parallel suite, and Testbench. Do not redundantly run those full checks separately at the same checkpoint. If it fails, follow the repository workflow: fix with targeted checks, then run the failed entry and every remaining `fix` entry in order. + +Run the repository's configured documentation validation if one exists. Use broad `grep` sweeps across `src/`, `tests/`, and living documentation (excluding historical `docs/plans/` records and local upstream references) to prove that stale PDO descriptions, direct base `Connection` PDO construction, old `refreshPdoConnections` naming, old session registration examples, `NonReplicableContext`, alias-chain claims, and claims that every copied object survives are gone outside explicit compatibility/difference explanations. + +## Performance and worker-lifetime review + +- The context marker adds one `instanceof` only while explicitly copying context, not on ordinary request execution. +- The connection split adds no strategy dispatch to query hot paths. Existing virtual method calls remain virtual method calls. +- PDO string escaping adds one branch only when SQL text is explicitly rendered. It reuses the already selected query session, avoiding wrong-endpoint connection, session-configuration, and reconnect work; ordinary query execution is unchanged. Pretend mode has no executed-session role and retains the read-default behavior. +- `PooledConnection` keeps one child coroutine and channel only when heartbeat is enabled, exactly as today. +- `isReusable()` adds one driver method call on pool release, where PDO-specific state is already checked today. +- Configured database/prefix baselines add two refcounted string properties per wrapper and three scalar assignments on pool release; they add no query-path, context, database, or network operation. +- Connect-timeout mapping occurs during connection construction only. +- Non-pooled reconnect now eagerly opens and validates its write/read replacement set instead of transplanting lazy closures. This may open two sockets on a reconnect with split endpoints, but reconnect is an exceptional/explicit lifecycle path and the cost buys atomic replacement and truthful success; pooled refresh already pays this cost. +- Direct cached `::read`/`::write` wrappers retain current endpoint ownership while carrying explicit role metadata for in-place refresh; pooled `::write` still reuses the base pool rather than creating another resource collection. +- Migration target discovery/preflight occurs only in migration console commands and loads the same files execution loads. Fresh's nested migrate repeats read-only probes intentionally; no request/query hot path changes. +- `PdoConnection`'s static session maps retain `WeakMap` ownership. Move cleanup registration with the state; do not duplicate it. +- No new worker-global registry, mutex, per-query strategy object, or long-lived resource collection is introduced. + +## Rejected designs and why + +- **Key-prefix exclusions in Context:** hard-code Database/Redis ownership into a generic package and miss future unsafe types. A marker expresses the value's semantics at its owner. +- **Putting the marker on generic Pool connections:** adds a Context dependency and assumes every pool consumer has the same copy rule. +- **Deep-copying live connections:** cannot produce an independently owned physical slot or transfer the native defer stack. +- **Recursively scanning copied arrays/object graphs for markers:** framework resources are direct context values; recursive traversal adds cycle/reference machinery while changing documented nested application-value semantics. +- **PDO-or-mixed getters on neutral `Connection`:** preserve a dishonest API and move failures to runtime consumers such as Telescope. +- **Changing `QueryException` to `RuntimeException`:** gains conceptual purity but breaks useful Laravel exception taxonomy and requires broader deadlock/detector changes even though non-PDO drivers already wrap arbitrary `Throwable` without fake PDO state. +- **An execution-driver strategy object:** creates an unused transport/dialect matrix and weaker types. The class hierarchy is the earned extension seam. +- **A lazy/eager flag on `refreshFrom()`:** leaks lifecycle policy into the transport seam and preserves a non-pooled implementation detail. One eager, validated replacement contract is simpler and makes reconnect success truthful. +- **A generic statement-prepared event:** there is no cross-driver statement type or demonstrated listener behavior. +- **A `supportsTransactions()` flag:** invites silent branching. Unsupported transaction entry must fail; supported drivers implement physical hooks. +- **Changing the generic Pool package:** its contract is already neutral; the defect is in the Database adapter. +- **Per-driver heartbeat timeout implementations:** duplicate the existing correct cancellation harness. +- **A generic shared-resource strategy for in-memory SQLite:** only SQLite needs it today, and the existing explicit single-owner rule is correct. +- **Traversing migration aliases:** supports unverified chains and needs cycle machinery. One-hop resolution plus terminal-target validation is idempotent and matches the pooled-to-direct use case. +- **Fresh-only database creation or dropping secondary creation entirely:** the former makes `migrate` inconsistent; the latter regresses missing-default behavior and permits partial migration/destruction. Shared all-target preflight is the smaller complete rule. +- **Repeatable `migrate:fresh` target options, a target-resolver service, or PHP source scanning:** the migration declarations already provide a deterministic target set; arbitrary calls inside `up()` cannot be discovered safely without execution. +- **Silently excluding non-transactional connections in tests:** hides a user configuration error and removes isolation without consent. + +## Completion criteria + +Implementation is complete only when: + +1. every context-copy direction has identical atomic replication/omission semantics; +2. copied Database and Redis contexts can no longer share a borrowed resource; +3. Telescope has no PDO import or fallback quoting algorithm and substitutes literal/named bindings without replacement-language or prefix corruption; +4. neutral `Connection` exposes no PDO resource API and all built-in drivers retain full typed PDO access through `PdoConnection`; +5. config-first custom drivers can be pooled, probed, refreshed, disconnected, and discarded without fake PDO state; +6. current MySQL, MariaDB, PostgreSQL, and SQLite tests prove unchanged query/grammar/schema/transaction/session behavior; +7. insert-ID failure can never return `false` through an `int|string` API; +8. migration aliases resolve one hop to a validated terminal target, both migration commands prepare every declared database before mutation, and `migrate:fresh` creates verified-missing targets only after confirmation, wipes every pre-existing target without replacing its logical connection wrapper or pool ownership, and fails on every other error; +9. public documentation accurately separates PDO drivers, native/HTTP drivers, pool lifecycles, context copy rules, and migration target declarations; +10. the durable Laravel framework sync note accurately routes future `Connection.php` hunks without duplicating user documentation; +11. focused tests, `composer fix`, documentation validation, source/documentation sweeps, and a final fresh review all pass with no stale or dead artifacts. From 3d87371d5b938d5aa7cd373a4ba81fc77d15d72d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:30:57 +0000 Subject: [PATCH 11/18] docs(sync): keep divergence guidance with packages Remove the stale per-package divergence-document workflow from upstream sync instructions. Make package READMEs the canonical location for lasting Laravel differences and docs/todo.md the durable location for worthwhile deferred work. Restrict sync.yaml notes to operational sync facts and update its header comments accordingly. No package state, release tag, or sync date changes. --- docs/upstream-sync/README.md | 28 +++++++++++++++------------- docs/upstream-sync/sync.yaml | 13 +++++++------ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/upstream-sync/README.md b/docs/upstream-sync/README.md index 8ca4002ab5..8c1ba52f81 100644 --- a/docs/upstream-sync/README.md +++ b/docs/upstream-sync/README.md @@ -10,8 +10,9 @@ For the mechanics of porting code (namespace changes, container conversion, serv ## Files in this directory -- **`sync.yaml`** — state file: last reviewed tag and last ported tag per package. Updated during every session. YAML (not markdown) because raw-in-IDE readability matters more than GitHub rendering for this one. -- **`.md`** — per-package divergence notes. Created **lazily** when a real divergence is discovered. Never pre-stub empty files. Filename convention: gh repo slug with `/` replaced by `-` (e.g., `laravel-framework.md`, `orchestral-testbench.md`, `spatie-laravel-permission.md`). +- **`sync.yaml`** — state file: newest release fully reviewed and date of the most recent sync for each package. Updated during every session. Its `notes` field is only for sync-specific operational facts, such as a Composer package name that differs from the GitHub repository or the `laravel/framework` direct-to-branch scan. + +Deliberate, lasting differences from Laravel belong in the affected package README under `Differences From Laravel`. Do not create per-package divergence files in this directory. ## Non-negotiable rules @@ -25,15 +26,15 @@ For the mechanics of porting code (namespace changes, container conversion, serv ### Step 1 — Read state -Read `sync.yaml` top to bottom. For each package entry, note: the repo slug (top-level key), `release`, `sync_date`, and whether the `notes` field references a `.md` divergence doc. +Read `sync.yaml` top to bottom. For each package entry, note the repo slug, `release`, `sync_date`, and any operational instructions in `notes`. ### Step 2 — Process each package Work through the entries top to bottom. For each package: -**2a. Read divergence notes** +**2a. Read package guidance** -If the package's `notes` field references a `.md` divergence doc, read it in full before proceeding. Skip this step if there is no divergence doc. +Before classifying or porting an upstream change, read the README for every Hypervel package it affects. Its `Differences From Laravel` section is the canonical record of deliberate, lasting public differences. Also check the relevant source and tests for comments recording intentionally omitted Laravel APIs or features. **2b. Find new releases** @@ -66,7 +67,7 @@ gh pr view --repo Propose a classification and reasoning: - **port** — take this change into Hypervel -- **skip** — intentionally not taken (state why: Laravel-Cloud-specific, PHP-FPM lifecycle, already diverged per `.md`, already implemented differently in Hypervel, deprecated upstream path, etc.) +- **skip** — intentionally not taken (state why: Laravel-Cloud-specific, PHP-FPM lifecycle, conflicts with a deliberate difference recorded in the affected package README, already implemented differently in Hypervel, deprecated upstream path, etc.) - **defer** — valid but blocked (state what is blocking it and what would unblock) Wait for user approval on every classification. Never silently skip. @@ -93,7 +94,7 @@ This check is **only required for `laravel/framework`**. Other packages release **2f. Close out the release** -When every PR (and any direct commits) in the release has been decided and committed/recorded, bump `release` in `sync.yaml` to this release's tag. This happens regardless of whether anything was deferred — deferred items are tracked in the session PR body (and in `.md` if the blocker is persistent), not by holding the tag back. +When every PR (and any direct commits) in the release has been decided and committed/recorded, bump `release` in `sync.yaml` to this release's tag. This happens regardless of whether anything was deferred. Track deferred items in the session PR body and, when they remain worthwhile future work, in `docs/todo.md`; do not hold the tag back. Then move to the next release for the same package. @@ -148,15 +149,16 @@ If a session is interrupted mid-package: Never leave `sync.yaml` in a state that misrepresents what was actually done. -## Per-package divergence notes (`.md`) +## Recording differences and deferred work -Create a divergence note **only** when a real, concrete divergence is discovered that will affect future sync decisions. Contents: +Do not create package-specific divergence documents in this directory. -- **What Hypervel does differently** — the actual divergence -- **Why** — the concrete reason (Swoole semantics, architectural decision, deprecated upstream, etc.) -- **Sync implications** — what kinds of upstream PRs to skip or adapt going forward +- Record deliberate, lasting public differences from Laravel in the affected package README under `Differences From Laravel`. +- Record intentionally omitted Laravel APIs or features in the package README, source, and matching test location as required by `AGENTS.md`. +- Record deferred work in the session PR body and, when it remains worthwhile future work, in `docs/todo.md`. +- Use `sync.yaml` notes only for operational facts needed to process the upstream package. -Never speculate. Never pre-stub. If you find yourself writing a hypothetical, stop. +Do not duplicate package guidance in the sync workflow. ## Prerequisites diff --git a/docs/upstream-sync/sync.yaml b/docs/upstream-sync/sync.yaml index 686ff5441f..8120faef54 100644 --- a/docs/upstream-sync/sync.yaml +++ b/docs/upstream-sync/sync.yaml @@ -10,14 +10,15 @@ # repo_url — GitHub URL, for humans # release — newest release tag walked in a sync session. Bumped even if # some PRs were deferred; deferred items are tracked in the -# session PR body and, if persistent, in .md +# session PR body and, when they remain worthwhile future work, +# in docs/todo.md # sync_date — date (YYYY-MM-DD) of the most recent session that touched # this entry (bumped even if no releases were new) -# notes — optional, omit when empty. Permanent per-repo context that -# informs every sync of this entry (e.g. composer/repo name -# mismatch, per-repo workflow tweaks). Per-session notes -# belong in the session PR body, not here. Reference -# .md here when a divergence doc exists. +# notes — optional, omit when empty. Sync-specific operational facts +# needed to process this repo (e.g. Composer/repo name mismatch +# or per-repo workflow tweaks). Per-session notes belong in the +# session PR body; package differences belong in the affected +# package README. # # `release` and `sync_date` are null on entries that haven't been synced yet. From 733a08c06d9ecf27793e6b48545ee20601b42589 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:30:03 +0000 Subject: [PATCH 12/18] fix(telescope): preserve PostgreSQL query syntax Exclude PostgreSQL cast tokens and doubled question-mark operator escapes from Telescope binding substitution. The guards are fixed-width checks in the display-only formatter and do not affect query execution. Cover a cast type that is also a real named binding and the SQL shape emitted by whereJsonContainsKey(), each with a real placeholder that must still be substituted. --- src/telescope/src/Watchers/QueryWatcher.php | 4 +-- tests/Telescope/Watchers/QueryWatcherTest.php | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/telescope/src/Watchers/QueryWatcher.php b/src/telescope/src/Watchers/QueryWatcher.php index df1e141b86..d034a0d975 100644 --- a/src/telescope/src/Watchers/QueryWatcher.php +++ b/src/telescope/src/Watchers/QueryWatcher.php @@ -84,8 +84,8 @@ public function replaceBindings(QueryExecuted $event): string foreach ($this->formatBindings($event) as $key => $binding) { $isPositional = is_numeric($key); $regex = $isPositional - ? "/\\?(?=(?:[^'\\\\']*'[^'\\\\']*')*[^'\\\\']*$)/" - : '/:' . preg_quote((string) $key, '/') . "(?![A-Za-z0-9_])(?=(?:[^'\\\\']*'[^'\\\\']*')*[^'\\\\']*$)/"; + ? "/(?queryEvent( + 'select :payload::jsonb, :jsonb', + ['payload' => 42, 'jsonb' => 43], + ); + + $this->assertSame( + 'select 42::jsonb, 43', + $this->app->make(QueryWatcher::class)->replaceBindings($event), + ); + } + + public function testQueryWatcherDoesNotReplacePostgresJsonKeyOperators(): void + { + [$event] = $this->queryEvent( + 'select * from "users" where coalesce(("options")::jsonb ?? \'languages\', false) and "id" = ?', + [42], + ); + + $this->assertSame( + 'select * from "users" where coalesce(("options")::jsonb ?? \'languages\', false) and "id" = 42', + $this->app->make(QueryWatcher::class)->replaceBindings($event), + ); + } + public function testQueryWatcherRedactsBindingsTheConnectionCannotEscape(): void { $event = new QueryExecuted( From e612824fb5477a989edc5d82579266bce904843b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:30:15 +0000 Subject: [PATCH 13/18] docs(database): preserve connection split invariants Record why PDO escaping must use the physical session that executed the last query. Quoting may depend on session configuration, while resolving the other endpoint can open or reconfigure it during query formatting. Keep future Laravel database updates on the correct side of the driver-neutral Connection and PDO-specific PdoConnection boundary, with dialect behavior remaining in its existing driver classes. --- src/database/README.md | 2 +- src/database/src/PdoConnection.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/database/README.md b/src/database/README.md index 4b389af7ee..e9febf05e1 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -8,7 +8,7 @@ Documentation: https://hypervel.org/docs/database ## Differences From Laravel - Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix. -- Laravel exposes PDO methods on its base connection class. Hypervel's base `Connection` is driver-neutral, while PDO-backed drivers extend `PdoConnection`. Code that requires direct PDO access should accept or narrow to `PdoConnection`. +- Laravel exposes PDO methods on its base connection class. Hypervel's base `Connection` is driver-neutral, while PDO-backed drivers extend `PdoConnection`. Code that requires direct PDO access should accept or narrow to `PdoConnection`. When bringing in Laravel database updates, keep driver-neutral connection behavior on `Connection`, PDO mechanics on `PdoConnection`, and driver-specific behavior on the matching connection, grammar, schema builder, or processor. - Laravel's `migrate:fresh` command wipes only its selected connection. Hypervel discovers the connection declared by each migration and wipes every resolved target before rebuilding the schema. - Laravel's deprecated database-inspection forwarding helpers are intentionally not ported. Extensions can call `ConnectionInterface::getDriverTitle()` and `threadCount()` directly. - Laravel's remaining directly deprecated Database compatibility forwarders are intentionally not ported. Use the current class-keyed factory resolver, schema blueprint and grammar APIs, and correctly named PostgreSQL truncation method instead. diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 7d71d13361..93f114ed12 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -279,6 +279,8 @@ public function bindValues(PDOStatement $statement, array $bindings): void */ protected function escapeString(string $value): string { + // Quote through the session that executed the last query because quoting may depend + // on its configuration, and resolving the other endpoint may open or reconfigure it. $pdo = $this->latestReadWriteTypeUsed() === 'write' ? $this->getPdo() : $this->getReadPdo(); From 0af9f4afc8553be1d47972b050ce84d298622cc0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:30:28 +0000 Subject: [PATCH 14/18] docs(migrations): clarify migration history ownership Explain that the migration command records history on its resolved repository connection even when individual migrations execute schema work on another connection. Place the note after migrations_connection resolution is described and avoid implying that the configurable migrations table has a fixed name. --- src/docs/migrations.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 026ed8f621..13a49cc244 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -155,6 +155,8 @@ If one of your application's database connections should use a different connect The migration connection must be a terminal target. The target connection may omit `migrations_connection` or point to itself, but connection chains such as `primary` to `schema` to `admin` are not supported. +Hypervel records migration history in the migrations table on the connection resolved by the command, even when individual migrations run on another connection. + #### Skipping Migrations From 3f557e1b0fcef6ff35844b95b3a5d7fe24ea8cdf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:30:40 +0000 Subject: [PATCH 15/18] docs(database): clarify pool lifecycle options Split the pool option reference into focused Laravel-style paragraphs while preserving the complete behavior of each setting. State that connections open on demand in the borrowing coroutine, that the managed count may fall below min_connections, and that lifetime recycling occurs only while a connection is idle or before it is reused. --- src/docs/database.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/docs/database.md b/src/docs/database.md index 3f4d01526a..2c75fdc393 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -189,7 +189,13 @@ Each connection may define its own `pool` configuration: ], ``` -The `min_connections` option controls how far trimming excess idle connections may reduce the total managed connection count. It is not an idle-count invariant or a guaranteed total minimum, and it does not prewarm or automatically replenish the pool. The caller that first needs each new connection pays its connection-establishment cost, and the pool may have zero idle connections under load. Lifecycle-expired or unhealthy connections and explicit discards can reduce the managed count below `min_connections`; failed connection creation can leave it below that value. None is automatically replenished. The `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection. The `wait_timeout` option controls how long a coroutine may wait for an available connection when the pool is exhausted. The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool; set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel asks the database driver to check each retained idle connection without firing query events, query logs, or query duration handlers. Hypervel's PDO drivers use a raw `SELECT 1` query, while native and HTTP drivers may use their own protocol. The `heartbeat_timeout` option controls how long a heartbeat check may run before the connection is discarded. The `max_idle_time` option controls how long an idle connection may remain in the pool while the total managed count is above `min_connections`. The `max_lifetime` option controls the upper bound for how long a pooled connection generation may live before it is recycled while idle or before it is reused; Hypervel assigns each generation an effective lifetime between 90-100% of this value to avoid synchronized reconnects. Set this value to `-1` to disable lifetime recycling. +The `min_connections` option controls how far Hypervel may trim excess idle connections. It does not prewarm or automatically replenish the pool, and the pool may have no idle connections while it is under load. The coroutine that first needs each new connection therefore pays the cost of opening it. Expired, unhealthy, or discarded connections may reduce the managed connection count below this value. A failed connection attempt may do the same. + +The `max_connections` option determines the maximum number of connections that may be opened for the worker. The `connect_timeout` option controls how long Hypervel will wait while opening a new database connection, while `wait_timeout` controls how long a coroutine may wait for an available connection when the pool is exhausted. + +The `heartbeat` option controls how often Hypervel validates idle connections in the worker pool. Set this value to `-1` to disable heartbeats. When heartbeats are enabled, Hypervel asks the database driver to check each retained idle connection without firing query events, query logs, or query duration handlers. Hypervel's PDO drivers use a raw `SELECT 1` query, while native and HTTP drivers may use their own protocol. The `heartbeat_timeout` option controls how long a heartbeat check may run before the connection is discarded. + +The `max_idle_time` option controls how long an idle connection may remain in the pool while the managed connection count is above `min_connections`. The `max_lifetime` option controls how long a pooled connection may live. Hypervel recycles an expired connection only while it is idle or before it is reused. To avoid synchronized reconnects, Hypervel varies each connection's effective lifetime between 90 and 100 percent of this value. Set `max_lifetime` to `-1` to disable lifetime recycling. 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. From 99e51f6e48c238dcaec9abfe78568a3643897890 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:30:54 +0000 Subject: [PATCH 16/18] docs(plan): record final database review corrections Keep the implementation plan aligned with the final source and documentation after review. Record Telescope cast and operator handling, the durable PDO escaping comment, driver-neutral Laravel update routing, central migration history ownership, and precise pool lifecycle prose. Remove the superseded upstream-sync document design so the plan points future database work to the package README without creating a second sync-state surface. --- ...00-driver-neutral-database-architecture.md | 41 ++++++++----------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md b/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md index 2ccef6eceb..56cf10af4e 100644 --- a/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md +++ b/docs/plans/2026-08-08-1300-driver-neutral-database-architecture.md @@ -44,6 +44,8 @@ This is a driver-independent correctness defect. PDO connections must not be sha `Database\Connection::escape()` is already the public connection-owned quoting API. The watcher should not select a transport or dialect itself. +The watcher's placeholder matcher also treats PostgreSQL syntax as bindings. A binding named `jsonb`, for example, can replace the cast token in `:payload::jsonb`, while the positional matcher consumes the first question mark in PostgreSQL's doubled `??` operator escape. The latter affects ordinary query-builder output such as `whereJsonContainsKey()`, not only raw SQL. + ### 3. The generic Pool package is already driver-neutral `Hypervel\Contracts\Pool\ConnectionInterface` owns only generic pool lifecycle operations. The PDO coupling is confined to `Database\Pool\PooledConnection` and `Database\Pool\DbPool`: @@ -205,10 +207,12 @@ try { Remove the now-unused `PDO` and `PDOException` imports and add the `RuntimeException` import. Catch only the documented representability/driver runtime failure from `escape()` for the individual string binding; do not catch `Throwable`, because a driver `TypeError`, assertion, or other programming failure must remain visible. Do not catch around the whole event, invent another quoting algorithm, or expose the original bytes in the marker. Other failures in record construction remain visible; only a value that cannot be represented safely in SQL text is redacted. -Correct two locally verified bugs inherited from `laravel/telescope` in the same method: +Correct four locally verified bugs inherited from `laravel/telescope` in the same method: - pass replacements through `preg_replace_callback()` so `$1`, backslashes, and other replacement-language bytes in an already escaped binding remain literal; -- `preg_quote()` named keys and require a parameter-name boundary so `:id` cannot replace the prefix of `:id2`. +- `preg_quote()` named keys and require a parameter-name boundary so `:id` cannot replace the prefix of `:id2`; +- require `(? Date: Mon, 24 Aug 2026 09:00:26 +0000 Subject: [PATCH 17/18] refactor(database): remove obsolete PHP guards Hypervel requires PHP 8.4 or newer, so the older PDO construction, SQLite transaction, and model serialization branches can never run. Use the supported PHP APIs directly while preserving the existing live behavior. Use the PHP 8.4 Pdo\Mysql SSL constants throughout schema state generation, shipped configuration, documentation, and matching tests. This removes an incorrect PHP 8.5 threshold and aligns the code with current Laravel without retaining deprecated PDO aliases. --- src/database/src/Connectors/Connector.php | 4 +--- src/database/src/Eloquent/Model.php | 8 +++----- src/database/src/SQLiteConnection.php | 10 ++-------- src/database/src/Schema/MySqlSchemaState.php | 18 ++++++++---------- src/docs/database.md | 2 +- src/foundation/config/database.php | 6 ++++-- .../DatabaseMariaDbSchemaStateTest.php | 12 ++++++------ .../Database/DatabaseMySqlSchemaStateTest.php | 12 ++++++------ 8 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/database/src/Connectors/Connector.php b/src/database/src/Connectors/Connector.php index d1f52fc9ee..2d8b8f0d4f 100755 --- a/src/database/src/Connectors/Connector.php +++ b/src/database/src/Connectors/Connector.php @@ -60,9 +60,7 @@ public function createConnection(string $dsn, array $config, array $options): PD */ protected function createPdoConnection(string $dsn, ?string $username, #[SensitiveParameter] ?string $password, array $options): PDO { - return version_compare(PHP_VERSION, '8.4.0', '<') - ? new PDO($dsn, $username, $password, $options) - : PDO::connect($dsn, $username, $password, $options); + return PDO::connect($dsn, $username, $password, $options); } /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 21ca8d7797..b7678aa7e0 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -2898,11 +2898,9 @@ public function __sleep(): array $keys = get_object_vars($this); - if (version_compare(PHP_VERSION, '8.4.0', '>=')) { - foreach ((new ReflectionClass($this))->getProperties() as $property) { - if ($property->hasHooks()) { - unset($keys[$property->getName()]); - } + foreach ((new ReflectionClass($this))->getProperties() as $property) { + if ($property->hasHooks()) { + unset($keys[$property->getName()]); } } diff --git a/src/database/src/SQLiteConnection.php b/src/database/src/SQLiteConnection.php index 0d1d7353aa..9341930073 100755 --- a/src/database/src/SQLiteConnection.php +++ b/src/database/src/SQLiteConnection.php @@ -28,15 +28,9 @@ public function getDriverTitle(): string */ protected function executeBeginTransactionStatement(): void { - if (version_compare(PHP_VERSION, '8.4.0', '>=')) { - $mode = $this->getConfig('transaction_mode') ?? 'DEFERRED'; + $mode = $this->getConfig('transaction_mode') ?? 'DEFERRED'; - $this->getPdo()->exec("BEGIN {$mode} TRANSACTION"); - - return; - } - - $this->getPdo()->beginTransaction(); + $this->getPdo()->exec("BEGIN {$mode} TRANSACTION"); } /** diff --git a/src/database/src/Schema/MySqlSchemaState.php b/src/database/src/Schema/MySqlSchemaState.php index df03b29010..c00a02fba6 100644 --- a/src/database/src/Schema/MySqlSchemaState.php +++ b/src/database/src/Schema/MySqlSchemaState.php @@ -9,7 +9,7 @@ use Hypervel\Database\MySqlConnection; use Hypervel\Support\Str; use Override; -use PDO; +use Pdo\Mysql; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; @@ -109,21 +109,19 @@ protected function connectionString(array $versionInfo): string ? ' --socket="${:HYPERVEL_LOAD_SOCKET}"' : ' --host="${:HYPERVEL_LOAD_HOST}" --port="${:HYPERVEL_LOAD_PORT}"'; - if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA])) { + if (isset($config['options'][Mysql::ATTR_SSL_CA])) { $value .= ' --ssl-ca="${:HYPERVEL_LOAD_SSL_CA}"'; } - if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT])) { + if (isset($config['options'][Mysql::ATTR_SSL_CERT])) { $value .= ' --ssl-cert="${:HYPERVEL_LOAD_SSL_CERT}"'; } - if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : PDO::MYSQL_ATTR_SSL_KEY])) { + if (isset($config['options'][Mysql::ATTR_SSL_KEY])) { $value .= ' --ssl-key="${:HYPERVEL_LOAD_SSL_KEY}"'; } - $verifyCertOption = PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT; - - if (isset($config['options'][$verifyCertOption]) && $config['options'][$verifyCertOption] === false) { + if (($config['options'][Mysql::ATTR_SSL_VERIFY_SERVER_CERT] ?? null) === false) { if (version_compare($versionInfo['version'], '5.7.11', '>=') && ! $versionInfo['isMariaDb']) { $value .= ' --ssl-mode=DISABLED'; } else { @@ -149,9 +147,9 @@ protected function baseVariables(array $config): array 'HYPERVEL_LOAD_USER' => $config['username'], 'HYPERVEL_LOAD_PASSWORD' => $config['password'] ?? '', 'HYPERVEL_LOAD_DATABASE' => $config['database'], - 'HYPERVEL_LOAD_SSL_CA' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA] ?? '', - 'HYPERVEL_LOAD_SSL_CERT' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT] ?? '', - 'HYPERVEL_LOAD_SSL_KEY' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : PDO::MYSQL_ATTR_SSL_KEY] ?? '', + 'HYPERVEL_LOAD_SSL_CA' => $config['options'][Mysql::ATTR_SSL_CA] ?? '', + 'HYPERVEL_LOAD_SSL_CERT' => $config['options'][Mysql::ATTR_SSL_CERT] ?? '', + 'HYPERVEL_LOAD_SSL_KEY' => $config['options'][Mysql::ATTR_SSL_KEY] ?? '', ]); } diff --git a/src/docs/database.md b/src/docs/database.md index 2c75fdc393..a4ccca4ba5 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -132,7 +132,7 @@ To see how read / write connections should be configured, let's look at this exa 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + \Pdo\Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index ccca51844b..eb6a04c6d2 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Pdo\Mysql; + return [ /* |-------------------------------------------------------------------------- @@ -73,7 +75,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), @@ -103,7 +105,7 @@ 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ - (PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], 'pool' => [ 'min_connections' => (int) env('DB_MIN_CONNECTIONS', 1), diff --git a/tests/Database/DatabaseMariaDbSchemaStateTest.php b/tests/Database/DatabaseMariaDbSchemaStateTest.php index 98226e1f73..c0aa2fb70c 100644 --- a/tests/Database/DatabaseMariaDbSchemaStateTest.php +++ b/tests/Database/DatabaseMariaDbSchemaStateTest.php @@ -8,7 +8,7 @@ use Hypervel\Database\MariaDbConnection; use Hypervel\Database\Schema\MariaDbSchemaState; use Hypervel\Tests\TestCase; -use PDO; +use Pdo\Mysql; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use Symfony\Component\Process\Exception\ProcessFailedException; @@ -74,7 +74,7 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', + Mysql::ATTR_SSL_CA => 'ssl.ca', ], ], ]; @@ -94,9 +94,9 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT => '/path/to/client-cert.pem', - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : PDO::MYSQL_ATTR_SSL_KEY => '/path/to/client-key.pem', + Mysql::ATTR_SSL_CA => 'ssl.ca', + Mysql::ATTR_SSL_CERT => '/path/to/client-cert.pem', + Mysql::ATTR_SSL_KEY => '/path/to/client-key.pem', ], ], ]; @@ -116,7 +116,7 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, + Mysql::ATTR_SSL_VERIFY_SERVER_CERT => false, ], ], ]; diff --git a/tests/Database/DatabaseMySqlSchemaStateTest.php b/tests/Database/DatabaseMySqlSchemaStateTest.php index bb75a4dbaf..f01c571ad6 100644 --- a/tests/Database/DatabaseMySqlSchemaStateTest.php +++ b/tests/Database/DatabaseMySqlSchemaStateTest.php @@ -9,7 +9,7 @@ use Hypervel\Database\MySqlConnection; use Hypervel\Database\Schema\MySqlSchemaState; use Hypervel\Tests\TestCase; -use PDO; +use Pdo\Mysql; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use Symfony\Component\Process\Process; @@ -93,7 +93,7 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', + Mysql::ATTR_SSL_CA => 'ssl.ca', ], ], ]; @@ -113,9 +113,9 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA => 'ssl.ca', - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : PDO::MYSQL_ATTR_SSL_CERT => '/path/to/client-cert.pem', - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : PDO::MYSQL_ATTR_SSL_KEY => '/path/to/client-key.pem', + Mysql::ATTR_SSL_CA => 'ssl.ca', + Mysql::ATTR_SSL_CERT => '/path/to/client-cert.pem', + Mysql::ATTR_SSL_KEY => '/path/to/client-key.pem', ], ], ]; @@ -135,7 +135,7 @@ public static function provider(): Generator 'username' => 'root', 'database' => 'forge', 'options' => [ - PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => false, + Mysql::ATTR_SSL_VERIFY_SERVER_CERT => false, ], ], ]; From a33659484e9448597e83efd38b5c43a900a1c771 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:00:37 +0000 Subject: [PATCH 18/18] test(http): remove obsolete BMP MIME branch Hypervel supports PHP 8.4 and newer, where Fileinfo reports generated BMP images as image/bmp. Assert that supported behavior directly instead of retaining an unreachable PHP 8.2 fallback. --- tests/Http/HttpTestingFileFactoryTest.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/Http/HttpTestingFileFactoryTest.php b/tests/Http/HttpTestingFileFactoryTest.php index c0de94e043..fbb23ba735 100644 --- a/tests/Http/HttpTestingFileFactoryTest.php +++ b/tests/Http/HttpTestingFileFactoryTest.php @@ -97,13 +97,10 @@ public function testImageBmp(): void { $image = (new FileFactory)->image('test.bmp'); - $imagePath = $image->getRealPath(); - - if (version_compare(PHP_VERSION, '8.3.0-dev', '>=')) { - $this->assertSame('image/bmp', mime_content_type($imagePath)); - } else { - $this->assertSame('image/x-ms-bmp', mime_content_type($imagePath)); - } + $this->assertSame( + 'image/bmp', + mime_content_type($image->getRealPath()) + ); } public function testCreateWithMimeType(): void