Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7f4d308
Support keyless models and preserve model attribute semantics
binaryfire Nov 1, 2026
04db7dc
Preserve pooled read routing and recover from dead PDO cleanup
binaryfire Nov 1, 2026
219a52c
Add JPEG XL image encoding and dimension support
binaryfire Nov 1, 2026
7521919
Support strict numeric validation rules
binaryfire Nov 1, 2026
f73e7fb
Remove the unavailable higher-order until proxy
binaryfire Nov 1, 2026
18e6b38
Name closure-defined one-of-many joins after the related table
binaryfire Nov 1, 2026
1c2ac5a
Use the upstream HealthCheck endpoint
binaryfire Nov 1, 2026
9626e61
Consolidate notification failure event coverage
binaryfire Nov 1, 2026
d5452c1
Cover quoted SQLite foreign-key targets during table changes
binaryfire Nov 1, 2026
c0fa60b
Clone Eloquent relationships without losing their constraints
binaryfire Nov 1, 2026
b670978
Cover unique listener locks after failed dispatch
binaryfire Nov 1, 2026
77cd2d3
Cover scheduled unique job failures
binaryfire Nov 1, 2026
7a2f1cc
Dispatch skip events for duplicate unique broadcasts
binaryfire Nov 1, 2026
53719fe
Bound transformed input keys per coroutine
binaryfire Nov 1, 2026
59f5ec4
Cover Redis TLS broken-pipe connection failures
binaryfire Nov 1, 2026
e446e9e
Normalize view namespace references before lookup
binaryfire Nov 1, 2026
72e3c95
Keep CI image manifests intact during registry cleanup
binaryfire Nov 1, 2026
30e5fc6
Preserve inherited model table and connection overrides
binaryfire Nov 1, 2026
04bf4a7
Keep one-of-many join aliases separate from schema names
binaryfire Nov 1, 2026
5852326
Rescue failures while preparing unique broadcasts
binaryfire Nov 1, 2026
b9158fc
Handle unavailable JPEG XL encoders in the image fixture
binaryfire Nov 1, 2026
f558311
Include the resource in the OAuth callback session fixture
binaryfire Nov 1, 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
3 changes: 3 additions & 0 deletions .github/workflows/ci-images.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Publish a plain manifest: cleanup deletes untagged versions,
# including the required children of an image index.
- name: Build image
env:
IMAGE: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.3.0-rc1
run: |
docker build \
--pull \
--provenance=false \
--build-arg PHP_VERSION=${{ matrix.php }} \
--tag "$IMAGE" \
--file .github/docker/ci/Dockerfile \
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@
},
"conflict": {
"aws/aws-sdk-php": "<3.369.1",
"intervention/image": "<4.0 || >=5.0",
"intervention/image": "<4.2 || >=5.0",
"league/flysystem-ftp": "<3.13",
"league/flysystem-sftp-v3": "<3.13"
},
Expand Down
20 changes: 15 additions & 5 deletions src/broadcasting/src/BroadcastManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Hypervel\Contracts\Bus\Dispatcher;
use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
use Hypervel\Contracts\Foundation\CachesRoutes;
use Hypervel\Contracts\ObjectPool\Factory as PoolFactory;
use Hypervel\Contracts\ObjectPool\InvalidatesPool;
Expand All @@ -41,6 +42,7 @@
use Hypervel\Queue\Attributes\Connection as ConnectionAttribute;
use Hypervel\Queue\Attributes\Queue as QueueAttribute;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
use Hypervel\Queue\Events\UniqueJobSkipped;
use Hypervel\Redis\RedisConfig;
use Hypervel\Routing\Router;
use Hypervel\Support\Arr;
Expand Down Expand Up @@ -235,10 +237,6 @@ public function queue(mixed $event): void
? new UniqueBroadcastEvent($event instanceof UnitEnum ? $event : clone $event)
: new BroadcastEvent($event instanceof UnitEnum ? $event : clone $event);

if ($event instanceof ShouldBeUnique && $this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) {
return;
}

$push = $synchronousMercure
? fn () => $this->dispatchSynchronousBroadcast($broadcastEvent)
: fn () => $this->app->make(Queue::class)
Expand All @@ -251,7 +249,19 @@ public function queue(mixed $event): void

