From 8bd611a5087bb60857897cf257d65d17863f19a5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:10:29 +0000 Subject: [PATCH 01/11] Harden schema execution and session restoration Route Blueprint execution through the connection-owned schema builder, compile statements once, and fail loudly when a schema statement reports failure. Make foreign-key suppression connection-owned and nest-safe, preserve the incoming MySQL and MariaDB state, bypass application callbacks for physical-session restoration, and invalidate leaked or failed session state before pooled reuse. Reject pooled reconnects that remain unsafe, including shared in-memory SQLite sessions that cannot be replaced without losing their database, and cover execution order, failure handling, nesting, restoration, pool reset, and real database behavior. --- src/database/src/Connection.php | 58 +++++++- src/database/src/Pool/PooledConnection.php | 12 ++ src/database/src/Schema/Blueprint.php | 4 +- src/database/src/Schema/Builder.php | 113 +++++++++++++- src/database/src/Schema/MySqlBuilder.php | 17 ++- src/support/src/Facades/Schema.php | 1 + tests/Database/DatabaseConnectionTest.php | 61 +++++++- tests/Database/DatabaseMySqlBuilderTest.php | 49 ++++++ .../Database/DatabaseSchemaBlueprintTest.php | 14 +- tests/Database/DatabaseSchemaBuilderTest.php | 139 ++++++++++++++++++ .../DatabaseMariaDbSchemaBuilderTest.php | 32 ++++ .../MySql/DatabaseMySqlSchemaBuilderTest.php | 32 ++++ .../Database/PooledConnectionTest.php | 74 +++++++--- 13 files changed, 562 insertions(+), 44 deletions(-) diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 8ecb73587..a11c3d316 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -25,6 +25,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Traits\Macroable; +use LogicException; use PDO; use PDOStatement; use RuntimeException; @@ -114,6 +115,11 @@ class Connection implements ConnectionInterface */ protected int $transactions = 0; + /** + * The depth of the active foreign key constraint suppression scope. + */ + protected int $foreignKeyConstraintSuppressionDepth = 0; + /** * The transaction manager instance. */ @@ -1014,14 +1020,43 @@ public function clearBeforeExecutingCallbacks(): void $this->beforeExecutingCallbacks = []; } + /** + * Begin a foreign key constraint suppression scope. + * + * @internal + */ + public function beginForeignKeyConstraintSuppression(): bool + { + return ++$this->foreignKeyConstraintSuppressionDepth === 1; + } + + /** + * End a foreign key constraint suppression scope. + * + * @internal + */ + public function endForeignKeyConstraintSuppression(): void + { + if ($this->foreignKeyConstraintSuppressionDepth === 0) { + throw new LogicException('No foreign key constraint suppression scope is active.'); + } + + --$this->foreignKeyConstraintSuppressionDepth; + } + /** * Reset all wrapper state for pool release. * - * Physical database session state is preserved and synchronized against + * Trustworthy physical session state is preserved and synchronized against * the next coroutine's desired state when the PDO is handed out again. */ public function resetForPool(): void { + if ($this->foreignKeyConstraintSuppressionDepth > 0) { + $this->markCurrentSessionStateUnknown(); + $this->foreignKeyConstraintSuppressionDepth = 0; + } + // Clear registered callbacks $this->beforeExecutingCallbacks = []; $this->beforeStartingTransaction = []; @@ -1424,15 +1459,28 @@ protected function invalidateSessionState(PDO $pdo): void */ protected function markSessionStateUnknown(PDO $pdo): void { - if (static::$sessionConfigurators === []) { - return; - } - $sessionState = static::physicalSessionState($pdo); $sessionState->appliedStates = []; $sessionState->unknown = true; } + /** + * 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); + } + /** * Determine whether an open PDO has unknown session state. * diff --git a/src/database/src/Pool/PooledConnection.php b/src/database/src/Pool/PooledConnection.php index d5d090e12..0e3a061ad 100644 --- a/src/database/src/Pool/PooledConnection.php +++ b/src/database/src/Pool/PooledConnection.php @@ -119,6 +119,18 @@ public function reconnect(): bool $this->connection = $this->factory->make($this->config, $this->config['name'] ?? null); } + if ($this->connection->hasUnknownSessionState()) { + $this->markInvalid(); + + if ($sharedPdo !== null) { + throw new RuntimeException( + 'The shared in-memory SQLite database session is unknown and its sole connection cannot be replaced without discarding the database.' + ); + } + + throw new RuntimeException('Database session state remains unknown after reconnecting.'); + } + // Configure event dispatcher for query events if ($this->container->bound('events')) { $this->connection->setEventDispatcher($this->container->make('events')); diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 491793c07..c53c15945 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -103,9 +103,7 @@ public function __construct(Connection $connection, string $table, ?Closure $cal */ public function build(): void { - foreach ($this->toSql() as $statement) { - $this->connection->statement($statement); - } + $this->connection->getSchemaBuilder()->executeBlueprint($this); } /** diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index a24485b4f..296c1c395 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use LogicException; use RuntimeException; +use Throwable; class Builder { @@ -366,7 +367,7 @@ public function getColumns(string $table): array /** * Get the indexes for a given table. * - * @return list, type: string, unique: bool, primary: bool}> + * @return list, type: null|string, unique: bool, primary: bool}> */ public function getIndexes(string $table): array { @@ -559,12 +560,74 @@ public function disableForeignKeyConstraints(): bool */ public function withoutForeignKeyConstraints(Closure $callback): mixed { - $this->disableForeignKeyConstraints(); + $outermost = $this->connection->beginForeignKeyConstraintSuppression(); + $restoreConstraints = false; try { + // Pretend mode cannot inspect physical state, but it must still log both session statements. + $restoreConstraints = $outermost + && ($this->connection->pretending() || $this->foreignKeyConstraintsAreEnabled()); + + if ($restoreConstraints) { + $this->setForeignKeyConstraints(false); + } + return $callback(); } finally { - $this->enableForeignKeyConstraints(); + try { + if ($restoreConstraints) { + $this->setForeignKeyConstraints(true); + } + } finally { + $this->connection->endForeignKeyConstraintSuppression(); + } + } + } + + /** + * Determine whether foreign key constraints are enabled. + * + * Drivers that cannot inspect the current mode report enabled so the + * outer scope applies and restores their normal constraint mode. + */ + protected function foreignKeyConstraintsAreEnabled(): bool + { + return true; + } + + /** + * Set the foreign key constraint state for an internal suppression scope. + */ + protected function setForeignKeyConstraints(bool $enabled): void + { + $statement = $enabled + ? $this->grammar->compileEnableForeignKeyConstraints() + : $this->grammar->compileDisableForeignKeyConstraints(); + + if ($this->connection->pretending()) { + if ($this->connection->statement($statement) === false) { + throw new RuntimeException("Failed to execute schema statement [{$statement}]."); + } + + return; + } + + $this->executeSessionStatement($statement); + } + + /** + * Execute internal physical-session maintenance. + */ + 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; } } @@ -593,12 +656,54 @@ public function ensureExtensionExists(string $name, ?string $schema = null): voi }); } + /** + * Execute the given schema blueprint. + */ + public function executeBlueprint(Blueprint $blueprint): void + { + $this->executeStatements($blueprint->toSql()); + } + /** * Execute the blueprint to build / modify the table. */ protected function build(Blueprint $blueprint): void { - $blueprint->build(); + $this->executeBlueprint($blueprint); + } + + /** + * Execute the given schema statements in order. + * + * @param list $statements + */ + protected function executeStatements(array $statements): void + { + foreach ($statements as $statement) { + if ($this->connection->statement($statement) === false) { + throw new RuntimeException("Failed to execute schema statement [{$statement}]."); + } + } + } + + /** + * Determine whether every executable command is declared by the framework grammar. + * + * @param class-string $grammar + */ + protected function commandsAreDeclaredOn(Blueprint $blueprint, string $grammar): bool + { + foreach ($blueprint->getCommands() as $command) { + if ($command->shouldBeSkipped) { + continue; + } + + if (! method_exists($grammar, 'compile' . ucfirst($command->name))) { + return false; + } + } + + return true; } /** diff --git a/src/database/src/Schema/MySqlBuilder.php b/src/database/src/Schema/MySqlBuilder.php index 946df8325..48abf5053 100755 --- a/src/database/src/Schema/MySqlBuilder.php +++ b/src/database/src/Schema/MySqlBuilder.php @@ -23,15 +23,11 @@ public function dropAllTables(): void return; } - $this->disableForeignKeyConstraints(); - - try { + $this->withoutForeignKeyConstraints(function () use ($tables): void { $this->connection->statement( $this->grammar->compileDropAllTables($tables) ); - } finally { - $this->enableForeignKeyConstraints(); - } + }); } /** @@ -51,6 +47,15 @@ public function dropAllViews(): void ); } + /** + * Determine whether foreign key constraints are enabled. + */ + #[Override] + protected function foreignKeyConstraintsAreEnabled(): bool + { + return (bool) $this->connection->scalar('select @@foreign_key_checks'); + } + /** * Get the names of current schemas for the connection. */ diff --git a/src/support/src/Facades/Schema.php b/src/support/src/Facades/Schema.php index 78806884f..f129dd62d 100644 --- a/src/support/src/Facades/Schema.php +++ b/src/support/src/Facades/Schema.php @@ -48,6 +48,7 @@ * @method static mixed withoutForeignKeyConstraints(\Closure $callback) * @method static void ensureVectorExtensionExists(string|null $schema = null) * @method static void ensureExtensionExists(string $name, string|null $schema = null) + * @method static void executeBlueprint(\Hypervel\Database\Schema\Blueprint $blueprint) * @method static null|string[] getCurrentSchemaListing() * @method static string|null getCurrentSchemaName() * @method static array parseSchemaAndTable(string $reference, string|bool|null $withDefaultSchema = null) diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 5db4fa9a1..09ccb9943 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -26,6 +26,7 @@ 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; @@ -994,6 +995,7 @@ public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned $this->assertSame(1, $connection->transactionLevel()); $this->assertCount(1, $manager->getPendingTransactions()); $this->assertSame($pdo, $connection->getRawPdo()); + $this->assertTrue($connection->hasUnknownSessionState()); $connection->rollBack(); } @@ -1036,8 +1038,6 @@ public function testLostManagedCommitTerminallyDetachesTransactionState(): void public function testNonLostPhysicalRollbackFailureKeepsActiveStateAndMarksTheSessionUnknown(): void { - Connection::configureSessionUsing(new StatementPathSessionConfigurator); - $failure = new RuntimeException('rollback failure'); $pdo = $this->getMockBuilder(PDOStub::class) ->onlyMethods(['beginTransaction', 'inTransaction', 'rollBack']) @@ -1190,6 +1190,10 @@ public function testDisconnectExhaustsCleanupAndPreservesThePhysicalFailure(): v $this->assertCount(0, $manager->getPendingTransactions()); $this->assertNull($connection->getRawPdo()); $this->assertNull($connection->getRawReadPdo()); + + $connection->setPdo($pdo); + + $this->assertTrue($connection->hasUnknownSessionState()); } public function testDisconnectTreatsLostPhysicalRollbackFailureAsAlreadyTerminal(): void @@ -1380,6 +1384,59 @@ public function testResetForPoolClearsStickyReadRoutingState(): void $this->assertSame($readPdo, $connection->getReadPdo()); } + public function testForeignKeyConstraintSuppressionDepthIsConnectionOwned(): void + { + $connection = $this->getMockConnection(); + + $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); + $this->assertFalse($connection->beginForeignKeyConstraintSuppression()); + + $connection->endForeignKeyConstraintSuppression(); + $connection->endForeignKeyConstraintSuppression(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No foreign key constraint suppression scope is active.'); + + $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 diff --git a/tests/Database/DatabaseMySqlBuilderTest.php b/tests/Database/DatabaseMySqlBuilderTest.php index 4b6dbc283..5a70f1ed0 100644 --- a/tests/Database/DatabaseMySqlBuilderTest.php +++ b/tests/Database/DatabaseMySqlBuilderTest.php @@ -9,6 +9,7 @@ use Hypervel\Database\Schema\MySqlBuilder; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; class DatabaseMySqlBuilderTest extends TestCase { @@ -42,4 +43,52 @@ public function testDropDatabaseIfExists() $builder->dropDatabaseIfExists('my_database_a'); } + + 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(); + $connection->shouldReceive('getDatabaseName')->once()->andReturn('database'); + $builder->shouldReceive('getTableListing')->once()->with(['database'])->andReturn(['users']); + $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); + $connection->shouldReceive('pretending')->times(3)->andReturnFalse(); + $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks')->andReturn(1); + $connection->shouldReceive('getPdo')->twice()->andReturn($pdo); + $pdo->shouldReceive('exec')->once()->with('SET FOREIGN_KEY_CHECKS=0;')->andReturn(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('endForeignKeyConstraintSuppression')->once(); + + $builder->dropAllTables(); + } + + public function testDropAllTablesPreservesDisabledForeignKeyConstraints(): void + { + $connection = m::mock(Connection::class); + $grammar = new MySqlGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = m::mock(MySqlBuilder::class, [$connection])->makePartial(); + $connection->shouldReceive('getDatabaseName')->once()->andReturn('database'); + $builder->shouldReceive('getTableListing')->once()->with(['database'])->andReturn(['users']); + $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks')->andReturn(0); + $connection->shouldReceive('getPdo')->never(); + $connection->shouldReceive('statement') + ->once() + ->with($grammar->compileDropAllTables(['users'])) + ->andReturnTrue(); + $connection->shouldReceive('endForeignKeyConstraintSuppression')->once(); + + $builder->dropAllTables(); + } } diff --git a/tests/Database/DatabaseSchemaBlueprintTest.php b/tests/Database/DatabaseSchemaBlueprintTest.php index a2fc38c7f..eaf6ea29a 100755 --- a/tests/Database/DatabaseSchemaBlueprintTest.php +++ b/tests/Database/DatabaseSchemaBlueprintTest.php @@ -23,13 +23,15 @@ protected function tearDown(): void parent::tearDown(); } - public function testToSqlRunsCommandsFromBlueprint() + public function testBuildDelegatesToTheConnectionOwnedBuilder(): void { - $conn = $this->getConnection(); - $conn->shouldReceive('statement')->once()->with('foo'); - $conn->shouldReceive('statement')->once()->with('bar'); - $blueprint = $this->getMockBuilder(Blueprint::class)->onlyMethods(['toSql'])->setConstructorArgs([$conn, 'users'])->getMock(); - $blueprint->expects($this->once())->method('toSql')->willReturn(['foo', 'bar']); + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new MySqlGrammar($connection)); + $builder = m::mock(Builder::class); + $blueprint = new Blueprint($connection, 'users'); + + $connection->shouldReceive('getSchemaBuilder')->once()->andReturn($builder); + $builder->shouldReceive('executeBlueprint')->once()->with($blueprint); $blueprint->build(); } diff --git a/tests/Database/DatabaseSchemaBuilderTest.php b/tests/Database/DatabaseSchemaBuilderTest.php index 9ba3a4870..0d50a4b06 100644 --- a/tests/Database/DatabaseSchemaBuilderTest.php +++ b/tests/Database/DatabaseSchemaBuilderTest.php @@ -6,10 +6,13 @@ use Hypervel\Database\Connection; use Hypervel\Database\Query\Processors\Processor; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\Grammars\Grammar; use Hypervel\Tests\TestCase; use Mockery as m; +use PDO; +use RuntimeException; class DatabaseSchemaBuilderTest extends TestCase { @@ -37,6 +40,142 @@ public function testDropDatabaseIfExists() $this->assertTrue($builder->dropDatabaseIfExists('foo')); } + public function testExecuteBlueprintCompilesOnceAndExecutesStatementsInOrder(): void + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $blueprint = m::mock(Blueprint::class); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $blueprint->shouldReceive('toSql')->once()->andReturn(['first statement', 'second statement']); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new Builder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintThrowsWhenAStatementReturnsFalse(): void + { + $connection = m::mock(Connection::class); + $grammar = m::mock(Grammar::class); + $blueprint = m::mock(Blueprint::class); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $blueprint->shouldReceive('toSql')->once()->andReturn(['failed statement', 'unreached statement']); + $connection->shouldReceive('statement')->once()->with('failed statement')->andReturnFalse(); + $connection->shouldReceive('statement')->never()->with('unreached statement'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [failed statement].'); + + (new Builder($connection))->executeBlueprint($blueprint); + } + + public function testWithoutForeignKeyConstraintsNestsAcrossBuilderInstances(): void + { + $pdo = m::mock(PDO::class); + $connection = new Connection($pdo, 'test'); + $grammar = m::mock(Grammar::class); + $connection->setSchemaGrammar($grammar); + + $grammar->shouldReceive('compileDisableForeignKeyConstraints')->once()->andReturn('disable constraints'); + $grammar->shouldReceive('compileEnableForeignKeyConstraints')->once()->andReturn('enable constraints'); + $pdo->shouldReceive('exec')->once()->with('disable constraints')->andReturn(0)->ordered(); + $pdo->shouldReceive('exec')->once()->with('enable constraints')->andReturn(0)->ordered(); + + $outer = new Builder($connection); + $inner = new Builder($connection); + + $result = $outer->withoutForeignKeyConstraints( + fn () => $inner->withoutForeignKeyConstraints(fn () => 'result') + ); + + $this->assertSame('result', $result); + } + + public function testWithoutForeignKeyConstraintsUsesTheStatementPathWhilePretending(): void + { + $resolutions = 0; + $connection = new Connection( + static function () use (&$resolutions): PDO { + ++$resolutions; + + return new PDO('sqlite::memory:'); + }, + 'test' + ); + $grammar = m::mock(Grammar::class); + $connection->setSchemaGrammar($grammar); + + $grammar->shouldReceive('compileDisableForeignKeyConstraints')->once()->andReturn('disable constraints'); + $grammar->shouldReceive('compileEnableForeignKeyConstraints')->once()->andReturn('enable constraints'); + + $queries = $connection->pretend( + fn () => (new Builder($connection))->withoutForeignKeyConstraints(fn () => null) + ); + + $this->assertSame(0, $resolutions); + $this->assertSame( + ['disable constraints', 'enable constraints'], + array_column($queries, 'query') + ); + } + + public function testWithoutForeignKeyConstraintsMarksTheSessionUnknownWhenRestorationFails(): void + { + $pdo = m::mock(PDO::class); + $connection = new Connection($pdo, 'test'); + $grammar = m::mock(Grammar::class); + $connection->setSchemaGrammar($grammar); + + $grammar->shouldReceive('compileDisableForeignKeyConstraints')->once()->andReturn('disable constraints'); + $grammar->shouldReceive('compileEnableForeignKeyConstraints')->once()->andReturn('enable constraints'); + $pdo->shouldReceive('exec')->once()->with('disable constraints')->andReturn(0)->ordered(); + $pdo->shouldReceive('exec')->once()->with('enable constraints')->andReturnFalse()->ordered(); + + try { + (new Builder($connection))->withoutForeignKeyConstraints(fn () => null); + $this->fail('Expected foreign key constraint restoration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'Failed to execute schema statement [enable constraints].', + $exception->getMessage() + ); + } + + $this->assertTrue($connection->hasUnknownSessionState()); + $this->assertTrue($connection->beginForeignKeyConstraintSuppression()); + + $connection->endForeignKeyConstraintSuppression(); + } + + public function testWithoutForeignKeyConstraintsPreservesNativeExceptionChainingWhenCallbackAndRestorationFail(): void + { + $pdo = m::mock(PDO::class); + $connection = new Connection($pdo, 'test'); + $grammar = m::mock(Grammar::class); + $connection->setSchemaGrammar($grammar); + $callbackFailure = new RuntimeException('callback failed'); + $restorationFailure = new RuntimeException('restoration failed'); + + $grammar->shouldReceive('compileDisableForeignKeyConstraints')->once()->andReturn('disable constraints'); + $grammar->shouldReceive('compileEnableForeignKeyConstraints')->once()->andReturn('enable constraints'); + $pdo->shouldReceive('exec')->once()->with('disable constraints')->andReturn(0)->ordered(); + $pdo->shouldReceive('exec')->once()->with('enable constraints')->andThrow($restorationFailure)->ordered(); + + try { + (new Builder($connection))->withoutForeignKeyConstraints( + static fn () => throw $callbackFailure + ); + $this->fail('Expected foreign key constraint restoration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($restorationFailure, $exception); + $this->assertSame($callbackFailure, $exception->getPrevious()); + } + + $this->assertTrue($connection->hasUnknownSessionState()); + } + public function testHasTableCorrectlyCallsGrammar() { $connection = m::mock(Connection::class); diff --git a/tests/Integration/Database/MariaDb/DatabaseMariaDbSchemaBuilderTest.php b/tests/Integration/Database/MariaDb/DatabaseMariaDbSchemaBuilderTest.php index be4d8caf1..674353cc5 100644 --- a/tests/Integration/Database/MariaDb/DatabaseMariaDbSchemaBuilderTest.php +++ b/tests/Integration/Database/MariaDb/DatabaseMariaDbSchemaBuilderTest.php @@ -31,4 +31,36 @@ public function testAddCommentToTable() Schema::drop('users'); } + + public function testWithoutForeignKeyConstraintsPreservesIncomingStateAndNests(): void + { + $connection = DB::connection(); + $outer = $connection->getSchemaBuilder(); + $inner = $connection->getSchemaBuilder(); + + try { + $outer->enableForeignKeyConstraints(); + + $outer->withoutForeignKeyConstraints(function () use ($connection, $inner): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + + $inner->withoutForeignKeyConstraints(function () use ($connection): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(1, (int) $connection->scalar('select @@foreign_key_checks')); + + $outer->disableForeignKeyConstraints(); + $outer->withoutForeignKeyConstraints(function () use ($connection): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + } finally { + $outer->enableForeignKeyConstraints(); + } + } } diff --git a/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderTest.php b/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderTest.php index 7d70a0d19..d2b4dcd8c 100644 --- a/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderTest.php +++ b/tests/Integration/Database/MySql/DatabaseMySqlSchemaBuilderTest.php @@ -46,4 +46,36 @@ public function testGetRawIndex() $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); } + + public function testWithoutForeignKeyConstraintsPreservesIncomingStateAndNests(): void + { + $connection = DB::connection(); + $outer = $connection->getSchemaBuilder(); + $inner = $connection->getSchemaBuilder(); + + try { + $outer->enableForeignKeyConstraints(); + + $outer->withoutForeignKeyConstraints(function () use ($connection, $inner): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + + $inner->withoutForeignKeyConstraints(function () use ($connection): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(1, (int) $connection->scalar('select @@foreign_key_checks')); + + $outer->disableForeignKeyConstraints(); + $outer->withoutForeignKeyConstraints(function () use ($connection): void { + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + }); + + $this->assertSame(0, (int) $connection->scalar('select @@foreign_key_checks')); + } finally { + $outer->enableForeignKeyConstraints(); + } + } } diff --git a/tests/Integration/Database/PooledConnectionTest.php b/tests/Integration/Database/PooledConnectionTest.php index 5a5175321..088c0a026 100644 --- a/tests/Integration/Database/PooledConnectionTest.php +++ b/tests/Integration/Database/PooledConnectionTest.php @@ -606,6 +606,50 @@ public function testInvalidNormalConnectionReconnectsAndConfiguresAFreshPdo(): v } } + public function testLeakedForeignKeySuppressionScopeReconnectsANormalPoolWithoutAConfigurator(): void + { + $filesystem = new Filesystem; + $directory = ParallelTesting::tempDir('PooledConnectionTest-suppression-reconnect'); + $filesystem->deleteDirectory($directory); + $filesystem->ensureDirectoryExists($directory); + $databasePath = $directory . '/database.sqlite'; + touch($databasePath); + $this->app->make('config')->set('database.connections.suppression_reconnect_test', [ + 'driver' => 'sqlite', + 'database' => $databasePath, + 'prefix' => '', + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 1, + 'heartbeat' => -1, + ], + ]); + $pool = new DbPool($this->app, 'suppression_reconnect_test'); + $pooledConnection = null; + + try { + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $connection = $pooledConnection->getConnection(); + $oldPdo = $connection->getPdo(); + $connection->beginForeignKeyConstraintSuppression(); + $firstPooledConnection = $pooledConnection; + $pooledConnection->release(); + $pooledConnection = null; + + /** @var PooledConnection $pooledConnection */ + $pooledConnection = $pool->get(); + $newPdo = $pooledConnection->getConnection()->getPdo(); + + $this->assertSame($firstPooledConnection, $pooledConnection); + $this->assertNotSame($oldPdo, $newPdo); + } finally { + $pooledConnection?->release(); + $pool->close(); + $filesystem->deleteDirectory($directory); + } + } + public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid(): void { $filesystem = new Filesystem; @@ -676,10 +720,8 @@ public function testFailedRefreshPreservesTheCurrentGenerationAndMarksItInvalid( } } - public function testSharedInMemorySqliteUnknownRecoveryIsBoundedAndFailsClosed(): void + public function testSharedInMemorySqliteUnknownSessionFailsClosedWithoutDiscardingTheDatabase(): void { - $configurator = new PoolSessionConfigurator; - Connection::configureSessionUsing($configurator); $pool = new DbPool($this->app, 'pool_test'); /** @var PooledConnection $pooledConnection */ @@ -688,22 +730,15 @@ public function testSharedInMemorySqliteUnknownRecoveryIsBoundedAndFailsClosed() try { $connection = $pooledConnection->getConnection(); $sharedPdo = $connection->getPdo(); - $configurator->desiredState = 'fail'; - $configurator->applyCallback = static fn () => throw new Exception('Configuration failed.'); - - try { - $connection->getPdo(); - $this->fail('Expected configuration exception was not thrown.'); - } catch (Exception) { - } + $sharedPdo->exec('create table records (id integer primary key)'); + $sharedPdo->exec('insert into records (id) values (1)'); + $connection->beginForeignKeyConstraintSuppression(); $pooledConnection->release(); $pooledConnection = null; - $configurator->applyCallback = null; /** @var PooledConnection $pooledConnection */ $pooledConnection = $pool->get(); - $replacementConnection = $pooledConnection->getConnection(); $connectionEstablished = 0; $this->app->make(Dispatcher::class)->listen( ConnectionEstablished::class, @@ -713,15 +748,18 @@ static function () use (&$connectionEstablished): void { ); try { - $replacementConnection->getPdo(); + $pooledConnection->getConnection(); $this->fail('Expected unknown session exception was not thrown.'); } catch (RuntimeException $exception) { - $this->assertSame('Database session state remains unknown after reconnecting.', $exception->getMessage()); + $this->assertSame( + 'The shared in-memory SQLite database session is unknown and its sole connection cannot be replaced without discarding the database.', + $exception->getMessage() + ); } - $this->assertSame($sharedPdo, $replacementConnection->getRawPdo()); - $this->assertSame(1, $connectionEstablished); - $this->assertSame(2, $configurator->applyCalls); + $this->assertSame($sharedPdo, $pool->getSharedInMemorySqlitePdo()); + $this->assertSame(1, (int) $sharedPdo->query('select count(*) from records')->fetchColumn()); + $this->assertSame(0, $connectionEstablished); } finally { $pooledConnection?->release(); $pool->close(); From 336883d7ed78a92a4ab9c1193ad68037106f0b35 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:10:39 +0000 Subject: [PATCH 02/11] Make PostgreSQL Blueprint execution atomic Wrap supported multi-statement Blueprint operations in a database transaction while preserving caller-owned transactions, runtime grammar opt-outs, and framework command ordering. Keep online index operations unwrapped because PostgreSQL forbids concurrent index creation inside a transaction, and leave extension-defined compilers on the existing ordered execution path. Cover rollback, nested transaction ownership, every online index form, grammar extensions and overrides, raw compilation, and real PostgreSQL constraint-suppression nesting. --- src/database/src/Schema/PostgresBuilder.php | 40 +++++ .../Database/DatabasePostgresBuilderTest.php | 161 ++++++++++++++++++ .../Postgres/PostgresSchemaBuilderTest.php | 72 ++++++++ 3 files changed, 273 insertions(+) diff --git a/src/database/src/Schema/PostgresBuilder.php b/src/database/src/Schema/PostgresBuilder.php index 4df59e96a..45efe5821 100755 --- a/src/database/src/Schema/PostgresBuilder.php +++ b/src/database/src/Schema/PostgresBuilder.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Schema; use Hypervel\Database\Concerns\ParsesSearchPath; +use Hypervel\Database\Schema\Grammars\PostgresGrammar; use Override; /** @@ -14,6 +15,45 @@ class PostgresBuilder extends Builder { use ParsesSearchPath; + /** + * Execute the given schema blueprint. + */ + #[Override] + public function executeBlueprint(Blueprint $blueprint): void + { + $statements = $blueprint->toSql(); + + // CREATE INDEX CONCURRENTLY cannot run in a transaction block, so online lists stay unwrapped. + if (count($statements) > 1 + && $this->connection->transactionLevel() === 0 + && $this->grammar->supportsSchemaTransactions() + && $this->commandsAreDeclaredOn($blueprint, PostgresGrammar::class) + && ! $this->hasOnlineCommand($blueprint) + ) { + $this->connection->transaction( + fn () => $this->executeStatements($statements) + ); + + return; + } + + $this->executeStatements($statements); + } + + /** + * Determine whether the blueprint contains an online command. + */ + protected function hasOnlineCommand(Blueprint $blueprint): bool + { + foreach ($blueprint->getCommands() as $command) { + if (! $command->shouldBeSkipped && $command->online) { + return true; + } + } + + return false; + } + /** * Drop all tables from the database. */ diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index fc4ad975a..218c766c9 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -4,13 +4,17 @@ namespace Hypervel\Tests\Database; +use Closure; use Hypervel\Database\Connection; use Hypervel\Database\Query\Processors\PostgresProcessor; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Grammars\PostgresGrammar; use Hypervel\Database\Schema\PostgresBuilder; +use Hypervel\Support\Fluent; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use Override; class DatabasePostgresBuilderTest extends TestCase { @@ -44,6 +48,136 @@ public function testDropDatabaseIfExists() $builder->dropDatabaseIfExists('my_database_a'); } + public function testExecuteBlueprintWrapsKnownMultiStatementCommandsInATransaction(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + $blueprint = $this->executionBlueprint( + ['first statement', 'second statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotNestAnExistingTransaction(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + $blueprint = $this->executionBlueprint( + ['first statement', 'second statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotWrapASingleStatement(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + $blueprint = $this->executionBlueprint( + ['statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->never(); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('statement')->andReturnTrue(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotWrapOnlineCommands(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + $blueprint = $this->executionBlueprint( + ['create index concurrently', 'attach constraint'], + [new Fluent(['name' => 'unique', 'online' => true])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('create index concurrently')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('attach constraint')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotWrapCommandsAddedByAnExtensionGrammar(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresGrammar($connection); + $blueprint = $this->executionBlueprint( + ['extension statement one', 'extension statement two'], + [new Fluent(['name' => 'extensionCommand'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('extension statement one')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('extension statement two')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintWrapsFrameworkCommandsOverriddenByAnExtensionGrammar(): void + { + $connection = m::mock(Connection::class); + $grammar = new PostgresBuilderExtensionGrammar($connection); + $blueprint = $this->executionBlueprint( + ['overridden create', 'second statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('overridden create')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintHonorsTheRuntimeGrammarTransactionFlag(): void + { + $connection = m::mock(Connection::class); + $grammar = m::mock(PostgresGrammar::class, [$connection])->makePartial(); + $blueprint = $this->executionBlueprint( + ['first statement', 'second statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $grammar->shouldReceive('supportsSchemaTransactions')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new PostgresBuilder($connection))->executeBlueprint($blueprint); + } + public function testHasTableWhenSchemaUnqualifiedAndSearchPathMissing() { $connection = $this->getConnection(); @@ -328,8 +462,35 @@ protected function getConnection() return m::mock(Connection::class); } + /** + * Create a Blueprint double for execution-boundary tests. + * + * @param list $statements + * @param list $commands + */ + protected function executionBlueprint(array $statements, array $commands): Blueprint + { + $blueprint = m::mock(Blueprint::class); + $blueprint->shouldReceive('toSql')->once()->andReturn($statements); + $blueprint->shouldReceive('getCommands')->andReturn($commands); + + return $blueprint; + } + protected function getBuilder($connection) { return new PostgresBuilder($connection); } } + +class PostgresBuilderExtensionGrammar extends PostgresGrammar +{ + /** + * Compile a create table command. + */ + #[Override] + public function compileCreate(Blueprint $blueprint, Fluent $command): string + { + return 'overridden create'; + } +} diff --git a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php index e57aa426f..aaf964e2a 100644 --- a/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php +++ b/tests/Integration/Database/Postgres/PostgresSchemaBuilderTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Database\Postgres; use Hypervel\Contracts\Foundation\Application; +use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -179,6 +180,43 @@ public function testAddTableCommentOnExistingTable() $this->assertEquals('This is a new comment', DB::selectOne("select obj_description('public.posts'::regclass, 'pg_class')")->obj_description); } + public function testWithoutForeignKeyConstraintsNestsUntilTheOuterScopeRestoresImmediateChecks(): void + { + Schema::create('constraint_parents', function (Blueprint $table): void { + $table->id(); + }); + Schema::create('constraint_children', function (Blueprint $table): void { + $table->id(); + $table->foreignId('parent_id'); + $table->foreign('parent_id') + ->references('id') + ->on('constraint_parents') + ->deferrable() + ->initiallyImmediate(); + }); + + $connection = DB::connection(); + $outer = $connection->getSchemaBuilder(); + $inner = $connection->getSchemaBuilder(); + + $connection->transaction(function () use ($connection, $inner, $outer): void { + $outer->withoutForeignKeyConstraints(function () use ($connection, $inner): void { + $connection->table('constraint_children')->insert(['id' => 1, 'parent_id' => 1]); + + $inner->withoutForeignKeyConstraints(function () use ($connection): void { + $connection->table('constraint_children')->insert(['id' => 2, 'parent_id' => 2]); + }); + + $connection->table('constraint_parents')->insert([ + ['id' => 1], + ['id' => 2], + ]); + }); + }); + + $this->assertSame(2, $connection->table('constraint_children')->count()); + } + public function testGetTables() { Schema::create('public.table', function (Blueprint $table) { @@ -296,4 +334,38 @@ public function testCreateIndexesOnline() $this->assertContains('public_table_body_fulltext', $indexNames); $this->assertContains('table_raw_index', $indexNames); } + + public function testLateSchemaFailureRollsBackTheCompleteBlueprint(): void + { + try { + Schema::create('atomic_records', function (Blueprint $table): void { + $table->id(); + $table->rawIndex('(', 'invalid_index'); + }); + $this->fail('Expected the invalid index to fail.'); + } catch (QueryException) { + } + + $this->assertFalse(Schema::hasTable('atomic_records')); + } + + public function testOnlineIndexInsideATransactionRetainsTheNativeFailure(): void + { + Schema::create('transaction_records', function (Blueprint $table): void { + $table->id(); + $table->string('name'); + }); + + try { + DB::transaction(function (): void { + Schema::table('transaction_records', function (Blueprint $table): void { + $table->index('name')->online(); + }); + }); + $this->fail('Expected PostgreSQL to reject the online index inside a transaction.'); + } catch (QueryException) { + } + + $this->assertFalse(Schema::hasIndex('transaction_records', ['name'])); + } } From e58a04fec4d79787c437c471c11fcea843cea036 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:11:00 +0000 Subject: [PATCH 03/11] Preserve SQLite schema changes exactly and atomically Execute framework-owned multi-statement Blueprints inside guarded SQLite transactions while preserving foreign-key state, caller transactions, pretend mode, command order, and extension compiler behavior. Round-trip index identity and semantics through authoritative SQLite metadata, including expression and partial indexes, collations, descending order, constraint-backed indexes, comma-bearing identifiers, column renames, table options, and supported constraint clauses. Fail before mutation when SQLite metadata cannot reconstruct the original behavior safely. Replace live database-file truncation with guarded catalog cleanup, preserve views during table wipes, reload schema state safely across SQLite versions, and make explicit database-file refresh reject active transactions, in-memory databases, and WAL mode. Add focused unit and real-engine regressions for rollback, rebuild ordering, exact index and constraint behavior, stored definitions, foreign-key safety, writable-schema restoration, WAL and file handling, and every discovered data-integrity failure. --- .../src/Query/Processors/SQLiteProcessor.php | 59 +- src/database/src/Schema/BlueprintState.php | 111 ++- .../src/Schema/Grammars/SQLiteGrammar.php | 283 +++++- src/database/src/Schema/SQLiteBuilder.php | 289 ++++++- tests/Database/DatabaseSQLiteBuilderTest.php | 769 ++++++++++++++++- .../Database/DatabaseSQLiteProcessorTest.php | 181 ++++ .../DatabaseSQLiteSchemaGrammarTest.php | 29 +- .../Sqlite/DatabaseSchemaBlueprintTest.php | 814 ++++++++++++++++++ .../DatabaseSqliteSchemaBuilderTest.php | 292 ++++++- 9 files changed, 2741 insertions(+), 86 deletions(-) diff --git a/src/database/src/Query/Processors/SQLiteProcessor.php b/src/database/src/Query/Processors/SQLiteProcessor.php index 16ae90d07..111b93a83 100644 --- a/src/database/src/Query/Processors/SQLiteProcessor.php +++ b/src/database/src/Query/Processors/SQLiteProcessor.php @@ -4,7 +4,9 @@ namespace Hypervel\Database\Query\Processors; +use Hypervel\Support\Arr; use Override; +use UnexpectedValueException; class SQLiteProcessor extends Processor { @@ -57,6 +59,23 @@ public function processColumns(array $results, string $sql = ''): array #[Override] public function processIndexes(array $results): array + { + return array_map( + static fn (array $index): array => Arr::only( + $index, + ['name', 'columns', 'type', 'unique', 'primary'], + ), + $this->processIndexesForSchemaState($results), + ); + } + + /** + * Process indexes with the metadata required to reconstruct SQLite schema state. + * + * @internal + * @return list, type: null|string, unique: bool, primary: bool, sql: null|string, origin: null|string, reconstructible: bool, collations: null|list, descending: null|list}> + */ + public function processIndexesForSchemaState(array $results): array { $primaryCount = 0; @@ -69,10 +88,23 @@ public function processIndexes(array $results): array return [ 'name' => strtolower($result->name), - 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'physical_name' => (string) $result->name, + 'columns' => $this->decodeHexList($result->columns), 'type' => null, 'unique' => (bool) $result->unique, 'primary' => $isPrimary, + 'sql' => $result->sql, + 'origin' => $result->origin, + 'reconstructible' => (bool) $result->reconstructible, + 'collations' => is_null($result->collations) + ? null + : $this->decodeHexList($result->collations), + 'descending' => is_null($result->descending) + ? null + : array_map( + static fn (string $value): bool => $value === '1', + explode(',', $result->descending), + ), ]; }, $results); @@ -80,7 +112,30 @@ public function processIndexes(array $results): array $indexes = array_filter($indexes, fn ($index) => $index['name'] !== 'primary'); } - return $indexes; + return array_values($indexes); + } + + /** + * Decode a comma-separated list of hexadecimal SQLite schema values. + * + * @return list + */ + protected function decodeHexList(?string $values): array + { + if (is_null($values) || $values === '') { + return []; + } + + return array_map(static function (string $value): string { + if (strlen($value) % 2 !== 0 || ! ctype_xdigit($value)) { + throw new UnexpectedValueException('The SQLite schema metadata contains invalid hexadecimal text.'); + } + + /** @var string $decoded */ + $decoded = hex2bin($value); + + return $decoded; + }, explode(',', $values)); } #[Override] diff --git a/src/database/src/Schema/BlueprintState.php b/src/database/src/Schema/BlueprintState.php index 93b37ee40..31b24ff05 100644 --- a/src/database/src/Schema/BlueprintState.php +++ b/src/database/src/Schema/BlueprintState.php @@ -29,6 +29,11 @@ class BlueprintState */ private array $columns; + /** + * The stored table definition. + */ + private string $tableSql; + /** * The primary key. */ @@ -56,10 +61,13 @@ public function __construct(Blueprint $blueprint, Connection $connection) $this->blueprint = $blueprint; $this->connection = $connection; + /** @var SQLiteBuilder $schema */ $schema = $connection->getSchemaBuilder(); $table = $blueprint->getTable(); + $columnState = $schema->getColumnsForSchemaState($table); + $this->tableSql = $columnState['sql']; - $this->columns = (new Collection($schema->getColumns($table)))->map(fn ($column) => new ColumnDefinition([ + $this->columns = (new Collection($columnState['columns']))->map(fn ($column) => new ColumnDefinition([ 'name' => $column['name'], 'type' => $column['type_name'], 'full_type_definition' => $column['type'], @@ -76,15 +84,36 @@ public function __construct(Blueprint $blueprint, Connection $connection) : null, ]))->all(); - [$primary, $indexes] = (new Collection($schema->getIndexes($table)))->map(fn ($index) => new IndexDefinition([ - 'name' => match (true) { - $index['primary'] => 'primary', - $index['unique'] => 'unique', - default => 'index', - }, - 'index' => $index['name'], - 'columns' => $index['columns'], - ]))->partition(fn ($index) => $index->name === 'primary'); + $columnCollations = []; + + foreach ($this->columns as $column) { + $columnCollations[$column->name] = $column->collation ?? 'BINARY'; + } + + [$primary, $indexes] = (new Collection($schema->getIndexesForSchemaState($table)))->map( + fn ($index) => new IndexDefinition([ + 'name' => match (true) { + $index['primary'] => 'primary', + $index['unique'] => 'unique', + default => 'index', + }, + 'index' => $index['physical_name'], + 'columns' => $index['columns'], + 'existing' => true, + 'origin' => $index['origin'], + 'storedSql' => $index['sql'], + 'reconstructible' => $index['reconstructible'], + 'collations' => $index['collations'], + 'columnCollations' => array_map( + static fn (string $column): ?string => $columnCollations[$column] ?? null, + $index['columns'], + ), + 'descending' => $index['descending'], + 'renamed' => false, + 'columnRenamed' => false, + 'columnDropped' => false, + ]) + )->partition(fn ($index) => $index->name === 'primary'); $this->indexes = $indexes->all(); $this->primaryKey = $primary->first(); @@ -121,6 +150,14 @@ public function getColumns(): array return $this->columns; } + /** + * Get the stored table definition. + */ + public function getTableSql(): string + { + return $this->tableSql; + } + /** * Get the indexes. * @@ -171,21 +208,34 @@ public function update(Fluent $command): void } if ($this->primaryKey) { - $this->primaryKey->columns = str_replace($command->from, $command->to, $this->primaryKey->columns); + $this->primaryKey->columns = $this->replaceColumn( + $this->primaryKey->columns, + $command->from, + $command->to, + ); } foreach ($this->indexes as $index) { - $index->columns = str_replace($command->from, $command->to, $index->columns); + $index->columnRenamed = true; + $index->columns = $this->replaceColumn($index->columns, $command->from, $command->to); } foreach ($this->foreignKeys as $foreignKey) { - $foreignKey->columns = str_replace($command->from, $command->to, $foreignKey->columns); + $foreignKey->columns = $this->replaceColumn( + $foreignKey->columns, + $command->from, + $command->to, + ); } break; case 'dropColumn': + foreach ($this->indexes as $index) { + $index->columnDropped = true; + } + $this->columns = array_values( - array_filter($this->columns, fn ($column) => ! in_array($column->name, $command->columns)) + array_filter($this->columns, fn ($column) => ! in_array($column->name, $command->columns, true)) ); break; @@ -194,13 +244,28 @@ public function update(Fluent $command): void break; case 'unique': case 'index': + $command->existing = false; + $command->origin = null; + $command->storedSql = null; + $command->reconstructible = array_all( + $command->columns, + static fn (mixed $column): bool => is_string($column), + ); + $command->collations = null; + $command->columnCollations = null; + $command->descending = null; + $command->renamed = false; + $command->columnRenamed = false; + $command->columnDropped = false; + // @phpstan-ignore assign.propertyType (Blueprint commands are Fluent, stored as IndexDefinition) $this->indexes[] = $command; break; case 'renameIndex': foreach ($this->indexes as $index) { - if ($index->index === $command->from) { + if (strcasecmp($index->index, $command->from) === 0) { $index->index = $command->to; + $index->renamed = true; break; } } @@ -216,7 +281,7 @@ public function update(Fluent $command): void case 'dropIndex': case 'dropUnique': $this->indexes = array_values( - array_filter($this->indexes, fn ($index) => $index->index !== $command->index) + array_filter($this->indexes, fn ($index) => strcasecmp($index->index, $command->index) !== 0) ); break; @@ -228,4 +293,18 @@ public function update(Fluent $command): void break; } } + + /** + * Replace an exact column name in a projection. + * + * @param list $columns + * @return list + */ + protected function replaceColumn(array $columns, string $from, string $to): array + { + return array_map( + static fn (Expression|string $column): Expression|string => $column === $from ? $to : $column, + $columns, + ); + } } diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index 64ed83263..e3344e7f7 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -7,6 +7,7 @@ use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\IndexDefinition; +use Hypervel\Database\Schema\SQLiteBuilder; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; @@ -161,17 +162,27 @@ public function compileColumns(?string $schema, string $table): string */ public function compileIndexes(?string $schema, string $table): string { + $schema ??= 'main'; + $quotedTable = $this->quoteString($table); + $quotedSchema = $this->quoteString($schema); + return sprintf( - 'select \'primary\' as name, group_concat(col) as columns, 1 as "unique", 1 as "primary" ' + 'select \'primary\' as name, group_concat(hex(col)) as columns, 1 as "unique", 1 as "primary", null as sql, \'pk\' as origin, 1 as reconstructible, null as collations, null as "descending" ' . 'from (select name as col from pragma_table_xinfo(%s, %s) where pk > 0 order by pk, cid) group by name ' - . 'union select name, group_concat(col) as columns, "unique", origin = \'pk\' as "primary" ' - . 'from (select il.*, ii.name as col from pragma_index_list(%s, %s) il, pragma_index_info(il.name, %s) ii order by il.seq, ii.seqno) ' - . 'group by name, "unique", "primary"', - $table = $this->quoteString($table), - $schema = $this->quoteString($schema ?? 'main'), - $table, - $schema, - $schema + . 'union all select name, case when count(*) = count(col) then group_concat(col) end as columns, "unique", origin = \'pk\' as "primary", sql, origin, not partial and min(simple) as reconstructible, ' + . 'case when count(*) = count(col) then group_concat(collation) end as collations, ' + . 'case when count(*) = count(col) then group_concat("descending") end as "descending" ' + . 'from (select il.*, case when ii.name is not null then hex(ii.name) end as col, hex(ii.coll) as collation, ii."desc" as "descending", ' + . 'ii.name is not null and ii."desc" = 0 and lower(ii.coll) = \'binary\' as simple, ' + . '(select sql from %s.sqlite_master where type = \'index\' and name = il.name) as sql ' + . 'from pragma_index_list(%s, %s) il left join pragma_index_xinfo(il.name, %s) ii on ii.key = 1 order by il.seq, ii.seqno) ' + . 'group by name, "unique", "primary", sql, origin, partial', + $quotedTable, + $quotedSchema, + $this->wrapValue($schema), + $quotedTable, + $quotedSchema, + $quotedSchema ); } @@ -256,12 +267,35 @@ protected function getForeignKey(Fluent $foreign): string protected function addPrimaryKeys(?Fluent $primary): ?string { if (! is_null($primary)) { - return ", primary key ({$this->columnize($primary->columns)})"; + return ", primary key ({$this->columnizeIndexedColumns($primary)})"; } return null; } + /** + * Convert indexed columns and their SQLite attributes to SQL. + */ + protected function columnizeIndexedColumns(Fluent $index): string + { + return implode(', ', array_map(function (mixed $column, int $offset) use ($index): string { + $sql = $this->wrap($column); + $collation = $index->collations[$offset] ?? null; + $columnCollation = $index->columnCollations[$offset] ?? null; + + if (is_string($collation) + && (! is_string($columnCollation) || strcasecmp($collation, $columnCollation) !== 0)) { + $sql .= ' collate ' . $this->wrapValue($collation); + } + + if (($index->descending[$offset] ?? false) === true) { + $sql .= ' desc'; + } + + return $sql; + }, $index->columns, array_keys($index->columns))); + } + /** * Compile alter table commands for adding columns. */ @@ -281,10 +315,15 @@ public function compileAdd(Blueprint $blueprint, Fluent $command): string */ public function compileAlter(Blueprint $blueprint, Fluent $command): array { + $scannableTableSql = $this->tableSqlForScanning($blueprint->getState()->getTableSql()); + $this->ensureTableCanBeRebuilt($blueprint, $scannableTableSql); + $tableOptions = $this->compileTableOptions($scannableTableSql); + $stateColumns = $blueprint->getState()->getColumns(); + $definedColumnNames = array_map(fn (Fluent $column) => $column->name, $stateColumns); $columnNames = []; $autoIncrementColumn = null; - $columns = (new Collection($blueprint->getState()->getColumns())) + $columns = (new Collection($stateColumns)) ->map(function ($column) use ($blueprint, &$columnNames, &$autoIncrementColumn) { $name = $this->wrap($column); @@ -302,9 +341,31 @@ public function compileAlter(Blueprint $blueprint, Fluent $command): array ); })->all(); + $inlineUniqueConstraints = []; + $indexes = (new Collection($blueprint->getState()->getIndexes())) - ->reject(fn ($index) => str_starts_with('sqlite_', $index->index)) - ->map(fn ($index) => $this->{'compile' . ucfirst($index->name)}($blueprint, $index)) + ->map(function (Fluent $index) use ($blueprint, $definedColumnNames, &$inlineUniqueConstraints) { + $projectedColumns = array_filter( + $index->columns, + static fn (mixed $column): bool => is_string($column), + ); + + if (! empty(array_diff($projectedColumns, $definedColumnNames))) { + throw new RuntimeException( + "Cannot rebuild table [{$blueprint->getTable()}] because index [{$index->index}] references a dropped column." + ); + } + + if ($index->existing && $index->origin === 'u' && is_null($index->storedSql)) { + $inlineUniqueConstraints[] = 'unique (' . $this->columnizeIndexedColumns($index) . ')'; + + return null; + } + + return $this->compileRebuiltIndex($blueprint, $index); + }) + ->filter(fn (?string $sql) => ! is_null($sql)) + ->values() ->all(); [, $tableName] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); @@ -317,11 +378,12 @@ public function compileAlter(Blueprint $blueprint, Fluent $command): array return array_filter(array_merge([ $foreignKeyConstraintsEnabled ? $this->compileDisableForeignKeyConstraints() : null, sprintf( - 'create table %s (%s%s%s)', + 'create table %s (%s%s%s)%s', $tempTable, - implode(', ', $columns), + implode(', ', array_merge($columns, $inlineUniqueConstraints)), $this->addForeignKeys($blueprint->getState()->getForeignKeys()), - $autoIncrementColumn ? '' : $this->addPrimaryKeys($blueprint->getState()->getPrimaryKey()) + $autoIncrementColumn ? '' : $this->addPrimaryKeys($blueprint->getState()->getPrimaryKey()), + $tableOptions, ), sprintf('insert into %s (%s) select %s from %s', $tempTable, $columnNames, $columnNames, $table), sprintf('drop table %s', $table), @@ -329,6 +391,141 @@ public function compileAlter(Blueprint $blueprint, Fluent $command): array ], $indexes, [$foreignKeyConstraintsEnabled ? $this->compileEnableForeignKeyConstraints() : null])); } + /** + * Remove quoted values, identifiers, and comments before scanning stored table SQL. + */ + protected function tableSqlForScanning(string $sql): string + { + /** @var string $scannableSql */ + $scannableSql = preg_replace( + '~\'(?:\'\'|[^\'])*\'|"(?:""|[^"])*"|`(?:``|[^`])*`|\[(?:\]\]|[^\]])*\]|--[^\r\n]*|/\*.*?(?:\*/|\z)~s', + ' ', + $sql, + ); + + return $scannableSql; + } + + /** + * Ensure the stored table definition can be reconstructed without semantic loss. + */ + protected function ensureTableCanBeRebuilt(Blueprint $blueprint, string $sql): void + { + $unsupportedClauses = [ + 'CHECK constraint' => '/\bcheck\s*\(/i', + 'ON CONFLICT clause' => '/\bon\s+conflict\s+(?:rollback|fail|ignore|replace)\b/i', + ]; + + foreach ($unsupportedClauses as $clause => $pattern) { + if (preg_match($pattern, $sql) === 1) { + throw new RuntimeException( + "Cannot rebuild table [{$blueprint->getTable()}] because the {$clause} in its stored definition cannot be reconstructed." + ); + } + } + + if ($blueprint->getState()->getForeignKeys() !== [] + && preg_match( + // Exempt the NOT form because it has the same behavior as SQLite's default. + '/\bnot\s+deferrable\s+initially\s+deferred\b(*SKIP)(*F)|\bdeferrable\s+initially\s+deferred\b/i', + $sql, + ) === 1) { + throw new RuntimeException( + "Cannot rebuild table [{$blueprint->getTable()}] because the DEFERRABLE INITIALLY DEFERRED clause in its stored definition cannot be reconstructed." + ); + } + } + + /** + * Compile table options retained from the stored definition. + */ + protected function compileTableOptions(string $sql): string + { + if (preg_match( + '/\)\s*(?(?:without\s+rowid|strict)(?:\s*,\s*(?:without\s+rowid|strict))*)\s*\z/i', + $sql, + $matches, + ) !== 1) { + return ''; + } + + $options = []; + + if (preg_match('/\bwithout\s+rowid\b/i', $matches['options']) === 1) { + $options[] = 'without rowid'; + } + + if (preg_match('/\bstrict\b/i', $matches['options']) === 1) { + $options[] = 'strict'; + } + + return ' ' . implode(', ', $options); + } + + /** + * Compile an index retained across a table rebuild. + */ + protected function compileRebuiltIndex(Blueprint $blueprint, Fluent $index): string + { + if ($index->reconstructible) { + return $this->{'compile' . ucfirst($index->name)}($blueprint, $index); + } + + if ($index->columnRenamed) { + throw new RuntimeException( + "Cannot safely rebuild index [{$index->index}] across a column rename in the same blueprint. Move the rename after the rebuild-triggering command or use a separate Schema::table() call." + ); + } + + if ($index->columnDropped + && version_compare($this->connection->getServerVersion(), '3.35', '<')) { + throw new RuntimeException( + "Cannot safely rebuild index [{$index->index}] while dropping a column on this SQLite version. Drop the index first and recreate it after the column change." + ); + } + + if ($index->existing && ! is_null($index->storedSql)) { + return $this->compileStoredIndex( + $blueprint, + $index->storedSql, + $index->renamed ? $index->index : null, + ); + } + + if ($index->existing) { + throw new RuntimeException( + "Cannot rebuild index [{$index->index}] without its stored definition." + ); + } + + return $this->{'compile' . ucfirst($index->name)}($blueprint, $index); + } + + /** + * Compile a stored index definition for the target schema and name. + */ + protected function compileStoredIndex(Blueprint $blueprint, string $sql, ?string $name = null): string + { + $identifier = '(?:"(?:[^"]|"")*"|\x60(?:[^\x60]|\x60\x60)*\x60|\[(?:[^\]]|\]\])*\]|\'(?:[^\']|\'\')*\'|[^\s.]+)'; + // sqlite_schema normalizes the header and removes IF NOT EXISTS and schema qualifiers. + $pattern = '/\A(?CREATE (?:UNIQUE )?INDEX )(?' . $identifier . ')(?\s+(?i:ON)\s+.+)\z/s'; + + if (preg_match($pattern, $sql, $matches) !== 1) { + throw new RuntimeException('Cannot rebuild an index with an unrecognized stored definition.'); + } + + [$schema] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + if (is_null($schema) && is_null($name)) { + return $sql; + } + + return $matches['prefix'] + . (is_null($schema) ? '' : $this->wrapValue($schema) . '.') + . (is_null($name) ? $matches['name'] : $this->wrapValue($name)) + . $matches['suffix']; + } + #[Override] public function compileChange(Blueprint $blueprint, Fluent $command): array|string { @@ -357,7 +554,7 @@ public function compileUnique(Blueprint $blueprint, Fluent $command): string $schema ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), - $this->columnize($command->columns) + $this->columnizeIndexedColumns($command) ); } @@ -373,7 +570,7 @@ public function compileIndex(Blueprint $blueprint, Fluent $command): string $schema ? $this->wrapValue($schema) . '.' : '', $this->wrap($command->index), $this->wrapTable($table), - $this->columnize($command->columns) + $this->columnizeIndexedColumns($command) ); } @@ -532,13 +729,20 @@ public function compileRename(Blueprint $blueprint, Fluent $command): string /** * Compile a rename index command. * + * @return list * @throws RuntimeException */ public function compileRenameIndex(Blueprint $blueprint, Fluent $command): array { - $indexes = $this->connection->getSchemaBuilder()->getIndexes($blueprint->getTable()); + /** @var SQLiteBuilder $schema */ + $schema = $this->connection->getSchemaBuilder(); + + $indexes = $schema->getIndexesForSchemaState($blueprint->getTable()); - $index = Arr::first($indexes, fn ($index) => $index['name'] === $command->from); + $index = Arr::first( + $indexes, + fn ($index) => strcasecmp($index['physical_name'], $command->from) === 0, + ); if (! $index) { throw new RuntimeException("Index [{$command->from}] does not exist."); @@ -548,22 +752,47 @@ public function compileRenameIndex(Blueprint $blueprint, Fluent $command): array throw new RuntimeException('SQLite does not support altering primary keys.'); } - if ($index['unique']) { + if (is_null($index['sql'])) { + throw new RuntimeException( + "SQLite cannot rename the index [{$index['physical_name']}] because it backs a unique constraint. Change the table constraint instead." + ); + } + + if ($index['reconstructible']) { + $columnCollations = []; + + foreach ($schema->getColumnsForSchemaState($blueprint->getTable())['columns'] as $column) { + $columnCollations[$column['name']] = $column['collation'] ?? 'BINARY'; + } + + $definition = new IndexDefinition([ + 'index' => $command->to, + 'columns' => $index['columns'], + 'collations' => $index['collations'], + 'columnCollations' => array_map( + static fn (string $column): ?string => $columnCollations[$column] ?? null, + $index['columns'], + ), + 'descending' => $index['descending'], + ]); + return [ - $this->compileDropUnique($blueprint, new IndexDefinition(['index' => $index['name']])), - $this->compileUnique( + $this->compileDropIndex( $blueprint, - new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + new IndexDefinition(['index' => $index['physical_name']]), ), + $index['unique'] + ? $this->compileUnique($blueprint, $definition) + : $this->compileIndex($blueprint, $definition), ]; } return [ - $this->compileDropIndex($blueprint, new IndexDefinition(['index' => $index['name']])), - $this->compileIndex( + $this->compileDropIndex( $blueprint, - new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + new IndexDefinition(['index' => $index['physical_name']]), ), + $this->compileStoredIndex($blueprint, $index['sql'], $command->to), ]; } diff --git a/src/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php index f6c085a8c..17470d400 100644 --- a/src/database/src/Schema/SQLiteBuilder.php +++ b/src/database/src/Schema/SQLiteBuilder.php @@ -4,19 +4,76 @@ namespace Hypervel\Database\Schema; +use Hypervel\Database\Query\Processors\SQLiteProcessor; use Hypervel\Database\QueryException; +use Hypervel\Database\Schema\Grammars\SQLiteGrammar; use Hypervel\Database\SQLiteDatabase; use Hypervel\Support\Arr; use Hypervel\Support\Facades\File; use InvalidArgumentException; use Override; use RuntimeException; +use Throwable; /** * @property \Hypervel\Database\Schema\Grammars\SQLiteGrammar $grammar */ class SQLiteBuilder extends Builder { + /** + * Execute the given schema blueprint. + */ + #[Override] + public function executeBlueprint(Blueprint $blueprint): void + { + $statements = $blueprint->toSql(); + + // SQLite cannot wrap a whole migration because foreign-key pragmas must run + // outside transactions. This narrower boundary audits one known Blueprint. + if (count($statements) < 2 + || $this->connection->pretending() + || ! $this->commandsAreDeclaredOn($blueprint, SQLiteGrammar::class) + ) { + $this->executeStatements($statements); + + return; + } + + [$statements, $rebuildRequiresForeignKeySuppression] = $this->withoutForeignKeyGuardStatements($statements); + + if ($this->connection->transactionLevel() > 0) { + if ($rebuildRequiresForeignKeySuppression && $this->tableHasRows($blueprint)) { + throw new RuntimeException( + "SQLite cannot rebuild the populated table [{$blueprint->getTable()}] while foreign key constraints are enabled within an active transaction." + ); + } + + $this->connection->transaction( + fn () => $this->executeStatements($statements) + ); + + return; + } + + if (! $rebuildRequiresForeignKeySuppression) { + $this->connection->transaction( + fn () => $this->executeStatements($statements) + ); + + return; + } + + $this->executeSessionStatement($this->grammar->compileDisableForeignKeyConstraints()); + + try { + $this->connection->transaction( + fn () => $this->executeStatements($statements) + ); + } finally { + $this->executeSessionStatement($this->grammar->compileEnableForeignKeyConstraints()); + } + } + /** * Create a database in the schema. */ @@ -99,14 +156,52 @@ public function getViews(array|string|null $schema = null): array #[Override] public function getColumns(string $table): array + { + return $this->getColumnsForSchemaState($table)['columns']; + } + + /** + * Get the columns and stored table definition used to reconstruct SQLite schema state. + * + * @internal + * @return array{columns: list, sql: string} + */ + public function getColumnsForSchemaState(string $table): array { [$schema, $table] = $this->parseSchemaAndTable($table); $table = $this->connection->getTablePrefix() . $table; + $columns = $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)); + $sql = $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table)) ?? ''; + + return [ + 'columns' => $this->connection->getPostProcessor()->processColumns( + $columns, + $sql, + ), + 'sql' => $sql, + ]; + } - return $this->connection->getPostProcessor()->processColumns( - $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)), - $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table)) ?? '' + /** + * Get the indexes used to reconstruct SQLite schema state. + * + * @internal + * @return list, type: null|string, unique: bool, primary: bool, sql: null|string, origin: null|string, reconstructible: bool, collations: null|list, descending: null|list}> + */ + public function getIndexesForSchemaState(string $table): array + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix() . $table; + + /** @var SQLiteProcessor $processor */ + $processor = $this->connection->getPostProcessor(); + + return $processor->processIndexesForSchemaState( + $this->connection->selectFromWriteConnection( + $this->grammar->compileIndexes($schema, $table) + ) ); } @@ -116,22 +211,10 @@ public function getColumns(string $table): array #[Override] public function dropAllTables(): void { - $databases = array_column($this->getSchemas(), 'path', 'name'); + $this->ensureNoActiveTransaction('drop all tables'); foreach ($this->getCurrentSchemaListing() as $schema) { - $database = $databases[$schema] ?? null; - - if (is_string($database) && $database !== '') { - $this->refreshDatabaseFile($database); - } else { - $this->pragma('writable_schema', 1); - - $this->connection->statement($this->grammar->compileDropAllTables($schema)); - - $this->pragma('writable_schema', 0); - - $this->connection->statement($this->grammar->compileRebuild($schema)); - } + $this->dropSchemaObjects($schema, $this->grammar->compileDropAllTables($schema)); } } @@ -141,14 +224,48 @@ public function dropAllTables(): void #[Override] public function dropAllViews(): void { + $this->ensureNoActiveTransaction('drop all views'); + foreach ($this->getCurrentSchemaListing() as $schema) { - $this->pragma('writable_schema', 1); + $this->dropSchemaObjects($schema, $this->grammar->compileDropAllViews($schema)); + } + } - $this->connection->statement($this->grammar->compileDropAllViews($schema)); + /** + * Drop schema objects and reload SQLite's schema cache. + */ + protected function dropSchemaObjects(string $schema, string $statement): void + { + $writableSchemaEnabled = (bool) $this->pragma('writable_schema'); + $supportsReset = version_compare($this->connection->getServerVersion(), '3.37.0', '>='); - $this->pragma('writable_schema', 0); + try { + if (! $writableSchemaEnabled) { + $this->executeSessionStatement($this->grammar->pragma('writable_schema', 1)); + } - $this->connection->statement($this->grammar->compileRebuild($schema)); + try { + $this->executeStatements([$statement]); + } finally { + $this->executeSessionStatement($this->grammar->pragma( + 'writable_schema', + $supportsReset ? 'RESET' : 0 + )); + } + + try { + $this->executeStatements([$this->grammar->compileRebuild($schema)]); + } catch (Throwable $exception) { + if (! $supportsReset) { + $this->connection->markCurrentSessionStateUnknown(); + } + + throw $exception; + } + } finally { + if ($writableSchemaEnabled) { + $this->executeSessionStatement($this->grammar->pragma('writable_schema', 1)); + } } } @@ -164,16 +281,144 @@ public function pragma(string $key, mixed $value = null): mixed /** * Empty the database file. + * + * The caller must ensure that no other connection is using the target database file. */ public function refreshDatabaseFile(?string $path = null): void { - $path ??= $this->connection->getDatabaseName(); + if ($path === null) { + $this->ensureNoActiveTransaction('refresh the database file'); + + $database = $this->connection->getDatabaseName(); + + if (SQLiteDatabase::isInMemory($database)) { + throw new InvalidArgumentException( + "SQLite database management requires a plain filesystem path; [{$database}] is not supported." + ); + } + + if ($this->pragma('journal_mode') === 'wal') { + throw new RuntimeException( + 'SQLite database files cannot be refreshed through a connection using WAL journal mode. Use dropAllTables() to empty a database while connections are using it.' + ); + } + + $path = array_column($this->getSchemas(), 'path', 'name')['main'] ?? null; + + if (! is_string($path) || $path === '') { + throw new RuntimeException('Unable to resolve the SQLite database file path.'); + } + } + + $this->validateDatabasePath($path); if (File::put($path, '') === false) { throw new RuntimeException("Unable to refresh SQLite database file [{$path}]."); } } + /** + * Ensure a schema operation is not running within a transaction. + */ + protected function ensureNoActiveTransaction(string $operation): void + { + if ($this->connection->transactionLevel() > 0) { + throw new RuntimeException("SQLite cannot {$operation} within an active transaction."); + } + } + + /** + * Enable foreign key constraints. + */ + #[Override] + public function enableForeignKeyConstraints(): bool + { + $this->ensureForeignKeyConstraintsCanBeChanged(); + + return parent::enableForeignKeyConstraints(); + } + + /** + * Disable foreign key constraints. + */ + #[Override] + public function disableForeignKeyConstraints(): bool + { + $this->ensureForeignKeyConstraintsCanBeChanged(); + + return parent::disableForeignKeyConstraints(); + } + + /** + * Determine whether foreign key constraints are enabled. + */ + #[Override] + protected function foreignKeyConstraintsAreEnabled(): bool + { + return (bool) $this->pragma('foreign_keys'); + } + + /** + * Set the foreign key constraint state for an internal suppression scope. + */ + #[Override] + protected function setForeignKeyConstraints(bool $enabled): void + { + $this->ensureForeignKeyConstraintsCanBeChanged(); + + parent::setForeignKeyConstraints($enabled); + } + + /** + * Ensure foreign key constraints can be changed on this connection. + */ + protected function ensureForeignKeyConstraintsCanBeChanged(): void + { + if ($this->connection->transactionLevel() > 0) { + throw new RuntimeException( + 'SQLite foreign key constraints cannot be enabled or disabled within an active transaction.' + ); + } + } + + /** + * Remove the rebuild-only foreign-key guard statements. + * + * @param list $statements + * @return array{list, bool} + */ + protected function withoutForeignKeyGuardStatements(array $statements): array + { + $disable = $this->grammar->compileDisableForeignKeyConstraints(); + $enable = $this->grammar->compileEnableForeignKeyConstraints(); + $requiresForeignKeySuppression = false; + + $statements = array_values(array_filter( + $statements, + function (string $statement) use ($disable, $enable, &$requiresForeignKeySuppression): bool { + if ($statement !== $disable && $statement !== $enable) { + return true; + } + + $requiresForeignKeySuppression = true; + + return false; + } + )); + + return [$statements, $requiresForeignKeySuppression]; + } + + /** + * Determine whether the Blueprint's table contains any rows. + */ + protected function tableHasRows(Blueprint $blueprint): bool + { + return (bool) $this->connection->scalar( + 'select exists (select 1 from ' . $this->grammar->wrapTable($blueprint) . ' limit 1)' + ); + } + /** * Get the names of current schemas for the connection. */ diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php index 68a7088e9..bb0411ac8 100644 --- a/tests/Database/DatabaseSQLiteBuilderTest.php +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -4,13 +4,19 @@ namespace Hypervel\Tests\Database; +use Closure; use Hypervel\Database\Connection; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Database\Schema\Grammars\SQLiteGrammar; use Hypervel\Database\Schema\SQLiteBuilder; +use Hypervel\Database\SQLiteConnection; use Hypervel\Support\Facades\File; +use Hypervel\Support\Fluent; use Hypervel\Testbench\TestCase; use InvalidArgumentException; +use LogicException; use Mockery as m; +use PDO; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; @@ -116,20 +122,697 @@ public static function nonFileDatabaseNames(): array ]; } - public function testDropAllTablesRefreshesTheCanonicalAttachedDatabasePath(): void + public function testExecuteBlueprintWrapsAKnownRebuildAndMaintainsForeignKeyStateOutsideTheTransaction(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['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('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(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintPreservesDisabledForeignKeysOutsideTheTransaction(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['first statement', 'second statement'], + [new Fluent(['name' => 'alter'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getPdo')->never(); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotWrapASingleStatement(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['statement'], + [new Fluent(['name' => 'create'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->never(); + $connection->shouldReceive('transactionLevel')->never(); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('statement')->andReturnTrue(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotWrapCommandsAddedByAnExtensionGrammar(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteBuilderExtensionGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->twice()->andReturn($grammar); + $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->never(); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->once()->with('extension statement one')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('extension statement two')->andReturnTrue()->ordered(); + + $blueprint = new SQLiteBuilderExtensionBlueprint( + $connection, + 'users', + fn (SQLiteBuilderExtensionBlueprint $table) => $table->extensionCommand(), + ); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintWrapsFrameworkCommandsOverriddenByAnExtensionGrammar(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteBuilderExtensionGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->twice()->andReturn($grammar); + $connection->shouldReceive('getServerVersion')->once()->andReturn('3.45.0'); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('overridden alter')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + $blueprint = new SQLiteBuilderExtensionBlueprint( + $connection, + 'users', + fn (SQLiteBuilderExtensionBlueprint $table) => $table->frameworkAlter(), + ); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintRestoresForeignKeysWhenTheTransactionFails(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['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('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()) + ->ordered(); + $connection->shouldReceive('statement') + ->once() + ->with('failing statement') + ->andThrow(new LogicException('statement failed')) + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 1')->andReturn(0)->ordered(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('statement failed'); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintMarksTheSessionUnknownWhenForeignKeyRestorationFails(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['pragma foreign_keys = 0', '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('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()) + ->ordered(); + $connection->shouldReceive('statement')->once()->with('statement')->andReturnTrue()->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma foreign_keys = 1')->andReturnFalse()->ordered(); + $connection->shouldReceive('markCurrentSessionStateUnknown')->once(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [pragma foreign_keys = 1].'); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintRejectsAPopulatedRebuildInsideATransactionWithForeignKeysEnabled(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['pragma foreign_keys = 0', 'first statement', 'second statement', 'pragma foreign_keys = 1'], + [new Fluent(['name' => 'alter'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('getTablePrefix')->once()->andReturn(''); + $blueprint->shouldReceive('getTable')->twice()->andReturn('users'); + $connection->shouldReceive('scalar') + ->once() + ->with('select exists (select 1 from "users" limit 1)') + ->andReturn(1); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('statement')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'SQLite cannot rebuild the populated table [users] while foreign key constraints are enabled within an active transaction.' + ); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintUsesASavepointForAnEmptyRebuildInsideATransaction(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['pragma foreign_keys = 0', 'first statement', 'second statement', 'pragma foreign_keys = 1'], + [new Fluent(['name' => 'alter'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('getTablePrefix')->once()->andReturn(''); + $blueprint->shouldReceive('getTable')->once()->andReturn('users'); + $connection->shouldReceive('scalar') + ->once() + ->with('select exists (select 1 from "users" limit 1)') + ->andReturn(0); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintUsesASavepointInsideATransactionWhenForeignKeysAreDisabled(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['first statement', 'second statement'], + [new Fluent(['name' => 'alter'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->once()->andReturnFalse(); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('scalar')->never(); + $connection->shouldReceive('transaction') + ->once() + ->andReturnUsing(static fn (Closure $callback) => $callback()); + $connection->shouldReceive('statement')->once()->with('first statement')->andReturnTrue()->ordered(); + $connection->shouldReceive('statement')->once()->with('second statement')->andReturnTrue()->ordered(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testExecuteBlueprintDoesNotMutateSessionOrTransactionStateWhilePretending(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + $blueprint = $this->executionBlueprint( + ['pragma foreign_keys = 0', 'first statement', 'second statement', 'pragma foreign_keys = 1'], + [new Fluent(['name' => 'alter'])], + ); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('pretending')->once()->andReturnTrue(); + $connection->shouldReceive('transactionLevel')->never(); + $connection->shouldReceive('transaction')->never(); + $connection->shouldReceive('getPdo')->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(); + $connection->shouldReceive('statement')->once()->with('pragma foreign_keys = 1')->andReturnTrue()->ordered(); + + (new SQLiteBuilder($connection))->executeBlueprint($blueprint); + } + + public function testChangingForeignKeyConstraintsInsideATransactionFailsBeforeExecutingThePragma(): void + { + $connection = m::mock(Connection::class); + $grammar = new SQLiteGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('statement')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'SQLite foreign key constraints cannot be enabled or disabled within an active transaction.' + ); + + (new SQLiteBuilder($connection))->disableForeignKeyConstraints(); + } + + public function testWithoutForeignKeyConstraintsPreservesEnabledStateAcrossNestedBuilders(): void + { + $connection = $this->sqliteConnection(); + $outer = $connection->getSchemaBuilder(); + $inner = $connection->getSchemaBuilder(); + $outer->enableForeignKeyConstraints(); + + $result = $outer->withoutForeignKeyConstraints( + function () use ($connection, $inner): string { + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + + return $inner->withoutForeignKeyConstraints(function () use ($connection): string { + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + + return 'result'; + }); + } + ); + + $this->assertSame('result', $result); + $this->assertSame(1, (int) $connection->scalar('pragma foreign_keys')); + } + + public function testWithoutForeignKeyConstraintsPreservesDisabledState(): void + { + $connection = $this->sqliteConnection(); + $builder = $connection->getSchemaBuilder(); + + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + + $builder->withoutForeignKeyConstraints(function () use ($connection): void { + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + }); + + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + } + + public function testWithoutForeignKeyConstraintsRejectsAnEnabledStateInsideATransaction(): void + { + $connection = $this->sqliteConnection(); + $builder = $connection->getSchemaBuilder(); + $builder->enableForeignKeyConstraints(); + $connection->beginTransaction(); + $callbackCalled = false; + + try { + $builder->withoutForeignKeyConstraints(function () use (&$callbackCalled): void { + $callbackCalled = true; + }); + $this->fail('Expected the SQLite transaction restriction to be enforced.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'SQLite foreign key constraints cannot be enabled or disabled within an active transaction.', + $exception->getMessage() + ); + } finally { + $connection->rollBack(); + } + + $this->assertFalse($callbackCalled); + $this->assertSame(1, (int) $connection->scalar('pragma foreign_keys')); + } + + public function testWithoutForeignKeyConstraintsAllowsAnAlreadyDisabledStateInsideATransaction(): void + { + $connection = $this->sqliteConnection(); + $builder = $connection->getSchemaBuilder(); + $connection->beginTransaction(); + + try { + $result = $builder->withoutForeignKeyConstraints(fn () => 'result'); + } finally { + $connection->rollBack(); + } + + $this->assertSame('result', $result); + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + } + + 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')->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('statement') + ->once() + ->with($grammar->compileDropAllTables('main')) + ->andReturnTrue() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('statement') + ->once() + ->with($grammar->compileRebuild('main')) + ->andReturnTrue() + ->ordered(); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + 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')->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('statement') + ->once() + ->with($grammar->compileRebuild('main')) + ->andReturnTrue() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturn(0)->ordered(); + + (new SQLiteBuilder($connection))->dropAllViews(); + } + + 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')->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('statement') + ->once() + ->with($grammar->compileDropAllTables('main')) + ->andReturnFalse() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('statement')->with($grammar->compileRebuild('main'))->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Failed to execute schema statement [delete from "main".sqlite_master where type in (\'table\', \'index\', \'trigger\')].' + ); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + public function testDropAllTablesMarksALegacySessionUnknownWhenVacuumFails(): 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')->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('statement') + ->once() + ->with($grammar->compileDropAllTables('main')) + ->andReturnTrue() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 0')->andReturn(0)->ordered(); + $connection->shouldReceive('statement') + ->once() + ->with($grammar->compileRebuild('main')) + ->andReturnFalse() + ->ordered(); + $connection->shouldReceive('markCurrentSessionStateUnknown')->once(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [vacuum "main"].'); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + public function testDropAllTablesKeepsAModernSessionKnownWhenVacuumFailsAfterReset(): 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')->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('statement') + ->once() + ->with($grammar->compileDropAllTables('main')) + ->andReturnTrue() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = RESET')->andReturn(0)->ordered(); + $connection->shouldReceive('statement') + ->once() + ->with($grammar->compileRebuild('main')) + ->andReturnFalse() + ->ordered(); + $connection->shouldReceive('markCurrentSessionStateUnknown')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [vacuum "main"].'); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + public function testDropAllTablesMarksTheSessionUnknownWhenSchemaReloadFails(): 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')->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('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('statement')->with($grammar->compileRebuild('main'))->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [pragma writable_schema = RESET].'); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + public function testDropAllViewsMarksTheSessionUnknownWhenWritableModeRestorationFails(): 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')->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('statement') + ->once() + ->with($grammar->compileRebuild('main')) + ->andReturnTrue() + ->ordered(); + $pdo->shouldReceive('exec')->once()->with('pragma writable_schema = 1')->andReturnFalse()->ordered(); + $connection->shouldReceive('markCurrentSessionStateUnknown')->once(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to execute schema statement [pragma writable_schema = 1].'); + + (new SQLiteBuilder($connection))->dropAllViews(); + } + + public function testDropAllTablesRejectsAnActiveTransactionBeforeInspectingTheSchema(): void { $connection = m::mock(Connection::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); - $connection->shouldNotReceive('getDatabaseName'); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('scalar')->never(); + $connection->shouldReceive('statement')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite cannot drop all tables within an active transaction.'); + + (new SQLiteBuilder($connection))->dropAllTables(); + } + + public function testDropAllViewsRejectsAnActiveTransactionBeforeInspectingTheSchema(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('scalar')->never(); + $connection->shouldReceive('statement')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite cannot drop all views within an active transaction.'); + + (new SQLiteBuilder($connection))->dropAllViews(); + } + + public function testRefreshDatabaseFileUsesTheCanonicalMainDatabasePath(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDatabaseName')->once()->andReturn('file:database.sqlite?mode=rwc'); + $connection->shouldReceive('scalar')->once()->with('pragma journal_mode')->andReturn('delete'); $builder = m::mock(SQLiteBuilder::class, [$connection])->makePartial(); $builder->shouldReceive('getSchemas')->once()->andReturn([ ['name' => 'main', 'path' => '/canonical/database.sqlite'], ]); - $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['main']); - $builder->shouldReceive('refreshDatabaseFile')->once()->with('/canonical/database.sqlite'); - $builder->dropAllTables(); + File::shouldReceive('put')->once()->with('/canonical/database.sqlite', '')->andReturn(0); + + $builder->refreshDatabaseFile(); + } + + public function testRefreshDatabaseFileRejectsWalForTheConnectedDatabase(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDatabaseName')->once()->andReturn('/database.sqlite'); + $connection->shouldReceive('scalar')->once()->with('pragma journal_mode')->andReturn('wal'); + $connection->shouldReceive('selectFromWriteConnection')->never(); + File::shouldReceive('put')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'SQLite database files cannot be refreshed through a connection using WAL journal mode. Use dropAllTables() to empty a database while connections are using it.' + ); + + (new SQLiteBuilder($connection))->refreshDatabaseFile(); + } + + public function testRefreshDatabaseFileRejectsAnActiveTransactionBeforeInspectingTheDatabase(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->once()->andReturn(1); + $connection->shouldReceive('getDatabaseName')->never(); + $connection->shouldReceive('scalar')->never(); + File::shouldReceive('put')->never(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SQLite cannot refresh the database file within an active transaction.'); + + (new SQLiteBuilder($connection))->refreshDatabaseFile(); + } + + public function testRefreshDatabaseFileWithAnExplicitPathDoesNotInspectTheConnection(): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->never(); + $connection->shouldReceive('getDatabaseName')->never(); + $connection->shouldReceive('scalar')->never(); + $connection->shouldReceive('selectFromWriteConnection')->never(); + File::shouldReceive('put')->once()->with('/database.sqlite', '')->andReturn(0); + + (new SQLiteBuilder($connection))->refreshDatabaseFile('/database.sqlite'); + } + + #[DataProvider('inMemoryDatabaseNames')] + public function testRefreshDatabaseFileRejectsInMemoryDatabasesWithoutWritingAFile(string $database): void + { + $connection = m::mock(Connection::class); + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDatabaseName')->once()->andReturn($database); + $connection->shouldReceive('scalar')->never(); + File::shouldReceive('put')->never(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "SQLite database management requires a plain filesystem path; [{$database}] is not supported." + ); + + (new SQLiteBuilder($connection))->refreshDatabaseFile(); + } + + /** + * Provide in-memory SQLite database names. + */ + public static function inMemoryDatabaseNames(): array + { + return [ + 'literal memory database' => [':memory:'], + 'memory URI database' => ['file::memory:?cache=shared'], + 'mode memory URI database' => ['file:workflow?mode=memory&cache=shared'], + ]; } public function testRefreshDatabaseFileThrowsWhenTheFileCannotBeWritten(): void @@ -147,4 +830,80 @@ public function testRefreshDatabaseFileThrowsWhenTheFileCannotBeWritten(): void (new SQLiteBuilder($connection))->refreshDatabaseFile('/database.sqlite'); } + + /** + * Create a Blueprint double for execution-boundary tests. + * + * @param list $statements + * @param list $commands + */ + protected function executionBlueprint(array $statements, array $commands): Blueprint + { + $blueprint = m::mock(Blueprint::class); + $blueprint->shouldReceive('toSql')->once()->andReturn($statements); + $blueprint->shouldReceive('getCommands')->andReturn($commands); + + return $blueprint; + } + + /** + * Create a SQLite connection with its default database components. + */ + protected function sqliteConnection(): SQLiteConnection + { + $connection = new SQLiteConnection( + new PDO('sqlite::memory:'), + ':memory:', + '', + ['name' => 'test', 'driver' => 'sqlite'] + ); + $connection->useDefaultQueryGrammar(); + $connection->useDefaultPostProcessor(); + $connection->useDefaultSchemaGrammar(); + + return $connection; + } +} + +class SQLiteBuilderExtensionBlueprint extends Blueprint +{ + /** + * Add an extension command. + */ + public function extensionCommand(): Fluent + { + return $this->addCommand('extensionCommand'); + } + + /** + * Add a framework alter command. + */ + public function frameworkAlter(): Fluent + { + return $this->addCommand('alter'); + } +} + +class SQLiteBuilderExtensionGrammar extends SQLiteGrammar +{ + /** + * Compile an extension command. + * + * @return list + */ + public function compileExtensionCommand(Blueprint $blueprint, Fluent $command): array + { + return ['extension statement one', 'extension statement two']; + } + + /** + * Compile an alter command. + * + * @return list + */ + #[Override] + public function compileAlter(Blueprint $blueprint, Fluent $command): array + { + return ['overridden alter', 'second statement']; + } } diff --git a/tests/Database/DatabaseSQLiteProcessorTest.php b/tests/Database/DatabaseSQLiteProcessorTest.php index 2e1d83679..5838599ca 100644 --- a/tests/Database/DatabaseSQLiteProcessorTest.php +++ b/tests/Database/DatabaseSQLiteProcessorTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Query\Processors\SQLiteProcessor; use Hypervel\Tests\TestCase; +use UnexpectedValueException; class DatabaseSQLiteProcessorTest extends TestCase { @@ -35,4 +36,184 @@ public function testProcessColumns() $this->assertEquals($expected, $processor->processColumns($listing)); } + + public function testProcessIndexesKeepsPublicShapeAndPreservesSchemaStateMetadata(): void + { + $processor = new SQLiteProcessor; + $results = [ + [ + 'name' => 'MixedCase_Index', + 'columns' => '656D61696C', + 'unique' => 1, + 'primary' => 0, + 'sql' => 'CREATE UNIQUE INDEX "MixedCase_Index" ON "users" ("email")', + 'origin' => 'c', + 'reconstructible' => 1, + 'collations' => '42494E415259', + 'descending' => '0', + ], + [ + 'name' => 'sqlite_autoindex_users_1', + 'columns' => null, + 'unique' => 1, + 'primary' => 0, + 'sql' => null, + 'origin' => 'u', + 'reconstructible' => 0, + 'collations' => null, + 'descending' => null, + ], + ]; + + $this->assertSame([ + [ + 'name' => 'mixedcase_index', + 'columns' => ['email'], + 'type' => null, + 'unique' => true, + 'primary' => false, + ], + [ + 'name' => 'sqlite_autoindex_users_1', + 'columns' => [], + 'type' => null, + 'unique' => true, + 'primary' => false, + ], + ], $processor->processIndexes($results)); + + $this->assertSame([ + [ + 'name' => 'mixedcase_index', + 'physical_name' => 'MixedCase_Index', + 'columns' => ['email'], + 'type' => null, + 'unique' => true, + 'primary' => false, + 'sql' => 'CREATE UNIQUE INDEX "MixedCase_Index" ON "users" ("email")', + 'origin' => 'c', + 'reconstructible' => true, + 'collations' => ['BINARY'], + 'descending' => [false], + ], + [ + 'name' => 'sqlite_autoindex_users_1', + 'physical_name' => 'sqlite_autoindex_users_1', + 'columns' => [], + 'type' => null, + 'unique' => true, + 'primary' => false, + 'sql' => null, + 'origin' => 'u', + 'reconstructible' => false, + 'collations' => null, + 'descending' => null, + ], + ], $processor->processIndexesForSchemaState($results)); + } + + public function testProcessIndexMetadataPreservesCommaBearingValues(): void + { + $processor = new SQLiteProcessor; + $results = [[ + 'name' => 'sqlite_autoindex_contacts_1', + 'columns' => '656D61696C2C61646472657373', + 'unique' => 1, + 'primary' => 0, + 'sql' => null, + 'origin' => 'u', + 'reconstructible' => 0, + 'collations' => '636F6D6D612C6E616D65', + 'descending' => '1', + ]]; + + $this->assertSame([ + 'name' => 'sqlite_autoindex_contacts_1', + 'physical_name' => 'sqlite_autoindex_contacts_1', + 'columns' => ['email,address'], + 'type' => null, + 'unique' => true, + 'primary' => false, + 'sql' => null, + 'origin' => 'u', + 'reconstructible' => false, + 'collations' => ['comma,name'], + 'descending' => [true], + ], $processor->processIndexesForSchemaState($results)[0]); + + $this->assertSame(['email,address'], $processor->processIndexes($results)[0]['columns']); + } + + public function testProcessIndexMetadataRejectsInvalidHexadecimalValues(): void + { + $processor = new SQLiteProcessor; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('The SQLite schema metadata contains invalid hexadecimal text.'); + + $processor->processIndexesForSchemaState([[ + 'name' => 'contacts_index', + 'columns' => 'not-hexadecimal', + 'unique' => 0, + 'primary' => 0, + 'sql' => 'CREATE INDEX contacts_index ON contacts (email)', + 'origin' => 'c', + 'reconstructible' => 1, + 'collations' => '42494E415259', + 'descending' => '0', + ]]); + } + + /** + * Process composite primary-key indexes as lists. + */ + public function testProcessCompositePrimaryKeyIndexesAsLists(): void + { + $processor = new SQLiteProcessor; + $results = [ + [ + 'name' => 'users_email_index', + 'columns' => '656D61696C', + 'unique' => 0, + 'primary' => 0, + 'sql' => 'CREATE INDEX "users_email_index" ON "users" ("email")', + 'origin' => 'c', + 'reconstructible' => 1, + 'collations' => '42494E415259', + 'descending' => '0', + ], + [ + 'name' => 'primary', + 'columns' => '74656E616E745F6964,6964', + 'unique' => 1, + 'primary' => 1, + 'sql' => null, + 'origin' => 'pk', + 'reconstructible' => 1, + 'collations' => null, + 'descending' => null, + ], + [ + 'name' => 'sqlite_autoindex_users_1', + 'columns' => '74656E616E745F6964,6964', + 'unique' => 1, + 'primary' => 1, + 'sql' => null, + 'origin' => 'pk', + 'reconstructible' => 1, + 'collations' => '42494E415259,42494E415259', + 'descending' => '0,0', + ], + ]; + + $indexes = $processor->processIndexesForSchemaState($results); + + $this->assertTrue(array_is_list($indexes)); + $this->assertSame(['users_email_index', 'sqlite_autoindex_users_1'], array_column($indexes, 'name')); + + $publicIndexes = $processor->processIndexes($results); + + $this->assertTrue(array_is_list($publicIndexes)); + $this->assertSame(['users_email_index', 'sqlite_autoindex_users_1'], array_column($publicIndexes, 'name')); + } } diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index 897c981a0..91afdae3d 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -1118,11 +1118,14 @@ public function testRenamingAndChangingColumnsWork() { $builder = mock(SQLiteBuilder::class) ->makePartial() - ->shouldReceive('getColumns')->andReturn([ - ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], - ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ->shouldReceive('getColumnsForSchemaState')->andReturn([ + 'columns' => [ + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ], + 'sql' => 'CREATE TABLE users (name varchar, age varchar)', ]) - ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getIndexesForSchemaState')->andReturn([]) ->shouldReceive('getForeignKeys')->andReturn([]) ->getMock(); @@ -1146,11 +1149,14 @@ public function testRenamingAndChangingColumnsWorkWithSchema() { $builder = mock(SQLiteBuilder::class) ->makePartial() - ->shouldReceive('getColumns')->andReturn([ - ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], - ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ->shouldReceive('getColumnsForSchemaState')->andReturn([ + 'columns' => [ + ['name' => 'name', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ['name' => 'age', 'type_name' => 'varchar', 'type' => 'varchar', 'collation' => null, 'nullable' => false, 'default' => null, 'auto_increment' => false, 'comment' => null, 'generation' => null], + ], + 'sql' => 'CREATE TABLE users (name varchar, age varchar)', ]) - ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getIndexesForSchemaState')->andReturn([]) ->shouldReceive('getForeignKeys')->andReturn([]) ->getMock(); @@ -1197,8 +1203,11 @@ public function getBuilder() { return mock(SQLiteBuilder::class) ->makePartial() - ->shouldReceive('getColumns')->andReturn([]) - ->shouldReceive('getIndexes')->andReturn([]) + ->shouldReceive('getColumnsForSchemaState')->andReturn([ + 'columns' => [], + 'sql' => 'CREATE TABLE users ()', + ]) + ->shouldReceive('getIndexesForSchemaState')->andReturn([]) ->shouldReceive('getForeignKeys')->andReturn([]) ->getMock(); } diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php index 75295a506..fc658c10d 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php @@ -6,6 +6,7 @@ use Closure; use Exception; +use Hypervel\Database\QueryException; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -303,6 +304,808 @@ public function testRenameIndexWorks() $this->assertEquals($expected, $getSql('Postgres')); } + public function testRebuildPreservesExpressionPartialOrderedAndCollatedIndexes(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + + $schema->create('items', function (Blueprint $table) { + $table->integer('id'); + $table->string('email'); + $table->integer('score'); + }); + + $indexSql = 'CREATE UNIQUE INDEX "MixedCase_Index" ON "items" ("email" COLLATE NOCASE DESC) WHERE "score" > 0'; + $connection->statement($indexSql); + + $schema->table('items', function (Blueprint $table) { + $table->bigInteger('score')->change(); + }); + + $this->assertSame($indexSql, $this->indexSql('MixedCase_Index')); + } + + public function testRebuildQualifiesReplayedIndexesForAnAttachedSchema(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement("attach database ':memory:' as tenant"); + + try { + $schema->create('tenant.items', function (Blueprint $table) { + $table->integer('id'); + $table->string('email'); + }); + $connection->statement( + 'CREATE INDEX tenant."Tenant_Items_Email" ON "items" ("email" COLLATE NOCASE DESC)' + ); + + $schema->table('tenant.items', function (Blueprint $table) { + $table->text('email')->change(); + }); + + $this->assertSame( + 'CREATE INDEX "Tenant_Items_Email" ON "items" ("email" COLLATE NOCASE DESC)', + $connection->scalar( + "select sql from tenant.sqlite_schema where type = 'index' and name = 'Tenant_Items_Email'" + ) + ); + } finally { + $connection->statement('detach database tenant'); + } + } + + public function testColumnRenameUpdatesOnlyExactIndexAndForeignKeyColumns(): void + { + $schema = DB::connection()->getSchemaBuilder(); + + $schema->create('parents', function (Blueprint $table) { + $table->integer('id')->primary(); + }); + $schema->create('children', function (Blueprint $table) { + $table->integer('id'); + $table->integer('user_id'); + $table->string('name'); + $table->index('id', 'children_id_index'); + $table->index('user_id', 'children_user_id_index'); + $table->foreign('user_id')->references('id')->on('parents'); + }); + + $schema->table('children', function (Blueprint $table) { + $table->renameColumn('id', 'uuid'); + $table->text('name')->change(); + }); + + $this->assertTrue($schema->hasIndex('children', ['uuid'])); + $this->assertTrue($schema->hasIndex('children', ['user_id'])); + $this->assertSame( + ['user_id'], + $schema->getForeignKeys('children')[0]['columns'], + ); + } + + public function testRebuildReemitsInlineUniqueConstraints(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (id integer, email varchar not null unique, name varchar not null)' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement("insert into contacts (id, email, name) values (1, 'one@example.com', 'One')"); + + try { + $connection->statement("insert into contacts (id, email, name) values (2, 'one@example.com', 'Two')"); + $this->fail('Expected the rebuilt unique constraint to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesUniqueConstraintCollation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text, name varchar, unique (email collate nocase))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + + try { + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + $this->fail('Expected the rebuilt collated unique constraint to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesUniqueConstraintSortOrder(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text, name varchar, unique (email desc))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame( + 1, + (int) $connection->scalar( + 'select "desc" from pragma_index_xinfo(\'sqlite_autoindex_contacts_1\') where "key" = 1' + ), + ); + } + + public function testRebuildPreservesUniqueConstraintInheritedColumnCollation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text collate nocase, name varchar, unique (email))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + + try { + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + $this->fail('Expected the rebuilt inherited collation to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesExplicitBinaryUniqueConstraintCollation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text collate nocase, name varchar, unique (email collate binary))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + + $this->assertSame(2, $connection->table('contacts')->count()); + } + + public function testRebuildPreservesPrimaryKeyCollationAndSortOrder(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text, name varchar, primary key (email collate nocase desc))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame( + 1, + (int) $connection->scalar( + 'select "desc" from pragma_index_xinfo(\'sqlite_autoindex_contacts_1\') where "key" = 1' + ), + ); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + + try { + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + $this->fail('Expected the rebuilt collated primary key to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesExplicitBinaryPrimaryKeyCollation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text collate nocase, name varchar, primary key (email collate binary))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + + $this->assertSame(2, $connection->table('contacts')->count()); + } + + public function testRebuildPreservesCommaBearingColumnNames(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts ("email,address" text, name varchar, unique ("email,address"))' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $connection->statement( + 'insert into contacts ("email,address", name) values (\'one@example.com\', \'One\')' + ); + + try { + $connection->statement( + 'insert into contacts ("email,address", name) values (\'one@example.com\', \'Two\')' + ); + $this->fail('Expected the rebuilt comma-bearing unique constraint to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesWithoutRowid(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text primary key, name varchar) without rowid' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame( + 1, + (int) $connection->scalar("select wr from pragma_table_list where name = 'contacts'"), + ); + + try { + $connection->statement("insert into contacts (email, name) values (null, 'One')"); + $this->fail('Expected the rebuilt WITHOUT ROWID primary key to reject null.'); + } catch (QueryException) { + } + } + + public function testRebuildPreservesStrictTables(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (score integer, name text) strict' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame( + 1, + (int) $connection->scalar("select strict from pragma_table_list where name = 'contacts'"), + ); + + try { + $connection->statement("insert into contacts (score, name) values ('not an integer', 'One')"); + $this->fail('Expected the rebuilt STRICT table to reject an invalid integer.'); + } catch (QueryException) { + } + } + + public function testRebuildRejectsCheckConstraintsBeforeMutation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (score integer check (score > 0), name varchar)' + ); + $exception = null; + + try { + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + } catch (RuntimeException $caught) { + $exception = $caught; + } + + if ($exception === null) { + $this->fail('Expected the CHECK constraint to prevent the rebuild.'); + } + + $this->assertStringContainsString('CHECK constraint', $exception->getMessage()); + $this->assertSame('varchar', $schema->getColumnType('contacts', 'name')); + + try { + $connection->statement("insert into contacts (score, name) values (-1, 'One')"); + $this->fail('Expected the original CHECK constraint to remain enforced.'); + } catch (QueryException) { + } + } + + public function testRebuildRejectsBehaviorChangingConflictClausesBeforeMutation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email text unique on conflict replace, name varchar)' + ); + $exception = null; + + try { + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + } catch (RuntimeException $caught) { + $exception = $caught; + } + + if ($exception === null) { + $this->fail('Expected the conflict clause to prevent the rebuild.'); + } + + $this->assertStringContainsString('ON CONFLICT clause', $exception->getMessage()); + $this->assertSame('varchar', $schema->getColumnType('contacts', 'name')); + $connection->statement("insert into contacts (email, name) values ('one@example.com', 'One')"); + $connection->statement("insert into contacts (email, name) values ('one@example.com', 'Two')"); + $this->assertSame('Two', $connection->table('contacts')->value('name')); + } + + public function testRebuildRejectsDeferredForeignKeysBeforeMutation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement('create table parents (id integer primary key)'); + $connection->statement( + 'create table contacts (parent_id integer references parents (id) deferrable initially deferred, name varchar)' + ); + $exception = null; + + try { + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + } catch (RuntimeException $caught) { + $exception = $caught; + } + + if ($exception === null) { + $this->fail('Expected the deferred foreign key to prevent the rebuild.'); + } + + $this->assertStringContainsString('DEFERRABLE INITIALLY DEFERRED clause', $exception->getMessage()); + $this->assertSame('varchar', $schema->getColumnType('contacts', 'name')); + } + + public function testRebuildAllowsBehaviorEquivalentConflictAndForeignKeyClauses(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement('create table parents (id integer primary key)'); + $connection->statement( + 'create table abort_contacts (email text unique on conflict abort, name varchar)' + ); + $connection->statement( + 'create table immediate_contacts (parent_id integer references parents (id) deferrable initially immediate, name varchar)' + ); + $connection->statement( + 'create table nondeferrable_contacts (parent_id integer references parents (id) not deferrable initially deferred, name varchar)' + ); + + foreach (['abort_contacts', 'immediate_contacts', 'nondeferrable_contacts'] as $table) { + $schema->table($table, function (Blueprint $blueprint) { + $blueprint->text('name')->change(); + }); + + $this->assertSame('text', $schema->getColumnType($table, 'name')); + } + } + + public function testRebuildIgnoresGuardTokensInsideQuotedAndCommentedText(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement(<<<'SQL' +create table contacts ( + "deferrable" text default 'on conflict replace', + note text default 'check(', + name varchar /* check (ignored) */ +) +SQL); + + $schema->table('contacts', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame('text', $schema->getColumnType('contacts', 'name')); + } + + public function testFailedLateStatementRollsBackTheCompleteRebuild(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + + $schema->create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + $connection->table('users')->insert(['id' => 1, 'name' => 'Taylor']); + + try { + $schema->table('users', function (Blueprint $table) { + $table->text('name')->change(); + $table->rawIndex('(', 'invalid_index'); + }); + $this->fail('Expected the invalid index to fail.'); + } catch (QueryException) { + } + + $this->assertSame(['id', 'name'], $schema->getColumnListing('users')); + $this->assertSame( + ['id' => 1, 'name' => 'Taylor'], + (array) $connection->table('users')->first(), + ); + } + + public function testRebuildPreservesDisabledForeignKeyState(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + + $schema->table('users', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + } + + public function testAddingACompositeForeignKeyPreservesEnabledStateIndexesAndRows(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('parents', function (Blueprint $table) { + $table->integer('tenant_id'); + $table->integer('id'); + $table->unique(['tenant_id', 'id']); + }); + $schema->create('children', function (Blueprint $table) { + $table->integer('tenant_id'); + $table->integer('parent_id'); + $table->string('name'); + $table->index(['tenant_id', 'parent_id'], 'children_parent_index'); + }); + $connection->table('parents')->insert(['tenant_id' => 1, 'id' => 10]); + $connection->table('children')->insert([ + 'tenant_id' => 1, + 'parent_id' => 10, + 'name' => 'Taylor', + ]); + $schema->enableForeignKeyConstraints(); + + $schema->table('children', function (Blueprint $table) { + $table->foreign(['tenant_id', 'parent_id']) + ->references(['tenant_id', 'id']) + ->on('parents'); + }); + + $foreignKey = $schema->getForeignKeys('children')[0]; + + $this->assertSame(1, (int) $connection->scalar('pragma foreign_keys')); + $this->assertSame(['tenant_id', 'parent_id'], $foreignKey['columns']); + $this->assertSame(['tenant_id', 'id'], $foreignKey['foreign_columns']); + $this->assertSame('parents', $foreignKey['foreign_table']); + $this->assertTrue($schema->hasIndex('children', 'children_parent_index')); + $this->assertSame('Taylor', $connection->table('children')->value('name')); + } + + public function testEmptyRebuildUsesASavepointInsideATransactionWithForeignKeysEnabled(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + $schema->enableForeignKeyConstraints(); + + $connection->transaction(function () use ($connection, $schema) { + $schema->table('users', function (Blueprint $table) { + $table->text('name')->change(); + }); + + $this->assertSame(1, $connection->transactionLevel()); + $this->assertSame('text', $schema->getColumnType('users', 'name')); + }); + + $this->assertSame(1, (int) $connection->scalar('pragma foreign_keys')); + } + + public function testPopulatedRebuildFailsBeforeMutationInsideATransactionWithForeignKeysEnabled(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('users', function (Blueprint $table) { + $table->integer('id'); + $table->string('name'); + }); + $connection->table('users')->insert(['id' => 1, 'name' => 'Taylor']); + $schema->enableForeignKeyConstraints(); + + try { + $connection->transaction(function () use ($schema) { + $schema->table('users', function (Blueprint $table) { + $table->text('name')->change(); + }); + }); + $this->fail('Expected the populated rebuild to fail before mutation.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('populated table [users]', $exception->getMessage()); + } + + $this->assertSame(1, (int) $connection->scalar('pragma foreign_keys')); + $this->assertSame('varchar', $schema->getColumnType('users', 'name')); + $this->assertSame('Taylor', $connection->table('users')->value('name')); + } + + public function testRenameIndexPreservesItsStoredDefinitionAndPhysicalCase(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('items', function (Blueprint $table) { + $table->string('email'); + $table->integer('score'); + }); + $connection->statement( + 'CREATE UNIQUE INDEX "MixedCase_Index" ON "items" ("email" COLLATE NOCASE DESC) WHERE "score" > 0' + ); + + $schema->table('items', function (Blueprint $table) { + $table->renameIndex('mixedcase_index', 'Renamed_Index'); + }); + + $this->assertSame( + 'CREATE UNIQUE INDEX "Renamed_Index" ON "items" ("email" COLLATE NOCASE DESC) WHERE "score" > 0', + $this->indexSql('Renamed_Index'), + ); + } + + public function testRenameIndexPreservesExplicitBinaryCollation(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement('create table contacts (email text collate nocase, name varchar)'); + $connection->statement('create unique index contacts_email_unique on contacts (email collate binary)'); + + $schema->table('contacts', function (Blueprint $table) { + $table->renameIndex('contacts_email_unique', 'renamed_email_unique'); + }); + + $connection->statement("insert into contacts (email, name) values ('A', 'One')"); + $connection->statement("insert into contacts (email, name) values ('a', 'Two')"); + + $this->assertSame(2, $connection->table('contacts')->count()); + } + + public function testRenameIndexRejectsConstraintBackedIndexesBeforeExecution(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement('create table contacts (email varchar not null unique, name varchar not null)'); + + try { + $schema->table('contacts', function (Blueprint $table) { + $table->renameIndex('SQLITE_AUTOINDEX_CONTACTS_1', 'renamed_unique'); + }); + $this->fail('Expected the constraint-backed index rename to fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('backs a unique constraint', $exception->getMessage()); + } + + $this->assertTrue($schema->hasIndex('contacts', ['email'], 'unique')); + } + + public function testRichIndexRejectsStaleReplayWhenRenamePrecedesRebuild(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('items', function (Blueprint $table) { + $table->string('name'); + $table->string('email'); + $table->integer('score'); + }); + $connection->statement('CREATE INDEX "EmailExpr" ON "items" (lower("email"))'); + $connection->table('items')->insert(['name' => 'Taylor', 'email' => 'taylor@example.com', 'score' => 1]); + + try { + $schema->table('items', function (Blueprint $table) { + $table->renameColumn('name', 'label'); + $table->bigInteger('score')->change(); + }); + $this->fail('Expected the stale rich-index replay to fail.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('EmailExpr', $exception->getMessage()); + $this->assertStringContainsString('Move the rename after', $exception->getMessage()); + } + + $this->assertSame(['name', 'email', 'score'], $schema->getColumnListing('items')); + $this->assertSame('CREATE INDEX "EmailExpr" ON "items" (lower("email"))', $this->indexSql('EmailExpr')); + $this->assertSame('Taylor', $connection->table('items')->value('name')); + } + + public function testRichIndexUsesNativeRewriteWhenRenameFollowsRebuild(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('items', function (Blueprint $table) { + $table->string('name'); + $table->integer('score'); + }); + $connection->statement('CREATE INDEX "NameExpr" ON "items" (lower("name"))'); + + $schema->table('items', function (Blueprint $table) { + $table->bigInteger('score')->change(); + $table->renameColumn('name', 'label'); + }); + + $this->assertSame(['label', 'score'], $schema->getColumnListing('items')); + $this->assertSame('CREATE INDEX "NameExpr" ON "items" (lower("label"))', $this->indexSql('NameExpr')); + } + + public function testRenamedUniqueConstraintIsReemittedInline(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $connection->statement( + 'create table contacts (email varchar not null unique, name varchar not null)' + ); + + $schema->table('contacts', function (Blueprint $table) { + $table->renameColumn('email', 'address'); + $table->text('name')->change(); + }); + + $connection->table('contacts')->insert(['address' => 'one@example.com', 'name' => 'One']); + + try { + $connection->table('contacts')->insert(['address' => 'one@example.com', 'name' => 'Two']); + $this->fail('Expected the rebuilt unique constraint to reject a duplicate value.'); + } catch (QueryException) { + } + } + + public function testSQLiteDoubleQuotedStringFallbackChangesUniqueIndexSemantics(): void + { + $connection = DB::connection(); + $connection->statement('create table expression_case (label varchar not null, active integer not null)'); + $connection->statement( + 'create unique index expression_case_unique on expression_case ("name") where "active" = 1' + ); + $connection->table('expression_case')->insert(['label' => 'First', 'active' => 1]); + + try { + $connection->table('expression_case')->insert(['label' => 'Second', 'active' => 1]); + $this->fail('Expected the constant-expression unique index to admit only one matching row.'); + } catch (QueryException) { + } + + $connection->statement('create table predicate_case (name varchar not null, active integer not null)'); + $connection->statement( + 'create unique index predicate_case_unique on predicate_case ("name") where "gone" is not null' + ); + $connection->table('predicate_case')->insert(['name' => 'Taylor', 'active' => 0]); + + try { + $connection->table('predicate_case')->insert(['name' => 'Taylor', 'active' => 0]); + $this->fail('Expected the degraded partial predicate to reject the duplicate value.'); + } catch (QueryException) { + } + + $this->assertSame(-2, (int) $connection->scalar( + 'select cid from pragma_index_xinfo("expression_case_unique") where key = 1' + )); + } + + public function testNativeDropRejectsRichIndexDependencies(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + + foreach (['expression', 'predicate'] as $case) { + $table = $case . '_case'; + $schema->create($table, function (Blueprint $table) { + $table->string('name'); + $table->string('note'); + $table->integer('score'); + }); + $connection->statement(match ($case) { + 'expression' => 'create index expression_index on expression_case (lower("note"))', + 'predicate' => 'create index predicate_index on predicate_case ("name") where "note" is not null', + }); + + try { + $schema->table($table, function (Blueprint $table) { + $table->dropColumn('note'); + $table->bigInteger('score')->change(); + }); + $this->fail("Expected SQLite to reject the dependent {$case} index."); + } catch (QueryException) { + } + + $this->assertSame(['name', 'note', 'score'], $schema->getColumnListing($table)); + } + } + + public function testNativeDropAllowsAnUnrelatedRichIndexInEitherCommandOrder(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + + foreach (['drop_first', 'rebuild_first'] as $case) { + $schema->create($case, function (Blueprint $table) { + $table->string('name'); + $table->string('note'); + $table->integer('score'); + }); + $index = $case . '_name_expression'; + $indexSql = "CREATE INDEX \"{$index}\" ON \"{$case}\" (lower(\"name\"))"; + $connection->statement($indexSql); + + $schema->table($case, function (Blueprint $table) use ($case) { + if ($case === 'drop_first') { + $table->dropColumn('note'); + $table->bigInteger('score')->change(); + } else { + $table->bigInteger('score')->change(); + $table->dropColumn('note'); + } + }); + + $this->assertSame(['name', 'score'], $schema->getColumnListing($case)); + $this->assertSame($indexSql, $this->indexSql($index)); + } + } + + public function testNewRawIndexBeforeRenameFailsWithoutATypeError(): void + { + $connection = DB::connection(); + $schema = $connection->getSchemaBuilder(); + $schema->create('items', function (Blueprint $table) { + $table->string('name'); + $table->string('email'); + $table->integer('score'); + }); + + try { + $schema->table('items', function (Blueprint $table) { + $table->rawIndex('lower("email")', 'email_expression'); + $table->renameColumn('name', 'label'); + $table->bigInteger('score')->change(); + }); + $this->fail('Expected the raw index replay to fail safely.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('email_expression', $exception->getMessage()); + } + + $this->assertSame(['name', 'email', 'score'], $schema->getColumnListing('items')); + $this->assertNull($this->indexSql('email_expression')); + } + public function testAddUniqueIndexWithoutNameWorks() { DB::connection()->getSchemaBuilder()->create('users', function ($table) { @@ -463,4 +1266,15 @@ protected function getBlueprint( return new Blueprint($connection, $table, $callback); } + + /** + * Get the stored SQL for the given main-schema index. + */ + protected function indexSql(string $index): ?string + { + return DB::connection()->scalar( + "select sql from sqlite_schema where type = 'index' and name = ?", + [$index], + ); + } } diff --git a/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php index 180d414ca..c715cf03c 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSqliteSchemaBuilderTest.php @@ -10,7 +10,11 @@ use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; use Hypervel\Testing\ParallelTesting; +use InvalidArgumentException; use PDO; +use PDOException; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; class DatabaseSqliteSchemaBuilderTest extends SqliteTestCase { @@ -90,35 +94,315 @@ public function testGetRawIndex() $table->id(); $table->timestamps(); $table->rawIndex('(strftime("%Y", created_at))', 'table_raw_index'); + $table->rawIndex('id, strftime("%Y", created_at)', 'table_mixed_raw_index'); }); $indexes = Schema::getIndexes('table'); $this->assertSame([], collect($indexes)->firstWhere('name', 'table_raw_index')['columns']); + $this->assertSame([], collect($indexes)->firstWhere('name', 'table_mixed_raw_index')['columns']); } - public function testDropAllTablesRefreshesTheCanonicalPathForAFileUri(): void + public function testSchemaStateIndexMetadataDoesNotChangeThePublicIndexShape(): void { - $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest'); + $connection = DB::connection('conn1'); + $schema = $connection->getSchemaBuilder(); + $indexSql = 'CREATE INDEX "MixedCase_Index" ON "users" (lower("name"))'; + $connection->statement($indexSql); + + $this->assertSame([ + 'name' => 'mixedcase_index', + 'columns' => [], + 'type' => null, + 'unique' => false, + 'primary' => false, + ], collect($schema->getIndexes('users'))->firstWhere('name', 'mixedcase_index')); + + $this->assertSame([ + 'name' => 'mixedcase_index', + 'physical_name' => 'MixedCase_Index', + 'columns' => [], + 'type' => null, + 'unique' => false, + 'primary' => false, + 'sql' => $indexSql, + 'origin' => 'c', + 'reconstructible' => false, + 'collations' => null, + 'descending' => null, + ], collect($schema->getIndexesForSchemaState('users'))->firstWhere('name', 'mixedcase_index')); + } + + public function testDropAllTablesUsesCatalogCleanupForAFileUriInWalMode(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest-wal'); $files = new Filesystem; $files->deleteDirectory($directory); $files->ensureDirectoryExists($directory); $path = $directory . '/database.sqlite'; $files->put($path, ''); $uri = 'file:' . $path . '?mode=rwc'; - $pdo = new PDO('sqlite:' . $uri); + $pdo = new PDO('sqlite:' . $uri, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $connection = new SQLiteConnection($pdo, $uri); + $secondPdo = new PDO('sqlite:' . $uri, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); try { - $connection->statement('create table canonical_path_test (id integer primary key)'); + $this->assertSame('wal', $pdo->query('pragma journal_mode = wal')->fetchColumn()); + $connection->statement('create table records (id integer primary key)'); + $connection->statement('insert into records values (1)'); + $connection->statement('create view record_view as select id from records'); + $this->assertSame(1, $secondPdo->query('select count(*) from records')->fetchColumn()); + + $inode = fileinode($path); + $this->assertIsInt($inode); + + try { + $connection->getSchemaBuilder()->refreshDatabaseFile(); + $this->fail('Expected WAL database refresh to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'SQLite database files cannot be refreshed through a connection using WAL journal mode. Use dropAllTables() to empty a database while connections are using it.', + $exception->getMessage() + ); + } + + $this->assertSame(1, $pdo->query('select count(*) from records')->fetchColumn()); + $this->assertSame(1, $secondPdo->query('select count(*) from records')->fetchColumn()); $connection->getSchemaBuilder()->dropAllTables(); $this->assertSame([], $connection->getSchemaBuilder()->getTables()); + $this->assertSame(['record_view'], array_column($connection->getSchemaBuilder()->getViews(), 'name')); $this->assertSame($pdo, $connection->getPdo()); + $this->assertSame('wal', $pdo->query('pragma journal_mode')->fetchColumn()); + $this->assertSame($inode, fileinode($path)); + + $freshPdo = new PDO('sqlite:' . $uri, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + + $this->assertMissingTable($pdo, 'select * from record_view', 'records'); + $this->assertMissingTable($secondPdo, 'select * from record_view', 'records'); + $this->assertMissingTable($freshPdo, 'select * from record_view', 'records'); + + $connection->statement('create table records (id integer primary key)'); + $connection->statement('insert into records values (2)'); + + $this->assertSame(1, $pdo->query('select count(*) from record_view')->fetchColumn()); + $this->assertSame(1, $secondPdo->query('select count(*) from record_view')->fetchColumn()); + $this->assertSame(1, $freshPdo->query('select count(*) from record_view')->fetchColumn()); + } finally { + $connection->disconnect(); + unset($freshPdo, $secondPdo, $pdo); + $files->deleteDirectory($directory); + } + } + + public function testDropAllTablesPreservesViewsAndWritableModeInMemory(): void + { + $pdo = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $connection = new SQLiteConnection($pdo, ':memory:'); + $schema = $connection->getSchemaBuilder(); + + try { + $connection->statement('create table records (id integer primary key)'); + $connection->statement('insert into records values (1)'); + $connection->statement('create view record_view as select id from records'); + $connection->statement('pragma writable_schema = 1'); + + $schema->dropAllTables(); + + $this->assertSame([], $schema->getTables()); + $this->assertSame(['record_view'], array_column($schema->getViews(), 'name')); + $this->assertSame(1, $schema->pragma('writable_schema')); + $this->assertMissingTable($pdo, 'select * from record_view', 'records'); + + $connection->statement('create table records (id integer primary key)'); + $connection->statement('insert into records values (2)'); + $this->assertSame(1, $pdo->query('select count(*) from record_view')->fetchColumn()); + + $schema->dropAllViews(); + + $this->assertSame([], $schema->getViews()); + $this->assertSame(1, $schema->pragma('writable_schema')); + } finally { + $connection->statement('pragma writable_schema = 0'); + $connection->disconnect(); + } + } + + #[DataProvider('nonWalJournalModes')] + public function testRefreshDatabaseFileSupportsEveryNonWalJournalMode(string $journalMode): void + { + $directory = ParallelTesting::tempDir("DatabaseSqliteSchemaBuilderTest-{$journalMode}"); + $files = new Filesystem; + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $path = $directory . '/database.sqlite'; + $pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $connection = new SQLiteConnection($pdo, $path); + $secondPdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + + try { + $this->assertSame($journalMode, $pdo->query("pragma journal_mode = {$journalMode}")->fetchColumn()); + $connection->statement('create table records (id integer primary key)'); + $connection->statement('insert into records values (1)'); + $this->assertSame(1, $secondPdo->query('select count(*) from records')->fetchColumn()); + + $connection->getSchemaBuilder()->refreshDatabaseFile(); + + $freshPdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $this->assertMissingTable($pdo, 'select * from records', 'records'); + $this->assertMissingTable($secondPdo, 'select * from records', 'records'); + $this->assertMissingTable($freshPdo, 'select * from records', 'records'); + } finally { + $connection->disconnect(); + unset($freshPdo, $secondPdo, $pdo); + $files->deleteDirectory($directory); + } + } + + /** + * Provide every non-WAL SQLite journal mode. + */ + public static function nonWalJournalModes(): array + { + return [ + 'delete' => ['delete'], + 'truncate' => ['truncate'], + 'persist' => ['persist'], + 'memory' => ['memory'], + 'off' => ['off'], + ]; + } + + public function testRefreshDatabaseFileUsesTheCanonicalPathForAFileUri(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest-uri'); + $files = new Filesystem; + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $path = $directory . '/database.sqlite'; + $uri = 'file:' . $path . '?mode=rwc'; + $pdo = new PDO('sqlite:' . $uri, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $connection = new SQLiteConnection($pdo, $uri); + + try { + $connection->statement('create table records (id integer primary key)'); + + $connection->getSchemaBuilder()->refreshDatabaseFile(); + + $this->assertSame(0, filesize($path)); + $this->assertMissingTable($pdo, 'select * from records', 'records'); } finally { $connection->disconnect(); + unset($pdo); $files->deleteDirectory($directory); } } + + public function testRefreshDatabaseFileUsesTheCanonicalPathForARelativeDatabase(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest-relative'); + $files = new Filesystem; + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $workingDirectory = getcwd(); + $this->assertIsString($workingDirectory); + chdir($directory); + $path = $directory . '/database.sqlite'; + $pdo = null; + $connection = null; + + try { + $pdo = new PDO('sqlite:database.sqlite', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $connection = new SQLiteConnection($pdo, 'database.sqlite'); + $connection->statement('create table records (id integer primary key)'); + + $connection->getSchemaBuilder()->refreshDatabaseFile(); + + $this->assertSame(0, filesize($path)); + $this->assertMissingTable($pdo, 'select * from records', 'records'); + } finally { + $connection?->disconnect(); + unset($pdo); + chdir($workingDirectory); + $files->deleteDirectory($directory); + } + } + + public function testRefreshDatabaseFileAcceptsAnExplicitOfflinePath(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest-explicit'); + $files = new Filesystem; + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $path = $directory . '/database.sqlite'; + $targetPdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $targetPdo->exec('create table records (id integer primary key)'); + unset($targetPdo); + $connection = new SQLiteConnection( + new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]), + ':memory:' + ); + + try { + $connection->getSchemaBuilder()->refreshDatabaseFile($path); + + $this->assertSame(0, filesize($path)); + $freshPdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $this->assertMissingTable($freshPdo, 'select * from records', 'records'); + } finally { + $connection->disconnect(); + unset($freshPdo); + $files->deleteDirectory($directory); + } + } + + public function testRefreshDatabaseFileDoesNotCreateAFileForAnInMemoryDatabase(): void + { + $directory = ParallelTesting::tempDir('DatabaseSqliteSchemaBuilderTest-memory'); + $files = new Filesystem; + $files->deleteDirectory($directory); + $files->ensureDirectoryExists($directory); + $workingDirectory = getcwd(); + $this->assertIsString($workingDirectory); + chdir($directory); + $connection = null; + + try { + $connection = new SQLiteConnection( + new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]), + ':memory:' + ); + + try { + $connection->getSchemaBuilder()->refreshDatabaseFile(); + $this->fail('Expected an in-memory database refresh to be rejected.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame( + 'SQLite database management requires a plain filesystem path; [:memory:] is not supported.', + $exception->getMessage() + ); + } + + $this->assertFileDoesNotExist($directory . '/:memory:'); + } finally { + $connection?->disconnect(); + chdir($workingDirectory); + $files->deleteDirectory($directory); + } + } + + /** + * Assert that a query fails because its table is missing. + */ + protected function assertMissingTable(PDO $pdo, string $query, string $table): void + { + try { + $pdo->query($query); + $this->fail("Expected SQLite table [{$table}] to be missing."); + } catch (PDOException $exception) { + $this->assertStringContainsString('no such table:', $exception->getMessage()); + $this->assertStringContainsString($table, $exception->getMessage()); + } + } } From 542afc93e6370636ee526f5889cfb82caccada07 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:11:20 +0000 Subject: [PATCH 04/11] Discard unsafe test database sessions completely Teach the test database resolver to discard pooled wrappers whose physical session state became unknown, clear both cached connection entries, and complete all resets before rethrowing the first cleanup failure. Add resolver regressions for discard and failure ordering, plus integration coverage proving DatabaseTruncation preserves an initially disabled SQLite foreign-key state. --- .../Testing/DatabaseConnectionResolver.php | 24 ++++++- .../DatabaseConnectionResolverTest.php | 68 +++++++++++++++++++ .../Testing/DatabaseTruncationTest.php | 47 +++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Foundation/Testing/DatabaseTruncationTest.php diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index ad44f94e8..e42d1e024 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -80,13 +80,27 @@ public static function resetCachedConnections(): void static::$rebindingRegistered = false; } - foreach (static::$connections as $connection) { + $exception = null; + + foreach (static::$connections as $cacheKey => $connection) { if ($connection instanceof Connection) { $connection->resetForPool(); + + if ($connection->hasUnknownSessionState()) { + try { + static::discardCachedConnection($cacheKey); + } catch (Throwable $throwable) { + $exception ??= $throwable; + } + } } } static::registerDispatcherRebinding($container); + + if ($exception !== null) { + throw $exception; + } } /** @@ -156,6 +170,14 @@ public function flush(string $name): void { $cacheKey = $this->connectionCacheKey($name); + static::discardCachedConnection($cacheKey); + } + + /** + * Discard one cached connection and its owning pooled wrapper. + */ + protected static function discardCachedConnection(string $cacheKey): void + { try { if (isset(static::$pooledConnections[$cacheKey])) { static::$pooledConnections[$cacheKey]->discard(); diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 76eaedbb8..88afa91bb 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -10,6 +10,9 @@ use Hypervel\Database\Pool\PoolFactory; use Hypervel\Foundation\Testing\DatabaseConnectionResolver; use Hypervel\Testbench\TestCase; +use Mockery as m; +use ReflectionProperty; +use RuntimeException; class DatabaseConnectionResolverTest extends TestCase { @@ -83,6 +86,71 @@ public function testCachedWriteConnectionReappliesWriteReadRoutingAfterReset(): $this->assertSame('Taylor', $cachedConnection->selectOne('select name from users')->name); } + public function testResetCachedConnectionsDiscardsALeakedForeignKeySuppressionScope(): void + { + $resolver = $this->app->make(DatabaseConnectionResolver::class); + $connection = $resolver->connection(); + $connection->beginForeignKeyConstraintSuppression(); + + DatabaseConnectionResolver::resetCachedConnections(); + + $this->assertNull($connection->getRawPdo()); + $this->assertSame( + [], + (new ReflectionProperty(DatabaseConnectionResolver::class, 'connections'))->getValue() + ); + $this->assertSame( + [], + (new ReflectionProperty(DatabaseConnectionResolver::class, 'pooledConnections'))->getValue() + ); + } + + public function testResetCachedConnectionsCompletesEveryDiscardBeforeRethrowing(): void + { + DatabaseConnectionResolver::flushCachedConnections(); + + $firstConnection = m::mock(Connection::class); + $firstConnection->shouldReceive('resetForPool')->once(); + $firstConnection->shouldReceive('hasUnknownSessionState')->once()->andReturnTrue(); + $secondConnection = m::mock(Connection::class); + $secondConnection->shouldReceive('resetForPool')->once(); + $secondConnection->shouldReceive('hasUnknownSessionState')->once()->andReturnTrue(); + $failure = new RuntimeException('discard failed'); + $firstPooledConnection = m::mock(PooledConnection::class); + $firstPooledConnection->shouldReceive('discard')->once()->andThrow($failure); + $secondPooledConnection = m::mock(PooledConnection::class); + $secondPooledConnection->shouldReceive('discard')->once(); + + (new ReflectionProperty(DatabaseConnectionResolver::class, 'connections'))->setValue([ + 'first' => $firstConnection, + 'second' => $secondConnection, + ]); + (new ReflectionProperty(DatabaseConnectionResolver::class, 'pooledConnections'))->setValue([ + 'first' => $firstPooledConnection, + 'second' => $secondPooledConnection, + ]); + (new ReflectionProperty(DatabaseConnectionResolver::class, 'containerId'))->setValue( + spl_object_id(Container::getInstance()) + ); + (new ReflectionProperty(DatabaseConnectionResolver::class, 'rebindingRegistered'))->setValue(true); + + try { + DatabaseConnectionResolver::resetCachedConnections(); + $this->fail('Expected the first discard failure to be rethrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame( + [], + (new ReflectionProperty(DatabaseConnectionResolver::class, 'connections'))->getValue() + ); + $this->assertSame( + [], + (new ReflectionProperty(DatabaseConnectionResolver::class, 'pooledConnections'))->getValue() + ); + } + public function testSharedInMemorySqliteAliasesReuseAndFlushOneCachedOwner(): void { $resolver = $this->app->make(DatabaseConnectionResolver::class); diff --git a/tests/Integration/Foundation/Testing/DatabaseTruncationTest.php b/tests/Integration/Foundation/Testing/DatabaseTruncationTest.php new file mode 100644 index 000000000..8ec02df3b --- /dev/null +++ b/tests/Integration/Foundation/Testing/DatabaseTruncationTest.php @@ -0,0 +1,47 @@ +app->make('config'); + $connection = $this->app->make('db')->connection(); + $schema = $connection->getSchemaBuilder(); + + $this->assertFalse($config->boolean('database.connections.testing.foreign_key_constraints')); + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + + $schema->create('truncation_parents', function (Blueprint $table): void { + $table->id(); + }); + $schema->create('truncation_children', function (Blueprint $table): void { + $table->id(); + $table->foreignId('parent_id')->constrained('truncation_parents'); + }); + $connection->table('truncation_parents')->insert(['id' => 1]); + $connection->table('truncation_children')->insert(['id' => 1, 'parent_id' => 1]); + + $this->truncateTablesForAllConnections(); + + $this->assertSame(0, $connection->table('truncation_parents')->count()); + $this->assertSame(0, $connection->table('truncation_children')->count()); + $this->assertSame(0, (int) $connection->scalar('pragma foreign_keys')); + } +} From 55a870d2f30aa76ae1352162241564ab760fcbb7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:11:31 +0000 Subject: [PATCH 05/11] Clarify foreign-key constraint behavior in migrations Correct the stale claim about Hypervel SQLite defaults and document the transaction boundaries that govern SQLite constraint toggles and PostgreSQL constraint deferral. --- src/boost/docs/migrations.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/boost/docs/migrations.md b/src/boost/docs/migrations.md index d2fb6b404..772ea6310 100644 --- a/src/boost/docs/migrations.md +++ b/src/boost/docs/migrations.md @@ -1657,8 +1657,11 @@ Schema::withoutForeignKeyConstraints(function () { }); ``` +> [!NOTE] +> Hypervel's default SQLite connection enables foreign key constraints. Custom SQLite connections may control this behavior using the `foreign_key_constraints` configuration option. + > [!WARNING] -> SQLite disables foreign key constraints by default. When using SQLite, make sure to [enable foreign key support](/docs/{{version}}/database#configuration) in your database configuration before attempting to create them in your migrations. +> SQLite cannot enable or disable foreign key constraints while a transaction is active. Call these methods before beginning the transaction. PostgreSQL only defers constraint checks within a transaction; calling these methods outside a transaction does not disable constraints. ## Events From b080d40a08af2703217d72e17c4479c88bb58349 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:11:45 +0000 Subject: [PATCH 06/11] Document the database schema execution safety design Record the verified failure modes and final architecture for Blueprint execution, driver-specific transaction boundaries, exact SQLite index reconstruction, connection-owned foreign-key suppression, pooled-session invalidation, and safe SQLite catalog cleanup. Capture the required integration coverage, compatibility guarantees, performance boundaries, public behavior disclosures, and completed review status so the implementation and future maintenance share one concise source of truth. --- ...9-0555-database-schema-execution-safety.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/plans/2026-08-09-0555-database-schema-execution-safety.md diff --git a/docs/plans/2026-08-09-0555-database-schema-execution-safety.md b/docs/plans/2026-08-09-0555-database-schema-execution-safety.md new file mode 100644 index 000000000..e9dd2a98d --- /dev/null +++ b/docs/plans/2026-08-09-0555-database-schema-execution-safety.md @@ -0,0 +1,112 @@ +# Database schema execution safety plan + +## Status and objective + +Implement one framework PR that makes schema Blueprint execution, foreign-key suppression, SQLite rebuild/index replay, and destructive SQLite schema cleanup fail atomically and preserve physical-session state wherever the database engine permits it. + +The changes must preserve Laravel's public Schema/Blueprint APIs and command ordering. Internal behavior may improve on Laravel where current behavior is a verified correctness defect. Ordinary application queries, Eloquent, and queue/runtime paths must remain unchanged. + +Implementation, verification, and peer review are complete. The branch is ready for owner commit, push, and PR creation against `0.4`. + +## Verified defects and final design + +### Blueprint execution boundary + +- `Blueprint::build()` currently executes its compiled statements directly and ignores a `false` result from `Connection::statement()`. +- Add public `Builder::executeBlueprint(Blueprint $blueprint): void`. `Blueprint::build()` resolves the schema builder from its own connection and delegates to this method; `Builder::build()` delegates to the same method. This keeps custom builders and connection ownership authoritative without changing existing entry points. +- Compile each Blueprint once. Execute its ordered statement list through one guarded path that throws a `RuntimeException` naming the failed SQL when `statement()` returns `false`; native database exceptions remain unwrapped. +- Add one protected Builder predicate that ignores skipped commands and verifies every selected compiler name is declared on the framework grammar class supplied by the driver. Macro-only and subclass-added compilers remain ordered and unwrapped; subclass overrides of framework-declared commands retain that command kind's contract. Use static `method_exists()` checks, not reflection, public metadata, or a cache. +- `Blueprint::toSql()` remains a raw compilation API. It does not gain transaction or execution behavior. + +### PostgreSQL direct atomicity + +- `PostgresGrammar::supportsSchemaTransactions()` currently protects migrations only. A direct multi-statement `Schema::create()` or `Schema::table()` can commit early statements before a later failure even though PostgreSQL supports transactional DDL. +- `PostgresBuilder::executeBlueprint()` wraps a multi-statement Blueprint in `Connection::transaction()` only when no transaction is active, the runtime grammar still reports `supportsSchemaTransactions()`, every compiler is framework-declared on `PostgresGrammar`, and no non-skipped command has `online === true`. +- An existing transaction owns atomicity, so the builder executes directly without adding a nested transaction. +- A single SQL statement is already atomic and needs no wrapper. +- Any Blueprint containing `online()` remains one ordered, unwrapped list because `CREATE INDEX CONCURRENTLY` cannot run in a transaction. Do not split or reorder mixed online/ordinary commands. Online unique creation remains two unwrapped statements; a failed constraint attach can leave its concurrently created index, matching the explicit non-transactional contract. +- An online command inside an existing transaction still fails through PostgreSQL's native error, unchanged from current behavior. +- Current framework-declared PostgreSQL Blueprint transaction exclusions are completely represented by the existing `online` command property. Database creation/drop are builder-level operations and never pass through Blueprint execution. + +### SQLite Blueprint atomicity and foreign keys + +- Override Blueprint execution in `SQLiteBuilder` only for commands declared on `SQLiteGrammar`; extension compilers retain ordered, unwrapped execution. Do not consult or change SQLite's global schema-transaction flag: it describes whether an entire migration may be wrapped and remains false because whole-migration wrapping breaks foreign-key pragma handling. SQLite DDL is transactional, and this narrower wrapper handles the pragma explicitly. Record that distinction beside the override so it cannot be accidentally "simplified". +- Compile and classify the complete statement list before mutation. Strip only the exact grammar-generated foreign-key guard pragmas from rebuild lists; no string-wide SQL rewriting. +- With no active transaction: read and preserve the current foreign-key state, disable constraints when enabled, execute the complete Blueprint in one transaction, then restore the exact incoming state in `finally`. +- In an active transaction with foreign keys disabled: use the connection's existing nested transaction/savepoint behavior. +- In an active transaction with foreign keys enabled: a rebuild of an empty table uses a savepoint; a populated rebuild fails before mutation because SQLite ignores the foreign-key toggle inside a transaction and cascading rebuild DDL can delete related rows. Use one bounded existence query, not a row count. +- Direct SQLite foreign-key enable/disable calls fail before issuing their ineffective pragma when a transaction is active. +- Internal foreign-key state changes and restoration use the raw PDO outside pretend mode, matching transaction cleanup: application `beforeExecuting` callbacks cannot veto session restoration, and internal cleanup does not pollute query logs. Pretend mode never reaches raw session mutation. +- Pretend mode compiles/logs without state mutation or transaction effects. +- All SQLite multi-statement Blueprint failures roll back completely. Preserve caller command order: alteration followed by rename remains valid; rename followed by an old-table alteration fails with the original schema intact. MySQL/MariaDB cannot provide that rollback guarantee because their DDL is non-transactional. + +### Exact SQLite index round-trip + +- Remove the reversed `sqlite_` internal-index predicate in `SQLiteGrammar::compileAlter()` and classify introspected indexes by SQLite's authoritative origin instead of a name prefix. +- Preserve the public five-key `getIndexes()` row shape. Declare the internal schema-state index seam only on `SQLiteBuilder`, with physical name, stored SQL, origin, reconstructibility, and ordered indexed-column collation/order metadata required. `BlueprintState` and `SQLiteGrammar::compileRenameIndex()` narrow to that builder because both are SQLite-grammar-only paths; an incompatible custom builder fails natively during compilation before DDL. Join `pragma_index_xinfo` once in key order, derive simple-column reconstruction from those rows, and hex-encode both identifier lists across the aggregate boundary so commas in column or collation names remain exact. Expression-index metadata remains null and its public `columns` value remains `[]`. +- Replace `BlueprintState`'s substring-based column rename with exact array-element replacement for primary, index, and foreign-key column projections. Renaming `id` must not rewrite `user_id`. +- Compile every reconstructible user index from its authoritative physical name, uniqueness, and simple-column projection. Those facts completely describe its semantics, so the framework compiler produces canonical SQL without loss. Replay stored SQL only for richer indexes, preserving expressions, partial predicates, collations, sort order, mixed-case names, attached schemas, and renamed index identity. +- Keep stored SQL authoritative for richer indexes and record column/index rename and drop facts separately. A plain column change does not affect index SQL. +- Reject any non-reconstructible index when a column rename precedes its rebuild. SQLite's native rename rewrites expression and partial-index dependencies only at execution time, after Blueprint state captured the old SQL; replaying that stale SQL can silently turn a missing double-quoted identifier into a string expression. The error names the index and directs callers to place the rename after the rebuild command or use a separate `Schema::table()` call. +- Initialize the same state facts for new Blueprint index commands. A new command is reconstructible exactly when every projected column is a string: SQLite's ordinary Blueprint index APIs expose simple columns, while `rawIndex()` is the expression-bearing form and there is no Blueprint partial/collation/order index surface. Exact projection replacement leaves `Expression` values untouched. +- An index rename retains stored SQL and records that only its name token changed. SQLite normalizes stored index headers to `CREATE [UNIQUE] INDEX`, removes `IF NOT EXISTS` and schema qualifiers, and preserves the original suffix after the name. Apply only the required name/schema projection with grammar identifier helpers, match the preserved `ON` token case-insensitively, and fail closed on an unrecognized stored statement shape. Do not implement a general SQL parser. +- Make standalone `renameIndex()` use the same rich metadata. Match the exact physical name case-insensitively and reject primary and constraint-backed autoindexes before execution. Recompile reconstructible indexes canonically; emit the existing schema-aware drop plus the rewritten stored definition for richer indexes. +- Constraint-backed unique and primary-key indexes with null `sqlite_schema.sql` are re-emitted with their exact indexed-column collation and descending-order metadata. Normalize an effective collation away only when it matches the original column definition; this keeps inherited collations clean while retaining explicit `BINARY` overrides on non-binary columns. Synthetic `INTEGER PRIMARY KEY` state retains the normal plain-column path. New Blueprint indexes continue through normal grammar compilation. +- Retain the already-fetched stored `CREATE TABLE` SQL on `BlueprintState`. Scan a quote/comment-stripped copy once at the rebuild boundary: preserve `WITHOUT ROWID` and `STRICT` as canonical table suffixes; refuse behavior-changing `CHECK`, `ON CONFLICT ROLLBACK|FAIL|IGNORE|REPLACE`, and foreign-key `DEFERRABLE INITIALLY DEFERRED` clauses before mutation. Default `ON CONFLICT ABORT`, `NOT DEFERRABLE INITIALLY DEFERRED`, and `DEFERRABLE INITIALLY IMMEDIATE` rebuild normally because their behavior matches the emitted default. Do not parse and re-emit unsupported clauses or add another schema query. +- If a dropped column appears in any known simple-column index projection, reject before execution with a clear error. On SQLite 3.35+, native `DROP COLUMN` is the dependency authority for richer indexes in either caller order and permits unrelated drops. On legacy SQLite, reject a richer index when a prior drop must be implemented by rebuild because no reliable dependency list exists. + +### Nested foreign-key suppression and pooled sessions + +- `withoutForeignKeyConstraints()` must preserve the exact incoming state and support nesting across fresh schema-builder instances. Store one suppression depth on the owning `Connection`, not on ephemeral builders or process-global state. SQLite/MySQL/MariaDB state reads already make nesting correct, so their depth detects a scope that never unwound; PostgreSQL needs depth for nesting because its prior constraint mode is unreadable. +- SQLite, MySQL, and MariaDB read their real incoming foreign-key state. Only the outer scope changes it, and only the outer scope restores it. Callback/restore exception chaining remains native PHP behavior. +- Internal restoration resolves the synchronized write PDO, then executes directly on it outside pretend mode so configured session state has been applied while application query callbacks cannot veto cleanup and internal session maintenance stays out of query logs. Treat only an exact `false` result as failure because successful PDO session statements may return `0`. +- PostgreSQL retains Laravel's transaction-only `SET CONSTRAINTS ALL DEFERRED` / `IMMEDIATE` behavior. The connection-owned depth prevents an inner scope from restoring early; the helper does not claim to recover prior per-constraint modes that PostgreSQL does not expose. +- Outside a PostgreSQL transaction, the helper retains the database's native warning/no-effect behavior. Document this alongside the SQLite transaction restriction so callers do not infer a portable transaction-independent toggle. +- Do not add a session configurator for foreign keys; that would conflict with the public enable/disable API. +- If pool/test-resolver cleanup sees a positive suppression depth, mark an already-resolved physical PDO's session state unknown without opening a connection, clear wrapper depth, and continue normal transaction rollback/release. Never throw from `resetForPool()` before rollback. +- Remove the `markSessionStateUnknown()` no-configurator early return. A non-lost physical commit, rollback, disconnect cleanup, or foreign-key restoration failure makes reuse unsafe on every driver regardless of configured session policy. +- A pooled wrapper may report a reconnect as successful only after the rebound physical session is trustworthy. Normal pools replace an unknown PDO; shared in-memory SQLite cannot replace its sole PDO without silently replacing the database with an empty one, so it remains invalid and throws an error that names this cause. +- Default applications have no session configurators, so `getPdo()` and `getReadPdo()` bypass session synchronization. Enforce unknown-session replacement at the pool boundary rather than adding a WeakMap lookup to every PDO resolution. +- Test resolver cleanup must discard an unknown connection's owning pooled wrapper and unset both parallel cache entries. Complete all cached resets/discards before rethrowing the first cleanup failure. +- Ordinary release adds one suppression-depth comparison and one in-memory session-trust check. It adds no query, network round trip, allocation, or lock; the trust check reaches the existing `WeakMap` only after session state has been recorded. + +### SQLite drop-all and file refresh + +- Route `dropAllTables()` and `dropAllViews()` through one guarded catalog-cleanup path for memory and file-backed databases. SQLite owns the mutation, every live connection sees the new schema, WAL mode and the file inode are preserved, and writable-schema deletion reaches objects that cannot be dropped individually. Remove the file-path lookup, truncation branch, and filesystem race. +- Keep the table and view predicates separate. `dropAllTables()` preserves views like MySQL and honors `db:wipe --drop-views`; PostgreSQL removes only dependent views because its table drop requires `CASCADE`. A preserved SQLite view becomes usable again when migrations recreate its table. +- `dropAllTables()` and `dropAllViews()` reject an active wrapper transaction before any query or mutation. +- Keep `refreshDatabaseFile()` as a separate caller-coordinated filesystem operation. An explicit path must be a plain filesystem path and performs no connection query or path comparison; its docblock states that no connection may be using the target while it is refreshed. +- The no-argument form rejects an active wrapper transaction before any query and rejects in-memory databases without creating a literal `:memory:` file. It reads the current journal mode, rejects exact `wal` with guidance to use `dropAllTables()` to empty a live database, and truncates the canonical `main` path from `pragma_database_list` so file URIs and relative paths work correctly. All other SQLite journal modes remain supported. +- Route catalog table/view deletion through one focused cleanup helper: + - read and preserve incoming `writable_schema`; + - enable it only when needed and check the deletion result; + - on SQLite 3.37+, use `writable_schema=RESET` after success or failure so the live schema cache reloads; RESET leaves writable mode off, so the outer restoration remains required; + - on older supported SQLite, restore `OFF` and rely on guarded `VACUUM` to reload; + - run guarded `VACUUM` only after successful deletion and after the schema reload; on SQLite 3.37+ it reclaims pages rather than providing cache correctness; + - restore the exact incoming writable mode in an outer `finally`; + - preserve native exception chaining; and + - mark the physical session unknown if restoration fails, or if a legacy post-delete `VACUUM` failure leaves schema state uncertain. On modern SQLite, successful `RESET` keeps the session known even if the later `VACUUM` fails, while the operation still throws. +- Do not add per-table drops, journal-mode switching, automatic reconnects, a pool-wide schema lock, arbitrary path comparisons, or a generic public statement API. + +## Implementation and testing + +- Add the shared execution seam, guarded statement results, PostgreSQL transaction selection, SQLite audited transaction handling, exact index state/replay, connection-owned foreign-key scope, pool cleanup, and destructive SQLite cleanup as one coherent implementation. Cross file/driver boundaries directly; do not add temporary paths or repeat logic to manufacture intermediate states. +- Update `DatabaseSchemaBlueprintTest::testToSqlRunsCommandsFromBlueprint()` to assert delegation through the connection-owned builder rather than directly mocking `statement()`; do not weaken its execution-contract coverage. +- Cover PostgreSQL direct rollback, existing-transaction ownership, online-inside-transaction failure, every online index form, online unique, raw compilation, custom Blueprint/builder resolution, grammar transaction opt-out, macro/subclass-added compiler exclusion, built-in compiler overrides, and false results. +- Cover SQLite empty/populated rebuilds, outer transactions, pretend mode, expression/partial/ordered/collated indexes, attached schemas and prefixes, comma-bearing column/collation names, exact `id` to `uuid` projection replacement beside unchanged `user_id` index/foreign-key projections, index and column rename/drop ordering, same-Blueprint raw indexes, DQS expression/predicate corruption outcomes, exact unique/primary indexed-column semantics, preserved `WITHOUT ROWID` / `STRICT` behavior, pre-mutation rejection of unsupported table clauses, non-rejection of behavior-equivalent clauses and quoted/string tokens, rollback, macro/subclass-added compiler exclusion, and built-in compiler overrides. Reproduce the original migration shape by adding composite foreign keys to an existing table through a second `Schema::table()` call with foreign-key enforcement enabled, then assert that the rebuild preserves its indexes and constraints. +- Cover SQLite/MySQL/MariaDB exact foreign-key state and nesting, PostgreSQL transaction nesting, callback/restore failures, interrupted/leaked scopes, no-configurator commit/rollback/disconnect invalidation, normal pooled replacement, shared in-memory SQLite fail-closed diagnostics, test-resolver discard with complete cleanup after failure, DatabaseTruncation's initially-disabled testbench connection, and unchanged ordinary release behavior. +- Cover SQLite memory/file tables and views, writable mode on/off, false results and exceptions at every cleanup step, modern ghost-schema prevention, legacy invalidation, and DatabaseTruncation's drop paths. For memory and file/WAL databases, prove a view remains listed after `dropAllTables()`, fails while its table is absent, and works after the table is recreated. Cover no-argument/explicit refresh, no in-memory junk file, canonical file-URI and relative paths, all six journal modes, WAL rejection, multiple open PDOs, and file identity and journal-mode preservation. + +## Verification and compatibility + +- Run every changed test file immediately, then the complete database unit suite and the real SQLite, PostgreSQL, MySQL, and MariaDB integration groups through their documented workflows. +- Run pooling/test-resolver integration tests because correctness depends on physical-PDO invalidation, not mocks alone. +- Run `composer fix` once after the complete implementation and focused integration checks, then run targeted checks after review fixes and another full run only when warranted. +- Compare affected public signatures, named arguments, protected extension points, command ordering, generated SQL, and current upstream Laravel source/tests. Reconstructible indexes retain canonical Laravel-style generated SQL; richer SQLite indexes necessarily expose their stored definition through `toSql()` because no canonical compiler representation can preserve them. Additive low-level methods need Laravel-style title docblocks without exposing internal execution machinery as a new user workflow. +- Update the migration documentation in Laravel-docs prose with SQLite's transaction restriction and PostgreSQL's transaction-only constraint deferral. Correct its stale claim that Hypervel disables SQLite foreign keys by default; the application database config and database guide correctly document the enabled default. Keep low-level builder/grammar extension details in method docblocks and focused source comments rather than adding user-facing internals. +- Describe the public behavior corrections in the PR body: safe SQLite catalog cleanup replaces live file truncation; file-backed table cleanup now preserves views; no-argument file refresh rejects WAL and in-memory databases while resolving URI/relative paths canonically; `withoutForeignKeyConstraints()` deliberately bypasses overridable enable/disable methods so application query callbacks cannot veto physical-session restoration or log internal maintenance; mixed expression indexes report no simple-column projection; and comma-bearing column names no longer split into different indexed columns. +- Audit every final diff for overengineering, Laravel-style ergonomics, allocation/query/network overhead, coroutine and worker-lifetime safety, stale code, and duplicated execution paths. +- After signoff, present the reviewed branch to the owner for commit, push, and PR creation against `0.4`, with the engine-specific guarantees, compatibility boundaries, and verification results ready for the PR body. +- Record the byte-identical Laravel defects—the reversed internal-index predicate, substring column rename, ambiguous comma-joined index metadata, lost indexed-column/table-constraint/table-option semantics, raw-expression index rename crash, unchecked drop-all shape, and live file truncation under WAL—in the Hypervel PR, then prepare focused upstream reports or patches for separate owner approval before external submission. + +The PR is complete only when the full implementation review is signed off, every supported database path is green, no Blueprint or guarded schema-cleanup operation can silently report a false statement as success, and no unsafe physical session can return to the pool. From fb2a16f08f9ce72e6f72866a8877487a819c777e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:02 +0000 Subject: [PATCH 07/11] fix(database): harden MySQL schema cleanup Read foreign-key constraint state from the write PDO so nested suppression restores the physical session that schema mutations use. MariaDB inherits the same correction through its MySQL builder base.\n\nRoute drop-all table and view statements through the guarded schema executor. Exact false statement results now surface as failures, while native exceptions, SQL ordering, and foreign-key restoration behavior remain unchanged.\n\nAdd regression coverage for write-session reads, failed cleanup statements, and restoration before error propagation. --- src/database/src/Schema/MySqlBuilder.php | 15 +++--- tests/Database/DatabaseMySqlBuilderTest.php | 51 ++++++++++++++++++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/database/src/Schema/MySqlBuilder.php b/src/database/src/Schema/MySqlBuilder.php index 48abf5053..1921680ba 100755 --- a/src/database/src/Schema/MySqlBuilder.php +++ b/src/database/src/Schema/MySqlBuilder.php @@ -24,9 +24,9 @@ public function dropAllTables(): void } $this->withoutForeignKeyConstraints(function () use ($tables): void { - $this->connection->statement( - $this->grammar->compileDropAllTables($tables) - ); + $this->executeStatements([ + $this->grammar->compileDropAllTables($tables), + ]); }); } @@ -42,9 +42,9 @@ public function dropAllViews(): void return; } - $this->connection->statement( - $this->grammar->compileDropAllViews($views) - ); + $this->executeStatements([ + $this->grammar->compileDropAllViews($views), + ]); } /** @@ -53,7 +53,8 @@ public function dropAllViews(): void #[Override] protected function foreignKeyConstraintsAreEnabled(): bool { - return (bool) $this->connection->scalar('select @@foreign_key_checks'); + // Foreign-key checks are session state, so inspect the write PDO that schema changes use. + return (bool) $this->connection->scalar('select @@foreign_key_checks', [], false); } /** diff --git a/tests/Database/DatabaseMySqlBuilderTest.php b/tests/Database/DatabaseMySqlBuilderTest.php index 5a70f1ed0..61dc82337 100644 --- a/tests/Database/DatabaseMySqlBuilderTest.php +++ b/tests/Database/DatabaseMySqlBuilderTest.php @@ -10,6 +10,7 @@ use Hypervel\Tests\TestCase; use Mockery as m; use PDO; +use RuntimeException; class DatabaseMySqlBuilderTest extends TestCase { @@ -56,7 +57,7 @@ public function testDropAllTablesPreservesEnabledForeignKeyConstraints(): void $builder->shouldReceive('getTableListing')->once()->with(['database'])->andReturn(['users']); $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); $connection->shouldReceive('pretending')->times(3)->andReturnFalse(); - $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks')->andReturn(1); + $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('statement') @@ -81,7 +82,7 @@ public function testDropAllTablesPreservesDisabledForeignKeyConstraints(): void $builder->shouldReceive('getTableListing')->once()->with(['database'])->andReturn(['users']); $connection->shouldReceive('beginForeignKeyConstraintSuppression')->once()->andReturnTrue(); $connection->shouldReceive('pretending')->once()->andReturnFalse(); - $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks')->andReturn(0); + $connection->shouldReceive('scalar')->once()->with('select @@foreign_key_checks', [], false)->andReturn(0); $connection->shouldReceive('getPdo')->never(); $connection->shouldReceive('statement') ->once() @@ -91,4 +92,50 @@ public function testDropAllTablesPreservesDisabledForeignKeyConstraints(): void $builder->dropAllTables(); } + + public function testDropAllTablesPropagatesAFalseStatementResultAfterRestoringConstraints(): 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(); + $connection->shouldReceive('getDatabaseName')->once()->andReturn('database'); + $builder->shouldReceive('getTableListing')->once()->with(['database'])->andReturn(['users']); + $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(); + $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('endForeignKeyConstraintSuppression')->once(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllTables(); + } + + public function testDropAllViewsPropagatesAFalseStatementResult(): void + { + $connection = m::mock(Connection::class); + $grammar = new MySqlGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = m::mock(MySqlBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['database']); + $builder->shouldReceive('getViews')->once()->with(['database'])->andReturn([ + ['schema_qualified_name' => 'active_users'], + ]); + $statement = $grammar->compileDropAllViews(['active_users']); + $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllViews(); + } } From 26ccf4b9b291eeb1c7606f79ab0d048cad2a344c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:19 +0000 Subject: [PATCH 08/11] fix(database): use writer-owned schema state Route Schema::hasTable(), SQLite pragma and rebuild probes, stored table definitions, and populated-table guards through the write PDO. This keeps mutation decisions consistent with the physical session and schema they govern when read/write connections differ.\n\nKeep SQLite compile-option discovery on the reader because it is process-wide library metadata, and document that deliberate exception.\n\nGuard PostgreSQL drop-all table, view, type, and domain statements against exact false results without changing their SQL or execution order. Add real split-PDO SQLite regressions and strict call-shape coverage across every supported builder. --- src/database/src/Schema/Builder.php | 3 +- .../src/Schema/Grammars/SQLiteGrammar.php | 3 +- src/database/src/Schema/PostgresBuilder.php | 20 ++-- src/database/src/Schema/SQLiteBuilder.php | 11 ++- .../DatabaseMariaDbSchemaBuilderTest.php | 2 +- .../DatabaseMySQLSchemaBuilderTest.php | 2 +- .../Database/DatabasePostgresBuilderTest.php | 98 +++++++++++++++++-- .../DatabasePostgresSchemaBuilderTest.php | 2 +- tests/Database/DatabaseSQLiteBuilderTest.php | 68 ++++++++++--- .../DatabaseSQLiteSchemaGrammarTest.php | 4 +- 10 files changed, 176 insertions(+), 37 deletions(-) diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 296c1c395..a2184b5ed 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -171,7 +171,8 @@ public function hasTable(string $table): bool $table = $this->connection->getTablePrefix() . $table; if ($sql = $this->grammar->compileTableExists($schema, $table)) { - return (bool) $this->connection->scalar($sql); + // Schema existence must be read from the same write connection that migrations mutate. + return (bool) $this->connection->scalar($sql, [], false); } foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index e3344e7f7..f00214a61 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -373,7 +373,8 @@ public function compileAlter(Blueprint $blueprint, Fluent $command): array $table = $this->wrapTable($blueprint); $columnNames = implode(', ', $columnNames); - $foreignKeyConstraintsEnabled = $this->connection->scalar($this->pragma('foreign_keys')); + // Rebuild guards must inspect foreign-key state on the write PDO they govern. + $foreignKeyConstraintsEnabled = $this->connection->scalar($this->pragma('foreign_keys'), [], false); return array_filter(array_merge([ $foreignKeyConstraintsEnabled ? $this->compileDisableForeignKeyConstraints() : null, diff --git a/src/database/src/Schema/PostgresBuilder.php b/src/database/src/Schema/PostgresBuilder.php index 45efe5821..fa6853b66 100755 --- a/src/database/src/Schema/PostgresBuilder.php +++ b/src/database/src/Schema/PostgresBuilder.php @@ -74,9 +74,9 @@ public function dropAllTables(): void return; } - $this->connection->statement( - $this->grammar->compileDropAllTables($tables) - ); + $this->executeStatements([ + $this->grammar->compileDropAllTables($tables), + ]); } /** @@ -91,9 +91,9 @@ public function dropAllViews(): void return; } - $this->connection->statement( - $this->grammar->compileDropAllViews($views) - ); + $this->executeStatements([ + $this->grammar->compileDropAllViews($views), + ]); } /** @@ -116,11 +116,15 @@ public function dropAllTypes(): void } if (! empty($types)) { - $this->connection->statement($this->grammar->compileDropAllTypes($types)); + $this->executeStatements([ + $this->grammar->compileDropAllTypes($types), + ]); } if (! empty($domains)) { - $this->connection->statement($this->grammar->compileDropAllDomains($domains)); + $this->executeStatements([ + $this->grammar->compileDropAllDomains($domains), + ]); } } diff --git a/src/database/src/Schema/SQLiteBuilder.php b/src/database/src/Schema/SQLiteBuilder.php index 17470d400..c612b4e32 100644 --- a/src/database/src/Schema/SQLiteBuilder.php +++ b/src/database/src/Schema/SQLiteBuilder.php @@ -112,6 +112,7 @@ protected function validateDatabasePath(string $name): void public function getTables(array|string|null $schema = null): array { try { + // Compile options are SQLite library metadata shared by every PDO in the process. $withSize = (bool) $this->connection->scalar($this->grammar->compileDbstatExists()); } catch (QueryException) { $withSize = false; @@ -172,7 +173,8 @@ public function getColumnsForSchemaState(string $table): array $table = $this->connection->getTablePrefix() . $table; $columns = $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)); - $sql = $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table)) ?? ''; + // Rebuild guards must inspect the stored definition on the same write PDO as the columns. + $sql = $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table), [], false) ?? ''; return [ 'columns' => $this->connection->getPostProcessor()->processColumns( @@ -274,8 +276,9 @@ protected function dropSchemaObjects(string $schema, string $statement): void */ public function pragma(string $key, mixed $value = null): mixed { + // Pragmas may be connection-local state, so getters must inspect the write PDO that setters mutate. return is_null($value) - ? $this->connection->scalar($this->grammar->pragma($key)) + ? $this->connection->scalar($this->grammar->pragma($key), [], false) : $this->connection->statement($this->grammar->pragma($key, $value)); } @@ -415,7 +418,9 @@ function (string $statement) use ($disable, $enable, &$requiresForeignKeySuppres protected function tableHasRows(Blueprint $blueprint): bool { return (bool) $this->connection->scalar( - 'select exists (select 1 from ' . $this->grammar->wrapTable($blueprint) . ' limit 1)' + 'select exists (select 1 from ' . $this->grammar->wrapTable($blueprint) . ' limit 1)', + [], + false, ); } diff --git a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php index 3261c793a..50a5755aa 100755 --- a/tests/Database/DatabaseMariaDbSchemaBuilderTest.php +++ b/tests/Database/DatabaseMariaDbSchemaBuilderTest.php @@ -22,7 +22,7 @@ public function testHasTable() $builder = new MariaDbBuilder($connection); $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->once()->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->once()->with('sql', [], false)->andReturn(1); $this->assertTrue($builder->hasTable('table')); } diff --git a/tests/Database/DatabaseMySQLSchemaBuilderTest.php b/tests/Database/DatabaseMySQLSchemaBuilderTest.php index 16c9936e5..6869aa4e5 100755 --- a/tests/Database/DatabaseMySQLSchemaBuilderTest.php +++ b/tests/Database/DatabaseMySQLSchemaBuilderTest.php @@ -22,7 +22,7 @@ public function testHasTable() $builder = new MySqlBuilder($connection); $grammar->shouldReceive('compileTableExists')->once()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->once()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->once()->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->once()->with('sql', [], false)->andReturn(1); $this->assertTrue($builder->hasTable('table')); } diff --git a/tests/Database/DatabasePostgresBuilderTest.php b/tests/Database/DatabasePostgresBuilderTest.php index 218c766c9..03205ac46 100644 --- a/tests/Database/DatabasePostgresBuilderTest.php +++ b/tests/Database/DatabasePostgresBuilderTest.php @@ -15,6 +15,7 @@ use InvalidArgumentException; use Mockery as m; use Override; +use RuntimeException; class DatabasePostgresBuilderTest extends TestCase { @@ -186,7 +187,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathMissing() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -201,7 +202,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathFilled() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -217,7 +218,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathFallbackFilled() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -233,7 +234,7 @@ public function testHasTableWhenSchemaUnqualifiedAndSearchPathIsUserVariable() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -248,7 +249,7 @@ public function testHasTableWhenSchemaQualifiedAndSearchPathMismatches() $grammar = m::mock(PostgresGrammar::class); $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $grammar->shouldReceive('compileTableExists')->andReturn('sql'); - $connection->shouldReceive('scalar')->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->with('sql', [], false)->andReturn(1); $connection->shouldReceive('getTablePrefix'); $builder = $this->getBuilder($connection); @@ -406,7 +407,7 @@ public function testDropAllTablesWhenSearchPathIsString() $processor->shouldReceive('processTables')->once()->andReturn([['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users']]); $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'public', 'schema_qualified_name' => 'public.users']]); $grammar->shouldReceive('compileDropAllTables')->with(['public.users'])->andReturn('drop table "public"."users" cascade'); - $connection->shouldReceive('statement')->with('drop table "public"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "public"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); $builder->dropAllTables(); @@ -426,7 +427,7 @@ public function testDropAllTablesWhenSearchPathIsStringOfMany() $grammar->shouldReceive('compileTables')->andReturn('sql'); $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); $grammar->shouldReceive('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); - $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); $builder->dropAllTables(); @@ -451,12 +452,93 @@ public function testDropAllTablesWhenSearchPathIsArrayOfMany() $grammar->shouldReceive('compileTables')->andReturn('sql'); $connection->shouldReceive('selectFromWriteConnection')->with('sql')->andReturn([['name' => 'users', 'schema' => 'foouser', 'schema_qualified_name' => 'foouser.users']]); $grammar->shouldReceive('compileDropAllTables')->with(['foouser.users'])->andReturn('drop table "foouser"."users" cascade'); - $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade'); + $connection->shouldReceive('statement')->with('drop table "foouser"."users" cascade')->andReturnTrue(); $builder = $this->getBuilder($connection); $builder->dropAllTables(); } + public function testDropAllTablesPropagatesAFalseStatementResult(): void + { + $connection = $this->getConnection(); + $grammar = new PostgresGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $connection->shouldReceive('getConfig')->once()->with('dont_drop')->andReturn([]); + $builder = m::mock(PostgresBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['public']); + $builder->shouldReceive('getTables')->once()->with(['public'])->andReturn([ + ['name' => 'users', 'schema_qualified_name' => 'public.users'], + ]); + $statement = $grammar->compileDropAllTables(['public.users']); + $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllTables(); + } + + public function testDropAllViewsPropagatesAFalseStatementResult(): void + { + $connection = $this->getConnection(); + $grammar = new PostgresGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = m::mock(PostgresBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['public']); + $builder->shouldReceive('getViews')->once()->with(['public'])->andReturn([ + ['schema_qualified_name' => 'public.active_users'], + ]); + $statement = $grammar->compileDropAllViews(['public.active_users']); + $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllViews(); + } + + public function testDropAllTypesPropagatesAFalseStatementResult(): void + { + $connection = $this->getConnection(); + $grammar = new PostgresGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = m::mock(PostgresBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['public']); + $builder->shouldReceive('getTypes')->once()->with(['public'])->andReturn([ + ['implicit' => false, 'type' => 'enum', 'schema_qualified_name' => 'public.status'], + ]); + $statement = $grammar->compileDropAllTypes(['public.status']); + $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllTypes(); + } + + public function testDropAllDomainsPropagatesAFalseStatementResult(): void + { + $connection = $this->getConnection(); + $grammar = new PostgresGrammar($connection); + + $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); + $builder = m::mock(PostgresBuilder::class, [$connection])->makePartial(); + $builder->shouldReceive('getCurrentSchemaListing')->once()->andReturn(['public']); + $builder->shouldReceive('getTypes')->once()->with(['public'])->andReturn([ + ['implicit' => false, 'type' => 'domain', 'schema_qualified_name' => 'public.email'], + ]); + $statement = $grammar->compileDropAllDomains(['public.email']); + $connection->shouldReceive('statement')->once()->with($statement)->andReturnFalse(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Failed to execute schema statement [{$statement}]."); + + $builder->dropAllTypes(); + } + protected function getConnection() { return m::mock(Connection::class); diff --git a/tests/Database/DatabasePostgresSchemaBuilderTest.php b/tests/Database/DatabasePostgresSchemaBuilderTest.php index 062aa45b4..118389fa4 100755 --- a/tests/Database/DatabasePostgresSchemaBuilderTest.php +++ b/tests/Database/DatabasePostgresSchemaBuilderTest.php @@ -21,7 +21,7 @@ public function testHasTable() $builder = new PostgresBuilder($connection); $grammar->shouldReceive('compileTableExists')->twice()->andReturn('sql'); $connection->shouldReceive('getTablePrefix')->twice()->andReturn('prefix_'); - $connection->shouldReceive('scalar')->twice()->with('sql')->andReturn(1); + $connection->shouldReceive('scalar')->twice()->with('sql', [], false)->andReturn(1); $this->assertTrue($builder->hasTable('table')); $this->assertTrue($builder->hasTable('public.table')); diff --git a/tests/Database/DatabaseSQLiteBuilderTest.php b/tests/Database/DatabaseSQLiteBuilderTest.php index bb0411ac8..d8a091318 100644 --- a/tests/Database/DatabaseSQLiteBuilderTest.php +++ b/tests/Database/DatabaseSQLiteBuilderTest.php @@ -311,7 +311,7 @@ public function testExecuteBlueprintRejectsAPopulatedRebuildInsideATransactionWi $blueprint->shouldReceive('getTable')->twice()->andReturn('users'); $connection->shouldReceive('scalar') ->once() - ->with('select exists (select 1 from "users" limit 1)') + ->with('select exists (select 1 from "users" limit 1)', [], false) ->andReturn(1); $connection->shouldReceive('transaction')->never(); $connection->shouldReceive('statement')->never(); @@ -340,7 +340,7 @@ public function testExecuteBlueprintUsesASavepointForAnEmptyRebuildInsideATransa $blueprint->shouldReceive('getTable')->once()->andReturn('users'); $connection->shouldReceive('scalar') ->once() - ->with('select exists (select 1 from "users" limit 1)') + ->with('select exists (select 1 from "users" limit 1)', [], false) ->andReturn(0); $connection->shouldReceive('transaction') ->once() @@ -412,6 +412,52 @@ public function testChangingForeignKeyConstraintsInsideATransactionFailsBeforeEx (new SQLiteBuilder($connection))->disableForeignKeyConstraints(); } + public function testPragmaReadsStateFromTheWriteConnection(): void + { + $connection = $this->sqliteConnection(); + $readPdo = new PDO('sqlite::memory:'); + $connection->getPdo()->exec('pragma foreign_keys = 1'); + $readPdo->exec('pragma foreign_keys = 0'); + $connection->setReadPdo($readPdo); + + try { + $this->assertSame(1, $connection->getSchemaBuilder()->pragma('foreign_keys')); + } finally { + $connection->disconnect(); + } + } + + public function testSchemaStateReadsStoredTableDefinitionFromTheWriteConnection(): void + { + $connection = $this->sqliteConnection(); + $readPdo = new PDO('sqlite::memory:'); + $connection->statement('create table contacts (email varchar primary key) without rowid'); + $readPdo->exec('create table contacts (email varchar primary key)'); + $connection->setReadPdo($readPdo); + + try { + $state = $connection->getSchemaBuilder()->getColumnsForSchemaState('contacts'); + + $this->assertMatchesRegularExpression('/\bwithout\s+rowid\s*$/i', $state['sql']); + } finally { + $connection->disconnect(); + } + } + + public function testHasTableReadsStateFromTheWriteConnection(): void + { + $connection = $this->sqliteConnection(); + $readPdo = new PDO('sqlite::memory:'); + $connection->statement('create table write_only (id integer primary key)'); + $connection->setReadPdo($readPdo); + + try { + $this->assertTrue($connection->getSchemaBuilder()->hasTable('write_only')); + } finally { + $connection->disconnect(); + } + } + public function testWithoutForeignKeyConstraintsPreservesEnabledStateAcrossNestedBuilders(): void { $connection = $this->sqliteConnection(); @@ -499,7 +545,7 @@ public function testDropAllTablesUsesGuardedCatalogCleanup(): void $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0)->ordered(); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->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(); @@ -526,7 +572,7 @@ public function testDropAllViewsRestoresAnEnabledWritableSchema(): void $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0)->ordered(); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->andReturn(1)->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') @@ -553,7 +599,7 @@ public function testDropAllTablesReloadsTheSchemaAfterADeleteFailure(): void $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->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(); @@ -581,7 +627,7 @@ public function testDropAllTablesMarksALegacySessionUnknownWhenVacuumFails(): vo $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->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(); @@ -612,7 +658,7 @@ public function testDropAllTablesKeepsAModernSessionKnownWhenVacuumFailsAfterRes $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->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(); @@ -643,7 +689,7 @@ public function testDropAllTablesMarksTheSessionUnknownWhenSchemaReloadFails(): $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->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(); @@ -670,7 +716,7 @@ public function testDropAllViewsMarksTheSessionUnknownWhenWritableModeRestoratio $connection->shouldReceive('getSchemaGrammar')->once()->andReturn($grammar); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); - $connection->shouldReceive('scalar')->once()->with('pragma writable_schema')->andReturn(1); + $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') @@ -727,7 +773,7 @@ public function testRefreshDatabaseFileUsesTheCanonicalMainDatabasePath(): void $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('getDatabaseName')->once()->andReturn('file:database.sqlite?mode=rwc'); - $connection->shouldReceive('scalar')->once()->with('pragma journal_mode')->andReturn('delete'); + $connection->shouldReceive('scalar')->once()->with('pragma journal_mode', [], false)->andReturn('delete'); $builder = m::mock(SQLiteBuilder::class, [$connection])->makePartial(); $builder->shouldReceive('getSchemas')->once()->andReturn([ @@ -745,7 +791,7 @@ public function testRefreshDatabaseFileRejectsWalForTheConnectedDatabase(): void $connection->shouldReceive('getSchemaGrammar')->once()->andReturn(new SQLiteGrammar($connection)); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('getDatabaseName')->once()->andReturn('/database.sqlite'); - $connection->shouldReceive('scalar')->once()->with('pragma journal_mode')->andReturn('wal'); + $connection->shouldReceive('scalar')->once()->with('pragma journal_mode', [], false)->andReturn('wal'); $connection->shouldReceive('selectFromWriteConnection')->never(); File::shouldReceive('put')->never(); diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index 91afdae3d..9374dfe26 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -1130,7 +1130,7 @@ public function testRenamingAndChangingColumnsWork() ->getMock(); $connection = $this->getConnection(builder: $builder); - $connection->shouldReceive('scalar')->with('pragma foreign_keys')->andReturn(false); + $connection->shouldReceive('scalar')->with('pragma foreign_keys', [], false)->andReturn(false); $blueprint = new Blueprint($connection, 'users'); $blueprint->renameColumn('name', 'first_name'); @@ -1161,7 +1161,7 @@ public function testRenamingAndChangingColumnsWorkWithSchema() ->getMock(); $connection = $this->getConnection(builder: $builder); - $connection->shouldReceive('scalar')->with('pragma foreign_keys')->andReturn(false); + $connection->shouldReceive('scalar')->with('pragma foreign_keys', [], false)->andReturn(false); $blueprint = new Blueprint($connection, 'my_schema.users'); $blueprint->renameColumn('name', 'first_name'); From 78fe9d9c7ef24e258103045a559402a7ec6cc04e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:26 +0000 Subject: [PATCH 09/11] test(database): make SQLite rebuild coverage portable Verify WITHOUT ROWID preservation through sqlite_master so the assertion works on the same SQLite versions as the schema introspection path. Keep the reachable STRICT version guard and remove the redundant older-version guard.\n\nReplace compile-option assumptions about double-quoted string fallback with a behavioral DDL probe that covers both indexed-column and partial-predicate positions. Unsupported builds skip only on SQLite's missing-column diagnostic, while all other failures remain visible. --- .../Sqlite/DatabaseSchemaBlueprintTest.php | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php index fc658c10d..f4a5ee19c 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBlueprintTest.php @@ -10,6 +10,7 @@ use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; +use Hypervel\Testbench\Attributes\RequiresDatabase; use RuntimeException; class DatabaseSchemaBlueprintTest extends SqliteTestCase @@ -568,10 +569,11 @@ public function testRebuildPreservesWithoutRowid(): void $table->text('name')->change(); }); - $this->assertSame( - 1, - (int) $connection->scalar("select wr from pragma_table_list where name = 'contacts'"), + $tableSql = $connection->scalar( + "select sql from sqlite_master where type = 'table' and name = 'contacts'" ); + $this->assertIsString($tableSql); + $this->assertMatchesRegularExpression('/\bwithout\s+rowid\s*$/i', $tableSql); try { $connection->statement("insert into contacts (email, name) values (null, 'One')"); @@ -580,6 +582,7 @@ public function testRebuildPreservesWithoutRowid(): void } } + #[RequiresDatabase('sqlite', '>=3.37.0')] public function testRebuildPreservesStrictTables(): void { $connection = DB::connection(); @@ -992,6 +995,23 @@ public function testRenamedUniqueConstraintIsReemittedInline(): void public function testSQLiteDoubleQuotedStringFallbackChangesUniqueIndexSemantics(): void { $connection = DB::connection(); + + $connection->statement('create table dqs_probe (label varchar not null)'); + + try { + $connection->statement( + 'create index dqs_probe_index on dqs_probe ("missing") where "gone" is not null' + ); + } catch (QueryException $exception) { + if (! str_contains($exception->getMessage(), 'no such column:')) { + throw $exception; + } + + $this->markTestSkipped('SQLite double-quoted string fallback is disabled for DDL.'); + } finally { + $connection->statement('drop table dqs_probe'); + } + $connection->statement('create table expression_case (label varchar not null, active integer not null)'); $connection->statement( 'create unique index expression_case_unique on expression_case ("name") where "active" = 1' From fc810ecc8f266922bb5c75ad9f50d9efef8c4c2d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:32 +0000 Subject: [PATCH 10/11] docs(database): clarify foreign-key constraint toggles Separate the SQLite and PostgreSQL transaction rules so the guidance cannot be read as applying the same way to both drivers.\n\nDocument that PostgreSQL defers only foreign keys created with deferrable(), only inside a transaction, while other constraints remain enforced. --- src/boost/docs/migrations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/boost/docs/migrations.md b/src/boost/docs/migrations.md index 772ea6310..153c6feb3 100644 --- a/src/boost/docs/migrations.md +++ b/src/boost/docs/migrations.md @@ -1661,7 +1661,7 @@ Schema::withoutForeignKeyConstraints(function () { > Hypervel's default SQLite connection enables foreign key constraints. Custom SQLite connections may control this behavior using the `foreign_key_constraints` configuration option. > [!WARNING] -> SQLite cannot enable or disable foreign key constraints while a transaction is active. Call these methods before beginning the transaction. PostgreSQL only defers constraint checks within a transaction; calling these methods outside a transaction does not disable constraints. +> SQLite cannot enable or disable foreign key constraints while a transaction is active, so call these methods before beginning the transaction. On PostgreSQL, they defer only foreign keys created with `deferrable()`, and only within a transaction. Other foreign keys stay enforced, and on PostgreSQL calling them outside a transaction has no effect. ## Events From 0afd65c7fc95f063f9742e7158b94b676bb453b3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:33:41 +0000 Subject: [PATCH 11/11] docs(plans): record schema write-connection invariants Record that mutation-governing schema and session state belongs to the write connection, including Schema::hasTable() and SQLite rebuild state.\n\nAdd failed drop-all results and reader/writer divergence to the PR behavior and upstream-defect lists. Refresh the remaining-work wording without turning the plan into durable commit or push authority. --- ...2026-08-09-0555-database-schema-execution-safety.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-08-09-0555-database-schema-execution-safety.md b/docs/plans/2026-08-09-0555-database-schema-execution-safety.md index e9dd2a98d..58cc0b754 100644 --- a/docs/plans/2026-08-09-0555-database-schema-execution-safety.md +++ b/docs/plans/2026-08-09-0555-database-schema-execution-safety.md @@ -6,10 +6,12 @@ Implement one framework PR that makes schema Blueprint execution, foreign-key su The changes must preserve Laravel's public Schema/Blueprint APIs and command ordering. Internal behavior may improve on Laravel where current behavior is a verified correctness defect. Ordinary application queries, Eloquent, and queue/runtime paths must remain unchanged. -Implementation, verification, and peer review are complete. The branch is ready for owner commit, push, and PR creation against `0.4`. +Implementation and verification are complete. The remaining work is final review, then owner commit, push, and PR response. ## Verified defects and final design +- Schema state and connection-local settings that govern or reconstruct a schema mutation are read from the write connection. This includes existence checks, foreign-key and pragma state, and SQLite's stored table definition; a configured reader may lag or represent a different physical session. + ### Blueprint execution boundary - `Blueprint::build()` currently executes its compiled statements directly and ignores a `false` result from `Connection::statement()`. @@ -104,9 +106,9 @@ Implementation, verification, and peer review are complete. The branch is ready - Run `composer fix` once after the complete implementation and focused integration checks, then run targeted checks after review fixes and another full run only when warranted. - Compare affected public signatures, named arguments, protected extension points, command ordering, generated SQL, and current upstream Laravel source/tests. Reconstructible indexes retain canonical Laravel-style generated SQL; richer SQLite indexes necessarily expose their stored definition through `toSql()` because no canonical compiler representation can preserve them. Additive low-level methods need Laravel-style title docblocks without exposing internal execution machinery as a new user workflow. - Update the migration documentation in Laravel-docs prose with SQLite's transaction restriction and PostgreSQL's transaction-only constraint deferral. Correct its stale claim that Hypervel disables SQLite foreign keys by default; the application database config and database guide correctly document the enabled default. Keep low-level builder/grammar extension details in method docblocks and focused source comments rather than adding user-facing internals. -- Describe the public behavior corrections in the PR body: safe SQLite catalog cleanup replaces live file truncation; file-backed table cleanup now preserves views; no-argument file refresh rejects WAL and in-memory databases while resolving URI/relative paths canonically; `withoutForeignKeyConstraints()` deliberately bypasses overridable enable/disable methods so application query callbacks cannot veto physical-session restoration or log internal maintenance; mixed expression indexes report no simple-column projection; and comma-bearing column names no longer split into different indexed columns. +- Describe the public behavior corrections in the PR body: safe SQLite catalog cleanup replaces live file truncation; file-backed table cleanup now preserves views; no-argument file refresh rejects WAL and in-memory databases while resolving URI/relative paths canonically; `withoutForeignKeyConstraints()` deliberately bypasses overridable enable/disable methods so application query callbacks cannot veto physical-session restoration or log internal maintenance; `Schema::hasTable()` and the SQLite rebuild guards read schema and session state from the write connection; MySQL, MariaDB, and PostgreSQL drop-all cleanup reports failed statements; mixed expression indexes report no simple-column projection; and comma-bearing column names no longer split into different indexed columns. - Audit every final diff for overengineering, Laravel-style ergonomics, allocation/query/network overhead, coroutine and worker-lifetime safety, stale code, and duplicated execution paths. -- After signoff, present the reviewed branch to the owner for commit, push, and PR creation against `0.4`, with the engine-specific guarantees, compatibility boundaries, and verification results ready for the PR body. -- Record the byte-identical Laravel defects—the reversed internal-index predicate, substring column rename, ambiguous comma-joined index metadata, lost indexed-column/table-constraint/table-option semantics, raw-expression index rename crash, unchecked drop-all shape, and live file truncation under WAL—in the Hypervel PR, then prepare focused upstream reports or patches for separate owner approval before external submission. +- After signoff, present the reviewed changes to the owner for commit and push, then respond to the PR review with the engine-specific guarantees, compatibility boundaries, and verification results. +- Record the byte-identical Laravel defects—the reversed internal-index predicate, substring column rename, ambiguous comma-joined index metadata, lost indexed-column/table-constraint/table-option semantics, raw-expression index rename crash, unchecked drop-all shape, `withoutForeignKeyConstraints()` clobbering the caller's incoming state on every driver, drop-all cleanup ignoring false results on MySQL, MariaDB, and PostgreSQL, schema and session reads using the read connection while their writes use the write connection, and live file truncation under WAL—in the Hypervel PR, then prepare focused upstream reports or patches for separate owner approval before external submission. The PR is complete only when the full implementation review is signed off, every supported database path is green, no Blueprint or guarded schema-cleanup operation can silently report a false statement as success, and no unsafe physical session can return to the pool.