Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c309774
Centralize connection routing and streaming execution
binaryfire Sep 6, 2026
ce00000
Preserve database builder extension types
binaryfire Sep 6, 2026
ef4e0d1
Delegate database testing schema operations to drivers
binaryfire Sep 6, 2026
7ef1a21
Preserve literal configuration URL components
binaryfire Sep 6, 2026
9cd85b7
Retain complete endpoint configuration for custom drivers
binaryfire Sep 6, 2026
7bc8fef
Support custom database command-line clients
binaryfire Sep 6, 2026
2450c1d
Document database driver extension contracts
binaryfire Sep 6, 2026
458a0f6
Keep Laravel porting guidance focused on compatibility
binaryfire Sep 6, 2026
c25abfa
Require Laravel-style configuration section headings
binaryfire Sep 6, 2026
d0d6a3a
Preserve closed-stream termination in database execution
binaryfire Sep 6, 2026
7ead270
Generalize query embedding validation
binaryfire Sep 6, 2026
f8d273c
Preserve query predicate operands and iterable bounds
binaryfire Sep 6, 2026
d14fdc0
Preserve nested join parents and value operands
binaryfire Sep 6, 2026
bd9c404
Merge branch '0.4' into feature/database-extensibility
binaryfire Sep 6, 2026
b9a0ea2
Preserve single-row intent when inserting non-incrementing models
binaryfire Sep 6, 2026
95e5afb
Fix derived pagination count routing and bindings
binaryfire Sep 6, 2026
98cad5e
Prepare pagination counts after before-query callbacks
binaryfire Sep 6, 2026
7c8987c
Merge 0.4 into feature/database-extensibility
binaryfire Sep 9, 2026
190688f
fix(testbench): retain class cleanup when setup aborts
binaryfire Sep 9, 2026
9a8419c
Preserve single-row attributes when saving models with conflict handling
binaryfire Sep 9, 2026
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Build complete, long-term solutions, not MVPs or local workarounds. A broad chan

These rules apply to all code, including ported code.

- **Laravel-style config files** — Group related settings under Laravel's standard section comment blocks, with concise, user-facing explanations.
- Always use typed getters for values with one non-null type. Only use `get()` when null, union, or mixed values are meaningful. Add a test for any supported null behavior.
- Cast environment-backed booleans and numbers in config files; if `null` is supported, cast only non-null values. Consumers must not repeat those casts. When a factory accepts raw configuration records, normalize types and documented optional defaults once at that boundary; never supply missing required members.
- Required settings live in shipped config and must not have a code-level fallback, so missing or misspelled keys fail loudly.
Expand Down
137 changes: 120 additions & 17 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,84 @@ protected function run(string $query, array $bindings, Closure $callback): mixed
return $result;
}

/**
* Run a streaming SQL statement and log only its complete execution.
*
* @template TKey of array-key
* @template TValue
*
* @param Closure(string, array): iterable<TKey, TValue> $callback
* @return Generator<TKey, TValue>
*
* @throws CanceledException
* @throws QueryException
* @throws StreamClosedException
*/
protected function runStreaming(string $query, array $bindings, Closure $callback): Generator
{
foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
$beforeExecutingCallback($query, $bindings, $this);
}

$this->reconnectIfMissingConnection();

$start = hrtime(true) / 1e9;
$hasYielded = false;

$execute = function (string $query, array $bindings) use ($callback, &$hasYielded): Generator {
try {
foreach ($callback($query, $bindings) as $key => $value) {
$readWriteType = $this->latestReadWriteTypeRetrieved;
$hasYielded = true;

try {
yield $key => $value;
} finally {
// A consumer may run another query while this operation is suspended.
$this->latestReadWriteTypeRetrieved = $readWriteType;
}
}
} catch (CanceledException|StreamClosedException $exception) {
throw $exception;
} catch (Exception $exception) {
++$this->errorCount;

throw $this->newQueryException($query, $bindings, $exception);
}
};

try {
try {
yield from $execute($query, $bindings);
} catch (QueryException $exception) {
if ($hasYielded) {
throw $exception;
}

yield from $this->handleQueryException($exception, $query, $bindings, $execute);
}
} catch (CanceledException|StreamClosedException $exception) {
throw $exception;
} catch (Throwable $exception) {
$events = $this->events;

if ($events?->hasListeners(QueryFailed::class)) {
$events->dispatch(new QueryFailed(
$query,
$bindings,
$this->getElapsedTime($start),
$this,
$exception,
$this->latestReadWriteTypeUsed(),
));
}

throw $exception;
}