if ($event instanceof ShouldBeUnique) {
$dispatch = $push;
$push = static function () use ($broadcastEvent, $dispatch): mixed {
$push = function () use ($broadcastEvent, $dispatch): mixed {
// Acquisition and skip listeners can fail too, so keep them inside the rescue boundary.
if ($this->mustBeUniqueAndCannotAcquireLock($broadcastEvent)) {
/** @var EventDispatcher $events */
$events = $this->app->make('events');

if ($events->hasListeners(UniqueJobSkipped::class)) {
$events->dispatch(new UniqueJobSkipped($broadcastEvent));
}

return null;
}

try {
return $dispatch();
} finally {
Expand Down
2 changes: 0 additions & 2 deletions src/collections/src/Traits/EnumeratesValues.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
* @property-read HigherOrderCollectionProxy<'takeWhile', TValue, static> $takeWhile
* @property-read HigherOrderCollectionProxy<'unique', TValue, static> $unique
* @property-read HigherOrderCollectionProxy<'unless', TValue, static> $unless
* @property-read HigherOrderCollectionProxy<'until', TValue, static> $until
* @property-read HigherOrderCollectionProxy<'when', TValue, static> $when
*/
trait EnumeratesValues
Expand Down Expand Up @@ -106,7 +105,6 @@ trait EnumeratesValues
'takeWhile',
'unique',
'unless',
'until',
'when',
];

Expand Down
3 changes: 2 additions & 1 deletion src/database/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Documentation: https://hypervel.org/docs/database
- `chunkById` and `lazyById` prioritize the paging column, including when stopping early. Limited or offset calls led by another order throw instead of selecting a different set, including when a global scope uses `reorder`; choose ranked subsets with `fromSub` first. The descending variants follow the same rule. See [chunking results](https://hypervel.org/docs/queries#chunking-results).
- Outgoing framework HTTP requests return idle database sessions to their pools. Code using temporary tables, session locks or retained raw PDOs across these calls must use `withPinnedSession()`; ordinary queries and active transactions keep their behavior. See [releasing and pinning connections](https://hypervel.org/docs/database#releasing-and-pinning-connections).
- MySQL and MariaDB connections require `strict` or `modes` in their configuration instead of implicitly inheriting the server's SQL mode, so SQL literals can be escaped correctly.
- Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix.
- Laravel's external database pooler support uses a `::direct` connection suffix. Hypervel instead uses normal named connections for each endpoint and `migrations_connection` for schema and migration paths. This keeps direct and pooled endpoints as normal configured connections with their own pool settings, so Hypervel does not support Laravel's `::direct` suffix or its direct PDO methods such as `getDirectPdo()`.
- Laravel exposes PDO methods on its base connection class. Hypervel's base `Connection` is driver-neutral, while PDO-backed drivers extend `PdoConnection`. Code that requires direct PDO access should accept or narrow to `PdoConnection`. When bringing in Laravel database updates, keep driver-neutral connection behavior on `Connection`, PDO mechanics on `PdoConnection`, and driver-specific behavior on the matching connection, grammar, schema builder, or processor.
- Hypervel provides `BinaryParameter` for explicitly binding already-encoded binary strings in query builder operations and Eloquent key helpers. Laravel has no equivalent wrapper.
- Laravel's `migrate:fresh` command wipes only its selected connection. Hypervel discovers the connection declared by each migration and wipes every resolved target before rebuilding the schema.
Expand All @@ -29,6 +29,7 @@ Documentation: https://hypervel.org/docs/database
- `make:migration` omits Laravel's deprecated `--fullpath` option and obsolete Composer constructor dependency because migration creation no longer dumps autoload files.
- `Blueprint::dropForeign()` widens Laravel's method signature with an optional constraint name when columns are supplied, allowing explicitly named foreign keys to be dropped portably across SQLite and the server databases. Custom `Blueprint` subclasses that override this method must accept the optional second argument.
- Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error.
- When an inherited `Table` or `Connection` attribute overrides an ancestor's property, assigning that property's exact default before `parent::__construct()` does not override the attribute. Set runtime table/connection values after parent construction.
- Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`.
- Eloquent's protected `refreshSavedAttributes()` method receives the raw attribute values each write stored, so configured attributes are refreshed from the row that write changed. Models that override this method must accept the `array $attributes` argument.
- Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp.
Expand Down
21 changes: 21 additions & 0 deletions src/database/src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -1379,6 +1379,27 @@ public function useWriteConnectionWhenReading(bool $value = true): static
return $this;
}

/**
* Execute the given callback using the write connection for reads.
*
* @template TReturn
*
* @param Closure($this): TReturn $callback
* @return TReturn
*/
public function usingWritePdo(Closure $callback): mixed
{
$readOnWriteConnection = $this->readOnWriteConnection;

$this->useWriteConnectionWhenReading();

try {
return $callback($this);
} finally {
$this->useWriteConnectionWhenReading($readOnWriteConnection);
}
}

/**
* Resolve and record the connection role for an operation.
*
Expand Down
34 changes: 26 additions & 8 deletions src/database/src/ConnectionResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,15 +163,23 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
}

/**
* Check the current connection's transaction state without borrowing a pool slot.
* Get the current execution's connection without borrowing a pool slot.
*/
public function hasApplicableTransaction(UnitEnum|string|null $name = null): bool
public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface
{
$connectionName = ConnectionName::parse($this->connectionName($name));
return $this->getContextConnection($this->connectionName($name));
}

/**
* Find the context-owned connection, including shared in-memory aliases.
*/
private function getContextConnection(string $name): ?ConnectionInterface
{
$connectionName = ConnectionName::parse($name);
$held = CoroutineContext::get($this->getContextKey($connectionName->requested));

if ($held instanceof Connection) {
return $held->hasApplicableTransaction();
if ($held instanceof ConnectionInterface) {
return $held;
}

$pool = $this->poolManager->existing($connectionName->requested);
Expand All @@ -180,12 +188,22 @@ public function hasApplicableTransaction(UnitEnum|string|null $name = null): boo
if ($pool?->getSharedInMemorySqlitePdo() !== null) {
$held = CoroutineContext::get($this->getContextKey($pool->getName()));

if ($held instanceof Connection) {
return $held->hasApplicableTransaction();
if ($held instanceof ConnectionInterface) {
return $held;
}
}

return false;
return null;
}

/**
* Check the current connection's transaction state without borrowing a pool slot.
*/
public function hasApplicableTransaction(UnitEnum|string|null $name = null): bool
{
$held = $this->getContextConnection($this->connectionName($name));

return $held instanceof Connection && $held->hasApplicableTransaction();
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/database/src/DatabaseManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ public function connection(UnitEnum|string|null $name = null): ConnectionInterfa
return $this->app->make('db.resolver')->connection($name);
}

/**
* Get the connection already held by the current resolver.
*
* Resolvers without connection inspection support resolve normally.
*/
public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface
{
if ($name instanceof UnitEnum) {
$name = (string) enum_value($name);
}

$name = $name === null || $name === ''
? $this->getDefaultConnection()
: $name;

$resolver = $this->app->make('db.resolver');

return $resolver instanceof ConnectionResolver
? $resolver->getHeldConnection($name)
: $resolver->connection($name);
}

/**
* Determine whether a connection has an applicable transaction without borrowing a pool slot.
*/
Expand Down
2 changes: 1 addition & 1 deletion src/database/src/Eloquent/Attributes/Table.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Table
*/
public function __construct(
public ?string $name = null,
public ?string $key = null,
public string|false|null $key = null,
public ?string $keyType = null,
public ?bool $incrementing = null,
public ?bool $timestamps = null,
Expand Down
6 changes: 6 additions & 0 deletions src/database/src/Eloquent/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ public function merge($items): static

if ($key !== null) {
$dictionary[$key] = $item;
} elseif ($item->getKeyName() === null) {
// Match getDictionary() without allocating a second dictionary for incoming models.
throw new LogicException('No primary key defined on model.');
}
}

Expand Down Expand Up @@ -762,6 +765,9 @@ public function getDictionary(?iterable $items = null): array

if ($key !== null) {
$dictionary[$key] = $value;
} elseif ($value->getKeyName() === null) {
// Keyless results cannot be deduplicated by identity. Unsaved keyed models still skip.
throw new LogicException('No primary key defined on model.');
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/database/src/Eloquent/Concerns/QueriesRelationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,14 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s
*/
public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static
{
return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count)
->with($callback ? [$relation => static fn ($query) => $callback($query)] : $relation);
if ($callback === null) {
return $this->whereHas(Str::before($relation, ':'), null, $operator, $count)->with($relation);
}

[$name, $select] = $this->parseNameAndAttributeSelectionConstraint($relation);

return $this->whereHas($name, $callback, $operator, $count)
->with([$name => $this->combineConstraints([$select, static fn ($query) => $callback($query)])]);
}

/**
Expand Down
Loading
Loading