Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions docs/plans/2026-08-09-0555-database-schema-execution-safety.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/boost/docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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.

<a name="events"></a>
## Events
Expand Down
58 changes: 53 additions & 5 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/database/src/Pool/PooledConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
59 changes: 57 additions & 2 deletions src/database/src/Query/Processors/SQLiteProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace Hypervel\Database\Query\Processors;

use Hypervel\Support\Arr;
use Override;
use UnexpectedValueException;

class SQLiteProcessor extends Processor
{
Expand Down Expand Up @@ -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<array{name: string, physical_name: string, columns: list<string>, type: null|string, unique: bool, primary: bool, sql: null|string, origin: null|string, reconstructible: bool, collations: null|list<string>, descending: null|list<bool>}>
*/
public function processIndexesForSchemaState(array $results): array
{
$primaryCount = 0;

Expand All @@ -69,18 +88,54 @@ 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);

if ($primaryCount > 1) {
$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<string>
*/
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]
Expand Down
4 changes: 1 addition & 3 deletions src/database/src/Schema/Blueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
Loading