$this->logQuery($query, $bindings, $this->getElapsedTime($start));
}

/**
* Run a SQL statement.
*
Expand All @@ -624,28 +702,36 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal
} catch (Exception $e) {
++$this->errorCount;

$exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($e))
? UniqueConstraintViolationException::class
: QueryException::class;
throw $this->newQueryException($query, $bindings, $e);
}
}

$queryException = new $exceptionType(
$this->getName(),
$query,
$this->prepareBindings($bindings),
$e,
$this->getConnectionDetails(),
$this->latestReadWriteTypeUsed(),
$this->getConfig('mask_bindings_in_exception_messages'),
);
/**
* Create an exception containing the query and connection context.
*/
protected function newQueryException(string $query, array $bindings, Exception $previous): QueryException
{
$exceptionType = ($isUniqueConstraintError = $this->isUniqueConstraintError($previous))
? UniqueConstraintViolationException::class
: QueryException::class;

if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) {
['index' => $index, 'columns' => $columns] = $this->parseUniqueConstraintViolation($e);
$queryException = new $exceptionType(
$this->getName(),
$query,
$this->prepareBindings($bindings),
$previous,
$this->getConnectionDetails(),
$this->latestReadWriteTypeUsed(),
$this->getConfig('mask_bindings_in_exception_messages'),
);

$queryException->setIndex($index)->setColumns($columns);
}
if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) {
['index' => $index, 'columns' => $columns] = $this->parseUniqueConstraintViolation($previous);

throw $queryException;
$queryException->setIndex($index)->setColumns($columns);
}

return $queryException;
}

/**
Expand Down Expand Up @@ -1196,6 +1282,23 @@ public function useWriteConnectionWhenReading(bool $value = true): static
return $this;
}

/**
* Resolve and record the connection role for an operation.
*
* @return 'read'|'write'
*/
protected function resolveReadWriteType(bool $read = true): string
{
if ($read
&& $this->transactions === 0
&& ! $this->readOnWriteConnection
&& ! ($this->recordsModified && $this->getConfig('sticky'))) {
return $this->latestReadWriteTypeRetrieved = 'read';
}

return $this->latestReadWriteTypeRetrieved = 'write';
}

/**
* Invalidate the state remembered for the current physical session.
*/
Expand Down
17 changes: 13 additions & 4 deletions src/database/src/Connectors/ConnectionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,7 @@ public function make(array $config, ?string $name = null): Connection
// Next we will check to see if an extension has been registered for a driver
// and will call the Closure if so, which allows us to have a more generic
// resolver for the drivers themselves which applies to all connections.
$driver = $config['driver'] ?? null;
$resolver = $name !== null && isset($this->extensions[$name])
? $this->extensions[$name]
: ($driver !== null ? $this->extensions[$driver] ?? null : null);
$resolver = $this->getExtension($config, $name);

if ($resolver !== null) {
$connection = call_user_func($resolver, $config, $name);
Expand All @@ -68,6 +65,18 @@ public function make(array $config, ?string $name = null): Connection
return $this->createPdoConnectionFromConfig($config);
}

/**
* Get the extension resolver for a connection configuration.
*/
public function getExtension(array $config, ?string $name): ?callable
{
$driver = $config['driver'] ?? null;

return $name !== null && isset($this->extensions[$name])
? $this->extensions[$name]
: ($driver !== null ? $this->extensions[$driver] ?? null : null);
}

/**
* Register an extension connection resolver.
*
Expand Down
54 changes: 37 additions & 17 deletions src/database/src/Console/DbCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Hypervel\Console\Command;
use Hypervel\Database\ConfigurationUrlParser;
use Hypervel\Database\DatabaseCliConfiguration;
use Hypervel\Database\DatabaseCliManager;
use Hypervel\Support\Arr;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Process\Exception\ProcessFailedException;
Expand Down Expand Up @@ -33,27 +35,38 @@ class DbCommand extends Command
public function handle(): int
{
$connection = $this->getConnection();
$configuration = $this->hypervel->make(DatabaseCliManager::class)->resolve($connection);

if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') {
$this->components->error('No host specified for this database connection.');
$this->line(' Use the <options=bold>[--read]</> and <options=bold>[--write]</> options to specify a read or write connection.');
$this->newLine();
if ($configuration === null) {
$command = $this->getCommand($connection);

return Command::FAILURE;
if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') {
$this->components->error('No host specified for this database connection.');
$this->line(' Use the <options=bold>[--read]</> and <options=bold>[--write]</> options to specify a read or write connection.');
$this->newLine();

return Command::FAILURE;
}

$configuration = new DatabaseCliConfiguration(
$command,
$this->commandArguments($connection),
$this->commandEnvironment($connection) ?? [],
);
}

try {
(new Process(
array_merge([$command = $this->getCommand($connection)], $this->commandArguments($connection)),
array_merge([$configuration->command], $configuration->arguments),
null,
$this->commandEnvironment($connection)
$configuration->environment
))->setTimeout(null)->setTty(true)->mustRun(function ($type, $buffer) {
$this->output->write($buffer);
});
} catch (ProcessFailedException $e) {
throw_unless($e->getProcess()->getExitCode() === 127, $e);

$this->error("{$command} not found in path.");
$this->error("{$configuration->command} not found in path.");

return Command::FAILURE;
}
Expand Down Expand Up @@ -86,6 +99,10 @@ public function getConnection(): array
$connection = $this->mergeConnectionConfiguration($connection, 'write');
}

if (is_array($connection['host'] ?? null)) {
$connection['host'] = $connection['host'][0] ?? null;
}

return $connection;
}

Expand All @@ -104,14 +121,18 @@ protected function mergeConnectionConfiguration(array $connection, string $type)
$merge = $merge[0];
}

if (! empty($merge['url'])) {
$merge = (new ConfigurationUrlParser)->parseConfiguration($merge);
}

if (is_array($merge['host'] ?? null)) {
$merge['host'] = $merge['host'][0];
$merge['host'] = $merge['host'][0] ?? null;
}

$connection = array_merge($connection, $merge);

if (is_array($connection['host'] ?? null)) {
$connection['host'] = $connection['host'][0];
$connection['host'] = $connection['host'][0] ?? null;
}

return Arr::except($connection, ['read', 'write']);
Expand Down Expand Up @@ -146,12 +167,15 @@ public function commandEnvironment(array $connection): ?array
*/
public function getCommand(array $connection): string
{
return [
return match ($connection['driver']) {
'mysql' => 'mysql',
'mariadb' => 'mariadb',
'pgsql' => 'psql',
'sqlite' => 'sqlite3',
][$connection['driver']];
default => throw new UnexpectedValueException(
"Unsupported database CLI driver [{$connection['driver']}]. Register a resolver using DatabaseCliManager::extend()."
),
};
}

/**
Expand All @@ -165,10 +189,6 @@ protected function getMysqlArguments(array $connection): array
'charset' => '--default-character-set=' . ($connection['charset'] ?? ''),
];

if (! $connection['password']) {
unset($optionalArguments['password']);
}

return array_merge([
'--host=' . $connection['host'],
'--port=' . $connection['port'],
Expand Down Expand Up @@ -219,7 +239,7 @@ protected function getPgsqlEnvironment(array $connection): ?array
protected function getOptionalArguments(array $args, array $connection): array
{
return array_values(array_filter($args, function ($key) use ($connection) {
return ! empty($connection[$key]);
return isset($connection[$key]) && $connection[$key] !== '';
}, ARRAY_FILTER_USE_KEY));
}
}
21 changes: 21 additions & 0 deletions src/database/src/DatabaseCliConfiguration.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database;

readonly class DatabaseCliConfiguration
{
/**
* Create a database client configuration.
*
* @param list<string> $arguments
* @param array<string, scalar> $environment
*/
public function __construct(
public string $command,
public array $arguments = [],
public array $environment = [],
) {
}
}
40 changes: 40 additions & 0 deletions src/database/src/DatabaseCliManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace Hypervel\Database;

class DatabaseCliManager
{
/**
* The registered database client resolvers.
*
* @var array<string, callable(array): DatabaseCliConfiguration>
*/
protected array $extensions = [];

/**
* Register a database client resolver.
*
* Boot-only. The resolver persists on the auto-singleton manager for the
* worker lifetime and applies to every subsequent database CLI session.
*
* @param callable(array): DatabaseCliConfiguration $resolver
*/
public function extend(string $driver, callable $resolver): void
{
$this->extensions[$driver] = $resolver;
}

/**
* Resolve a registered database client configuration.
*/
public function resolve(array $connection): ?DatabaseCliConfiguration
{
if (! isset($this->extensions[$connection['driver']])) {
return null;
}

return ($this->extensions[$connection['driver']])($connection);
}
}
Loading