diff --git a/.github/workflows/ci-images.yml b/.github/workflows/ci-images.yml index 820ad2d301..d30f1319ce 100644 --- a/.github/workflows/ci-images.yml +++ b/.github/workflows/ci-images.yml @@ -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 \ diff --git a/composer.json b/composer.json index 0f1a215259..52bd58207e 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/src/broadcasting/src/BroadcastManager.php b/src/broadcasting/src/BroadcastManager.php index 1340780a20..780e1e164c 100644 --- a/src/broadcasting/src/BroadcastManager.php +++ b/src/broadcasting/src/BroadcastManager.php @@ -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; @@ -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; @@ -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) @@ -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 { diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 0afed69226..70debc67ba 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -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 @@ -106,7 +105,6 @@ trait EnumeratesValues 'takeWhile', 'unique', 'unless', - 'until', 'when', ]; diff --git a/src/database/README.md b/src/database/README.md index 3391fe76fd..1d54e194aa 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -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. @@ -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. diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index 49febe5ca9..47bb9689d9 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -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. * diff --git a/src/database/src/ConnectionResolver.php b/src/database/src/ConnectionResolver.php index f9f8cd8fe1..f2f5784be7 100755 --- a/src/database/src/ConnectionResolver.php +++ b/src/database/src/ConnectionResolver.php @@ -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); @@ -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(); } /** diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php index 10635c2442..ab50005498 100755 --- a/src/database/src/DatabaseManager.php +++ b/src/database/src/DatabaseManager.php @@ -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. */ diff --git a/src/database/src/Eloquent/Attributes/Table.php b/src/database/src/Eloquent/Attributes/Table.php index 99b343b394..99034fbc03 100644 --- a/src/database/src/Eloquent/Attributes/Table.php +++ b/src/database/src/Eloquent/Attributes/Table.php @@ -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, diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 09a2d1b308..eb38ce687c 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -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.'); } } @@ -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.'); } } diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index dd7426e492..387ac6fb58 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -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)])]); } /** diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index d8ffe48e12..f32a34c77a 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -52,6 +52,7 @@ use LogicException; use ReflectionClass; use ReflectionMethod; +use ReflectionProperty; use RuntimeException; use Stringable; use Throwable; @@ -119,7 +120,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The primary key for the model. */ - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; /** * The "type" of the primary key ID. @@ -312,13 +313,14 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt protected static array $classAttributes = []; /** - * Cache of whether each class directly declares each class attribute. + * Cache of whether class attributes override inherited properties. * - * Both positive and negative results are retained for the worker lifetime. + * True overrides unconditionally; false keeps the property. Other values + * are inherited property defaults that may be replaced if still unchanged. * - * @var array + * @var array */ - protected static array $classDeclaredAttributes = []; + protected static array $classAttributeOverrides = []; /** * Cache of the nearest class declaring each property. @@ -538,18 +540,22 @@ public function initializeModelAttributes(): void /** @var null|Table $table */ $table = static::resolveClassAttribute(Table::class); - $declaresTable = static::classPropertyDeclarer('table') === static::class; - - if (! $declaresTable && static::classDeclaresAttribute(Table::class)) { - $this->table = $table->name ?? null; + // Keep an attribute's override in descendants, and do not clear an inherited + // table when Table only configures other model settings. + if ($table?->name !== null && $this->classAttributeOverridesProperty(Table::class, 'table')) { + $this->table = $table->name; } else { $this->table ??= $table->name ?? null; } - $this->connection ??= static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + if ($this->classAttributeOverridesProperty(ConnectionAttribute::class, 'connection')) { + $this->connection = static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + } else { + $this->connection ??= static::resolveClassAttribute(ConnectionAttribute::class, 'name'); + } if ($this->primaryKey === 'id' && $table && $table->key !== null) { - $this->primaryKey = $table->key; + $this->primaryKey = $table->key === false ? null : $table->key; } if ($this->keyType === 'int' && $table && $table->keyType !== null) { @@ -597,7 +603,7 @@ public static function clearBootedModels(): void static::$booted = []; static::$bootedCallbacks = []; static::$classAttributes = []; - static::$classDeclaredAttributes = []; + static::$classAttributeOverrides = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; self::flushScopeCaches(); @@ -1727,7 +1733,8 @@ protected function getKeyForSaveQuery(): mixed */ private function getRawKeyForQuery(): mixed { - $keyName = $this->getKeyName(); + // Keyless models can be inserted, but these queries need a row identity. + $keyName = $this->getKeyName() ?? throw new LogicException('No primary key defined on model.'); return $this->original[$keyName] ?? ($this->isBinaryCast($this->getCasts()[$keyName] ?? null) && array_key_exists($keyName, $this->attributes) @@ -1950,7 +1957,6 @@ public function delete(): int|bool|null { $this->mergeAttributesFromCachedCasts(); - // @phpstan-ignore function.impossibleType (defensive: users may set $primaryKey = null) if (is_null($this->getKeyName())) { throw new LogicException('No primary key defined on model.'); } @@ -2577,18 +2583,45 @@ protected static function resolveClassAttribute(string $attributeClass, ?string } /** - * Determine whether a class directly declares an attribute. + * Determine whether a class attribute should override the property. + * + * Direct attributes override inherited properties. Inherited attributes only + * replace more distant property defaults, keeping different values assigned + * during construction. Assignments equal to a default are indistinguishable + * from untouched defaults and are replaced. * * @param class-string $attributeClass + * @param 'connection'|'table' $property */ - protected static function classDeclaresAttribute(string $attributeClass): bool + protected function classAttributeOverridesProperty(string $attributeClass, string $property): bool { $class = static::class; - $cacheKey = $class . '@' . $attributeClass; + $cacheKey = $class . '@' . $attributeClass . '@' . $property; + + if (! isset(static::$classAttributeOverrides[$cacheKey])) { + $declarer = static::classPropertyDeclarer($property); + $reflection = new ReflectionClass($class); + $override = false; + + do { + if ($reflection->getName() === $declarer) { + break; + } + + if ($reflection->getAttributes($attributeClass) !== []) { + $override = $reflection->getName() === $class + ? true + : ((new ReflectionProperty($class, $property))->getDefaultValue() ?? false); + break; + } + } while ($reflection = $reflection->getParentClass()); + + static::$classAttributeOverrides[$cacheKey] = $override; + } + + $override = static::$classAttributeOverrides[$cacheKey]; - return static::$classDeclaredAttributes[$cacheKey] ??= ( - new ReflectionClass($class) - )->getAttributes($attributeClass) !== []; + return $override === true || ($override !== false && $override === $this->{$property}); } /** @@ -2633,7 +2666,7 @@ public function setTable(string $table): static /** * Get the primary key for the model. */ - public function getKeyName(): string + public function getKeyName(): ?string { return $this->primaryKey; } @@ -2641,7 +2674,7 @@ public function getKeyName(): string /** * Set the primary key for the model. */ - public function setKeyName(string $key): static + public function setKeyName(?string $key): static { $this->primaryKey = $key; @@ -2683,7 +2716,8 @@ public function setKeyType(string $type): static */ public function getIncrementing(): bool { - return $this->incrementing; + // A keyless insert must not try to store a generated ID after writing the row. + return $this->incrementing && $this->getKeyName() !== null; } /** @@ -2703,7 +2737,7 @@ public function setIncrementing(bool $value): static */ public function getKey(): mixed { - return $this->getAttribute($this->getKeyName()); + return ($key = $this->getKeyName()) !== null ? $this->getAttribute($key) : null; } /** @@ -3021,7 +3055,7 @@ public static function flushState(): void static::$globalScopes = []; static::$resolvesDefaultConnections = []; static::$classAttributes = []; - static::$classDeclaredAttributes = []; + static::$classAttributeOverrides = []; static::$classPropertyDeclarers = []; static::$guardConfigurations = []; self::flushScopeCaches(); diff --git a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php index 65baa2d5b2..726ea926e4 100644 --- a/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php +++ b/src/database/src/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -164,8 +164,12 @@ public function oldestOfMany(string|array|null $column = 'id', ?string $relation */ protected function getDefaultOneOfManyJoinAlias(string $relation): string { - return $relation == $this->query->getModel()->getTable() - ? $relation . '_of_many' + // Schema qualification is not part of an alias, but the table name still + // needs a distinct join alias when the relation is named after that table. + $table = last(explode('.', $this->query->getModel()->getTable())); + + return last(explode('.', $relation)) === $table + ? $table . '_of_many' : $relation; } @@ -285,7 +289,9 @@ protected function qualifyRelatedColumn(string $column): string */ protected function guessRelationship(): string { - return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + $relation = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + + return str_contains($relation, '{closure') ? $this->query->getModel()->getTable() : $relation; } /** diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 4e6753f938..b48754526d 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -624,6 +624,14 @@ public static function getMorphAlias(string $className): int|string return $alias !== false ? $alias : $className; } + /** + * Clone the relationship. + */ + public function clone(): static + { + return clone $this; + } + /** * Flush all static state. */ diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index 5796fb5949..b398fcda39 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -22,20 +22,25 @@ public function causedByLostConnection(Throwable $e): bool $e = $e->getPrevious(); } - // mysqlnd can report protocol failures as PHP warnings even in exception mode. + $message = $e->getMessage(); + + // mysqlnd can report protocol and TLS failures as PHP warnings even in exception mode. if ($e instanceof ErrorException) { - return Str::startsWith($e->getMessage(), [ + if (Str::startsWith($message, [ 'Packets out of order. Expected', 'Error while sending', - ]); - } + ])) { + return true; + } - if (! $e instanceof PDOException && ! $e instanceof LostConnectionException) { + // Only native PDO TLS warnings may use the connection-loss reasons below. + if (! preg_match('/^PDO(?:Statement)?::\w+\(\): SSL(?:: | operation failed with code )/', $message)) { + return false; + } + } elseif (! $e instanceof PDOException && ! $e instanceof LostConnectionException) { return false; } - $message = $e->getMessage(); - return Str::contains($message, [ 'server has gone away', 'Server has gone away', diff --git a/src/database/src/PdoConnection.php b/src/database/src/PdoConnection.php index 39a20c9263..4fd6b872aa 100755 --- a/src/database/src/PdoConnection.php +++ b/src/database/src/PdoConnection.php @@ -364,9 +364,14 @@ public function getPdo(): PDO $pdo = $this->resolvePdo(); // Transaction and cleanup failures can invalidate a PDO even without session configurators. - return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) - ? $pdo - : $this->synchronizeSession($pdo, read: false); + if (static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo)) { + return $pdo; + } + + // Replacement must release the old handle through the setters, not this frame. + unset($pdo); + + return $this->synchronizeSession(read: false); } /** @@ -388,9 +393,13 @@ public function getReadPdo(): PDO $pdo = $this->resolveReadPdo(); - return static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo) - ? $pdo - : $this->synchronizeSession($pdo, read: true); + if (static::$sessionConfigurators === [] && ! static::sessionStateIsUnknown($pdo)) { + return $pdo; + } + + unset($pdo); + + return $this->synchronizeSession(read: true); } /** @@ -401,6 +410,10 @@ public function getRawReadPdo(): PDO|Closure|null return $this->readPdo; } + // Laravel's direct-PDO methods, including getDirectPdo(), are intentionally omitted. + // Direct endpoints use named connections so their pools own the handles and cleanup; + // migrations select that connection through migrations_connection. + /** * Resolve the current write PDO without synchronizing session state. */ @@ -483,8 +496,9 @@ public function detachPdoResources(Closure $pdo, ?Closure $readPdo): void /** * Synchronize the desired state for a physical database session. */ - protected function synchronizeSession(PDO $pdo, bool $read): PDO + protected function synchronizeSession(bool $read): PDO { + $pdo = $read ? $this->resolveReadPdo() : $this->resolvePdo(); $sessionState = static::physicalSessionState($pdo); if ($sessionState->configuring) { @@ -497,6 +511,7 @@ protected function synchronizeSession(PDO $pdo, bool $read): PDO $sessionState->configuring = true; try { + unset($pdo); $pdo = $this->replaceUnknownSession($read); } finally { $sessionState->configuring = false; @@ -695,7 +710,8 @@ public function setPdo(PDO|Closure|null $pdo): static $this->lockForPopping = null; $this->maxBindings = null; - $this->pdo = $pdo; + // A dead PDO may warn during destruction; that must not interrupt replacement. + @$this->pdo = $pdo; return $this; } @@ -705,7 +721,8 @@ public function setPdo(PDO|Closure|null $pdo): static */ public function setReadPdo(PDO|Closure|null $pdo): static { - $this->readPdo = $pdo; + // Suppress only handle destruction, leaving callbacks and rollback errors visible. + @$this->readPdo = $pdo; return $this; } @@ -760,6 +777,7 @@ protected function disconnectDriverResources(): void } } } finally { + unset($pdo, $readPdo, $handle); $this->forgetDriverResources(); } diff --git a/src/docs/broadcasting.md b/src/docs/broadcasting.md index 9b229a8e4b..6569165a42 100644 --- a/src/docs/broadcasting.md +++ b/src/docs/broadcasting.md @@ -961,6 +961,8 @@ If you would like all of your broadcast events to use the same queue without cus Broadcast events implementing `Hypervel\Contracts\Broadcasting\ShouldBeUnique` also support the `Hypervel\Queue\Attributes\UniqueFor` attribute to configure the lock's lifetime in seconds, just like [unique jobs](/docs/{{version}}/queues#unique-jobs). +When a unique broadcast is skipped because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event if an active listener is registered. Its `job` property contains the `UniqueBroadcastEvent` wrapper, whose `event` property contains your broadcast event. + If you would like to broadcast your event using the `sync` queue instead of the default queue driver, you can implement the `ShouldBroadcastNow` interface instead of `ShouldBroadcast`: ```php diff --git a/src/docs/database.md b/src/docs/database.md index 10ca985ec0..c9d4b4194d 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -9,6 +9,8 @@ - [PostgreSQL Keepalives](#postgresql-keepalives) - [PostgreSQL Server Options](#postgresql-server-options) - [Read and Write Connections](#read-and-write-connections) + - [Preventing Stale Database Reads](#preventing-stale-database-reads) + - [Using the Write Connection for Reads](#using-the-write-connection-for-reads) - [Connection Pooling](#connection-pooling) - [Releasing and Pinning Connections](#releasing-and-pinning-connections) - [Configuring Database Session State](#configuring-database-session-state) @@ -248,6 +250,47 @@ The `sticky` option is an *optional* value that can be used to allow the immedia A long-running queue worker gives each job its own coroutine, so one job's writes do not send later jobs' reads to the write connection. + +#### Preventing Stale Database Reads + +While the `sticky` option only affects the current request, the `Hypervel\Foundation\Http\Middleware\PreventStaleDatabaseReads` middleware allows subsequent requests in the same session to read recently written data from the write connection. This can help prevent users from seeing stale data while your read replicas catch up with the write database. + +To enable this behavior, append the middleware to the `web` middleware group in your application's `bootstrap/app.php` file: + +```php +use Hypervel\Foundation\Configuration\Middleware; +use Hypervel\Foundation\Http\Middleware\PreventStaleDatabaseReads; + +->withMiddleware(function (Middleware $middleware): void { + $middleware->web(append: [ + PreventStaleDatabaseReads::class, + ]); +}) +``` + +The middleware tracks writes to the default database connection in the user's session. By default, requests within two seconds of a write will use the write connection for reads on that connection. You may customize this duration using the middleware's `for` method: + +```php +$middleware->web(append: [ + PreventStaleDatabaseReads::for(5), +]); +``` + +Since this middleware relies on the session, it must run after the `Hypervel\Session\Middleware\StartSession` middleware. It does not affect reads made by other sessions or on other database connections. Child coroutines resolve their own connections, so their reads and writes are not covered by this middleware. + + +#### Using the Write Connection for Reads + +If you need to ensure that all reads on a connection use the write database while executing a callback, you may use the connection's `usingWritePdo` method: + +```php +use Hypervel\Support\Facades\DB; + +$users = DB::connection()->usingWritePdo(fn ($connection) => $connection->table('users')->get()); +``` + +This also applies to Eloquent queries and relationship queries executed within the callback using the same connection. The method returns the callback's result and restores the connection's previous read preference when the callback finishes, even if an exception is thrown. + ### Connection Pooling diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 97aba5aac2..5ab429c521 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2287,6 +2287,8 @@ $users = User::withWhereHas('posts', function ($query) { })->get(); ``` +You may also [select specific columns](#eager-loading-specific-columns) when providing a callback, such as `withWhereHas('posts:id,user_id,title', $callback)`. + For a single, simple condition, you may use the `withWhereRelation` method: ```php diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 4ff3604768..7b9224f62f 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -209,10 +209,14 @@ class Flight extends Model } ``` +For tables without a primary key, use `#[Table(key: false)]`. These models support retrieval and insertion, but updating, refreshing, or deleting an instance throws an exception. Eloquent collection methods such as `merge()` and `unique()` also throw because they require primary keys. Use explicit query conditions for updates and deletes, `concat()` to combine collections, and `unique('column')` to remove duplicates. + +A custom `$primaryKey` inherited from a parent takes precedence over this attribute; declare `protected ?string $primaryKey = null` on the child instead. + #### "Composite" Primary Keys -Eloquent requires each model to have at least one uniquely identifying "ID" that can serve as its primary key. "Composite" primary keys are not supported by Eloquent models. However, you are free to add additional multi-column, unique indexes to your database tables in addition to the table's uniquely identifying primary key. +Operations that identify an individual model require a single unique primary key. "Composite" primary keys are not supported by Eloquent models. However, you are free to add additional multi-column, unique indexes to your database tables in addition to the table's uniquely identifying primary key. ### UUID and ULID Keys @@ -473,6 +477,8 @@ You may specify multiple attributes as an array: After the model is written, the configured attributes will be refreshed from the database. +`Refreshes` reads the row back using the primary key, so models without one must override `setKeysForSelectQuery`. + ### Configuring Eloquent Strictness diff --git a/src/docs/images.md b/src/docs/images.md index a073580edc..8d8600441a 100644 --- a/src/docs/images.md +++ b/src/docs/images.md @@ -51,7 +51,7 @@ composer require hypervel/image If you will use the bundled GD or Imagick driver, also install Intervention Image: ```shell -composer require intervention/image:^4.0 +composer require intervention/image:^4.2 ``` You should also ensure your PHP installation has the matching GD or Imagick extension. A custom driver does not require Intervention Image or either bundled extension. @@ -251,10 +251,13 @@ $image = $image->toPng(); $image = $image->toGif(); $image = $image->toAvif(); $image = $image->toHeic(); +$image = $image->toJxl(); $image = $image->toBmp(); ``` -The public `toFormat` method accepts `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`, `heic`, `heif`, and `bmp`. The `heif` spelling is normalized to `heic`: +JPEG XL (`jxl`) requires the Imagick driver with JPEG XL support enabled in ImageMagick. + +The public `toFormat` method accepts `webp`, `jpg`, `jpeg`, `png`, `gif`, `avif`, `heic`, `heif`, `jxl`, and `bmp`. The `heif` spelling is normalized to `heic`: ```php $image = $image->toFormat('heif'); diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index b90ab4a35f..46f3991ac4 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -837,7 +837,7 @@ Laravel's base `Connection` class exposes PDO methods. Hypervel's base `Connecti Custom base query builders overriding `newQuery`, `forNestedWhere`, or `cloneForPaginationCount` must declare `static` returns and preserve the concrete builder class. Keep `forSubQuery` separate: join subqueries return the parent query builder. See the [database extension guide](/docs/{{version}}/database#extending-database-connections) for these return contracts. -Laravel's nested `direct` connection endpoint and `::direct` suffix are not available. Configure the direct endpoint as a normal named connection and point the pooled connection's `migrations_connection` option at it. +Use named connections instead of Laravel's nested `direct` endpoint, `::direct` suffix and direct-PDO methods such as `getDirectPdo()`. Select the migration connection with `migrations_connection`. Model casts are not applied to direct query builder operations or Eloquent key helpers. When ported code passes already-encoded binary strings to query builder `where`, bulk `update`, or `upsert` calls, or to Eloquent `find`, `whereKey`, or `whereKeyNot`, wrap them in `Hypervel\Database\BinaryParameter`. See [binding binary values](/docs/{{version}}/database#binding-binary-values) and [binary casting](/docs/{{version}}/eloquent-mutators#binary-casting). diff --git a/src/docs/queues.md b/src/docs/queues.md index 2ec3571318..fa07e2a9fe 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -493,7 +493,7 @@ class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique In the example above, the `UpdateSearchIndex` job is unique. So, the job will not be dispatched if another instance of the job is already on the queue and has not finished processing. -When a unique job dispatched through its static `dispatch` method is not queued because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event. The event's `job` property contains the job that was skipped. +When a unique job dispatched through its static `dispatch` method is not queued because its lock is already held, Hypervel dispatches the `Hypervel\Queue\Events\UniqueJobSkipped` event if an active listener is registered. The event's `job` property contains the job that was skipped. In certain cases, you may want to define a specific "key" that makes the job unique or you may want to specify a timeout beyond which the job no longer stays unique. To accomplish this, you may use the `UniqueFor` attribute and define a `uniqueId` method on your job class: diff --git a/src/docs/requests.md b/src/docs/requests.md index 8af3c1aadc..d7e19fc678 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -790,6 +790,8 @@ $value = $request->cookie('name'); By default, Hypervel includes the `Hypervel\Foundation\Http\Middleware\TrimStrings` and `Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull` middleware in your application's global middleware stack. These middleware will automatically trim all incoming string fields on the request, as well as convert any empty string fields to `null`. This allows you to not have to worry about these normalization concerns in your routes and controllers. +To prevent oversized nested field names from consuming excessive resources, these middleware reject requests with an HTTP 413 response when the combined size of the processed field paths exceeds 20 MiB. This limit counts full nested paths across the query string and request body. + #### Disabling Input Normalization If you would like to disable this behavior for all requests, you may remove the two middleware from your application's middleware stack by invoking the `$middleware->remove` method in your application's `bootstrap/app.php` file: diff --git a/src/docs/validation.md b/src/docs/validation.md index bd11da5b27..acfa656ad5 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -2626,7 +2626,7 @@ use Hypervel\Validation\Rule; ], ``` -The fluent numeric builder also provides `integer`, `min`, `max`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`, `digits`, `digitsBetween`, `minDigits`, `maxDigits`, `multipleOf`, `same`, `different`, and `exactly` methods. Pass `strict: true` to the `integer` method to apply the [strict integer rule](#rule-integer). +Pass `strict: true` to `Rule::numeric()` to reject numeric strings. The fluent numeric builder also provides `integer`, `min`, `max`, `greaterThan`, `greaterThanOrEqualTo`, `lessThan`, `lessThanOrEqualTo`, `digits`, `digitsBetween`, `minDigits`, `maxDigits`, `multipleOf`, `same`, `different`, and `exactly` methods. Pass `strict: true` to the `integer` method to apply the [strict integer rule](#rule-integer). #### present diff --git a/src/foundation/src/Configuration/ApplicationBuilder.php b/src/foundation/src/Configuration/ApplicationBuilder.php index cec33fc13e..6ef826b47b 100644 --- a/src/foundation/src/Configuration/ApplicationBuilder.php +++ b/src/foundation/src/Configuration/ApplicationBuilder.php @@ -13,7 +13,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\RegisterProviders; use Hypervel\Foundation\Exceptions\Handler; -use Hypervel\Foundation\Http\HealthCheckController; +use Hypervel\Foundation\Http\HealthCheck; use Hypervel\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Hypervel\Foundation\Support\Providers\EventServiceProvider as AppEventServiceProvider; use Hypervel\Foundation\Support\Providers\RouteServiceProvider as AppRouteServiceProvider; @@ -188,7 +188,7 @@ protected function buildRoutingCallback( } if (is_string($health)) { - Route::get($health, HealthCheckController::class); + Route::get($health, HealthCheck::class); } if (is_string($web) || is_array($web)) { diff --git a/src/foundation/src/Http/HealthCheck.php b/src/foundation/src/Http/HealthCheck.php new file mode 100644 index 0000000000..9d0823b1d4 --- /dev/null +++ b/src/foundation/src/Http/HealthCheck.php @@ -0,0 +1,53 @@ +hasDebugModeEnabled()) { + throw $throwable; + } + + report($throwable); + + $health = 'down'; + } + + $status = $health === 'up' ? 200 : 500; + + if ($request->expectsJson()) { + return response()->json(['status' => $health], $status); + } + + return response(View::file( + __DIR__ . '/../resources/health-up.blade.php', + ['request' => $request, 'status' => $health], + ), status: $status); + } +} diff --git a/src/foundation/src/Http/HealthCheckController.php b/src/foundation/src/Http/HealthCheckController.php deleted file mode 100644 index c1d1e4f966..0000000000 --- a/src/foundation/src/Http/HealthCheckController.php +++ /dev/null @@ -1,71 +0,0 @@ -events->hasListeners(DiagnosingHealth::class)) { - $this->events->dispatch(new DiagnosingHealth); - } - } catch (CanceledException $exception) { - throw $exception; - } catch (Throwable $throwable) { - if ($this->app->hasDebugModeEnabled()) { - throw $throwable; - } - - $this->exceptions->report($throwable); - - $health = 'down'; - } - - $status = $health === 'up' ? 200 : 500; - - if ($request->expectsJson()) { - return $this->responses->json(['status' => $health], $status); - } - - return $this->responses->make($this->views->file( - __DIR__ . '/../resources/health-up.blade.php', - ['request' => $request, 'status' => $health], - ), $status); - } -} diff --git a/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php b/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php new file mode 100644 index 0000000000..78ba6f4a72 --- /dev/null +++ b/src/foundation/src/Http/Middleware/PreventStaleDatabaseReads.php @@ -0,0 +1,58 @@ +manager->getDefaultConnection(); + $recentlyModified = $request->session()->get('_database_modified_at', 0) >= Date::now()->subSeconds((int) $seconds)->getTimestamp(); + + // Resolving a pooled connection takes a slot even before its first query. + // Without a recent write, inspect existing state so query-free routes do not exhaust the pool. + /** @var null|Connection $connection */ + $connection = $recentlyModified + ? $this->manager->connection($name) + : $this->manager->getHeldConnection($name); + $connection?->useWriteConnectionWhenReading($recentlyModified); + + $response = $next($request); + + // The request may have resolved its first connection, or changed the default name. + $connection ??= $this->manager->getHeldConnection($name); + + /** @var null|Connection $connection */ + if ($connection?->hasModifiedRecords()) { + $request->session()->put('_database_modified_at', Date::now()->getTimestamp()); + } + + return $response; + } +} diff --git a/src/foundation/src/Http/Middleware/TransformsRequest.php b/src/foundation/src/Http/Middleware/TransformsRequest.php index e149dcedc7..5c659c25c8 100644 --- a/src/foundation/src/Http/Middleware/TransformsRequest.php +++ b/src/foundation/src/Http/Middleware/TransformsRequest.php @@ -5,16 +5,38 @@ namespace Hypervel\Foundation\Http\Middleware; use Closure; +use Hypervel\Context\CoroutineContext; +use Hypervel\Http\Exceptions\PostTooLargeException; use Hypervel\Http\Request; use Symfony\Component\HttpFoundation\ParameterBag; class TransformsRequest { + protected const string KEY_BYTES_CONTEXT_KEY = '__foundation.transforms_request.key_bytes'; + + /** + * The maximum cumulative size of the keys processed per request, in bytes. + */ + protected int $maxKeyBytes = 20 * 1024 * 1024; + + /** + * The cumulative size of the keys processed for the current request. + */ + protected int $keyBytes { + // Middleware instances are shared; custom transforms can yield during request processing. + get => CoroutineContext::get(self::KEY_BYTES_CONTEXT_KEY, 0); + set { + CoroutineContext::set(self::KEY_BYTES_CONTEXT_KEY, $value); + } + } + /** * Handle an incoming request. */ public function handle(Request $request, Closure $next): mixed { + $this->keyBytes = 0; + $this->clean($request); return $next($request); @@ -51,8 +73,29 @@ protected function cleanParameterBag(ParameterBag $bag): void */ protected function cleanArray(array $data, string $keyPrefix = ''): array { + $prefixLength = strlen($keyPrefix); + $keyBytes = $this->keyBytes; + + // Charge the array before transforming values, keeping context lookups out of the per-key loop. foreach ($data as $key => $value) { - $data[$key] = $this->cleanValue($keyPrefix . $key, $value); + $keyBytes += $prefixLength + strlen((string) $key); + + if ($keyBytes > $this->maxKeyBytes) { + $this->keyBytes = $keyBytes; + + throw new PostTooLargeException('The cumulative size of the input keys is too large.'); + } + } + + $this->keyBytes = $keyBytes; + + foreach ($data as $key => $value) { + $cleaned = $this->cleanValue($keyPrefix . $key, $value); + + // Avoid copying arrays when none of their values have changed... + if ($cleaned !== $value) { + $data[$key] = $cleaned; + } } return $data; diff --git a/src/foundation/src/Testing/DatabaseConnectionResolver.php b/src/foundation/src/Testing/DatabaseConnectionResolver.php index 0899ad293d..24fc4c4c30 100644 --- a/src/foundation/src/Testing/DatabaseConnectionResolver.php +++ b/src/foundation/src/Testing/DatabaseConnectionResolver.php @@ -153,6 +153,18 @@ public function getResolvedConnection(string $name): ?ConnectionInterface return static::$connections[$this->connectionCacheKey($name)] ?? null; } + /** + * Get the connection owned by the active testing mode without borrowing one. + */ + public function getHeldConnection(UnitEnum|string|null $name = null): ?ConnectionInterface + { + $name = $this->connectionName($name); + + return $this->poolEnabled(ConnectionName::parse($name)) + ? parent::getHeldConnection($name) + : $this->getResolvedConnection($name); + } + /** * Get the connections shared across the test's setup, requests and assertions. * diff --git a/src/image/composer.json b/src/image/composer.json index 96bc89a145..aa88dfc8ec 100644 --- a/src/image/composer.json +++ b/src/image/composer.json @@ -44,10 +44,10 @@ "suggest": { "ext-gd": "Required to use the GD image driver.", "ext-imagick": "Required to use the Imagick image driver.", - "intervention/image": "Required to use the GD and Imagick image drivers (^4.0)." + "intervention/image": "Required to use the GD and Imagick image drivers (^4.2)." }, "conflict": { - "intervention/image": "<4.0 || >=5.0" + "intervention/image": "<4.2 || >=5.0" }, "config": { "sort-packages": true diff --git a/src/image/src/Drivers/InterventionDriver.php b/src/image/src/Drivers/InterventionDriver.php index 3664c1b537..f41ca43391 100644 --- a/src/image/src/Drivers/InterventionDriver.php +++ b/src/image/src/Drivers/InterventionDriver.php @@ -28,6 +28,7 @@ use Intervention\Image\Encoders\GifEncoder; use Intervention\Image\Encoders\HeicEncoder; use Intervention\Image\Encoders\JpegEncoder; +use Intervention\Image\Encoders\JxlEncoder; use Intervention\Image\Encoders\MediaTypeEncoder; use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\WebpEncoder; @@ -73,7 +74,7 @@ public function ensureRequirementsAreMet(): void if (! class_exists(ImageManager::class)) { throw new ImageException( 'Intervention Image is required to use this driver. ' - . 'You may install it via: composer require intervention/image:^4.0', + . 'You may install it via: composer require intervention/image:^4.2', ); } } @@ -85,7 +86,7 @@ public function process(string $contents, ImagePipeline $pipeline): string { $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); - if (! in_array($mimeType, ['image/jpeg', 'image/png', 'image/bmp', 'image/gif', 'image/webp', 'image/avif', 'image/x-avif', 'image/heic', 'image/x-heic', 'image/heif'], true)) { + if (! in_array($mimeType, ['image/jpeg', 'image/png', 'image/bmp', 'image/gif', 'image/webp', 'image/avif', 'image/x-avif', 'image/heic', 'image/x-heic', 'image/heif', 'image/jxl', 'image/x-jxl'], true)) { throw new ImageException("The image format [{$mimeType}] is not supported."); } @@ -133,6 +134,7 @@ public function process(string $contents, ImagePipeline $pipeline): string 'gif' => new GifEncoder, 'avif' => new AvifEncoder($quality), 'heic' => new HeicEncoder($quality), + 'jxl' => new JxlEncoder($quality), 'bmp' => new BmpEncoder, })->toString(); } diff --git a/src/image/src/Image.php b/src/image/src/Image.php index 58779cfbc4..b5742e7862 100644 --- a/src/image/src/Image.php +++ b/src/image/src/Image.php @@ -377,6 +377,14 @@ public function toHeic(): static return $this->toFormat('heic'); } + /** + * Convert the image to JPEG XL format. + */ + public function toJxl(): static + { + return $this->toFormat('jxl'); + } + /** * Convert the image to BMP format. */ @@ -392,7 +400,7 @@ public function toBmp(): static */ public function toFormat(string $format): static { - if (! in_array($format, ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'heic', 'heif', 'bmp'], true)) { + if (! in_array($format, ['webp', 'jpg', 'jpeg', 'png', 'gif', 'avif', 'heic', 'heif', 'jxl', 'bmp'], true)) { throw new ImageException("The [{$format}] format is not supported."); } @@ -527,6 +535,7 @@ public function extension(): string 'image/webp' => 'webp', 'image/avif', 'image/x-avif' => 'avif', 'image/heic', 'image/x-heic', 'image/heif' => 'heic', + 'image/jxl', 'image/x-jxl' => 'jxl', 'image/bmp' => 'bmp', 'image/svg+xml' => 'svg', 'image/tiff' => 'tiff', @@ -557,8 +566,8 @@ public function dimensions(): array $contents = $this->toBytes(); - // getimagesizefromstring() misreports HEIC's coded / padded frame size, so HEIC dimensions belong to the selected driver. - if (in_array($this->mimeType(), ['image/heic', 'image/heif', 'image/x-heic'], true)) { + // getimagesizefromstring() misreports HEIC's coded / padded frame size and cannot read JXL, so use the selected driver. + if (in_array($this->mimeType(), ['image/heic', 'image/heif', 'image/x-heic', 'image/jxl', 'image/x-jxl'], true)) { $driver = $this->resolveDriver(); try { diff --git a/src/image/src/ImageOutputOptions.php b/src/image/src/ImageOutputOptions.php index 3d3368cf5f..0549fc32b9 100644 --- a/src/image/src/ImageOutputOptions.php +++ b/src/image/src/ImageOutputOptions.php @@ -14,7 +14,7 @@ class ImageOutputOptions /** * The output format. * - * @var null|'avif'|'bmp'|'gif'|'heic'|'jpeg'|'jpg'|'png'|'webp' + * @var null|'avif'|'bmp'|'gif'|'heic'|'jpeg'|'jpg'|'jxl'|'png'|'webp' */ public ?string $format = null; diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php index 44315156dc..4b37a64297 100644 --- a/src/support/src/Facades/DB.php +++ b/src/support/src/Facades/DB.php @@ -24,6 +24,7 @@ * @method static void forgetExtension(string $name) * @method static array getConnections() * @method static string|null getDefaultConnection() + * @method static \Hypervel\Database\ConnectionInterface|null getHeldConnection(\UnitEnum|string|null $name = null) * @method static bool hasApplicableTransaction(\UnitEnum|string|null $name = null) * @method static bool hasMacro(string $name) * @method static void macro(string $name, callable|object $macro) @@ -121,6 +122,7 @@ * @method static void useDefaultQueryGrammar() * @method static void useDefaultSchemaGrammar() * @method static \Hypervel\Database\Connection useWriteConnectionWhenReading(bool $value = true) + * @method static mixed usingWritePdo(\Closure $callback) * @method static mixed withoutPretending(\Closure $callback) * @method static mixed withoutTablePrefix(\Closure $callback) * @method static mixed withPinnedSession(\Closure $callback) diff --git a/src/telescope/src/Storage/EntryModel.php b/src/telescope/src/Storage/EntryModel.php index 06deb50e6a..f57ba228ec 100644 --- a/src/telescope/src/Storage/EntryModel.php +++ b/src/telescope/src/Storage/EntryModel.php @@ -45,7 +45,7 @@ class EntryModel extends Model /** * The primary key for the model. */ - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; /** * The "type" of the auto-incrementing ID. diff --git a/src/testbench/src/Workbench/Workbench.php b/src/testbench/src/Workbench/Workbench.php index edec3588db..878737ffff 100644 --- a/src/testbench/src/Workbench/Workbench.php +++ b/src/testbench/src/Workbench/Workbench.php @@ -9,7 +9,7 @@ use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Database\Eloquent\Factories\Factory; -use Hypervel\Foundation\Http\HealthCheckController; +use Hypervel\Foundation\Http\HealthCheck; use Hypervel\Routing\Router; use Hypervel\Support\Collection; use Hypervel\Support\Env; @@ -127,7 +127,7 @@ public static function discoverRoutes(ApplicationContract $app, ConfigContract $ } if ($healthCheckEnabled === true) { - $router->get('/up', HealthCheckController::class); + $router->get('/up', HealthCheck::class); } if ($discoversConfig['web'] === true) { diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index c34b7c8bdc..8ac0983504 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -715,9 +715,9 @@ public static function string(): StringRule /** * Get a numeric rule builder instance. */ - public static function numeric(): Numeric + public static function numeric(bool $strict = false): Numeric { - return new Numeric; + return new Numeric(strict: $strict); } /** diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php index 875d2bd52e..697c3bbf1f 100644 --- a/src/validation/src/Rules/Numeric.php +++ b/src/validation/src/Rules/Numeric.php @@ -15,7 +15,15 @@ class Numeric implements Stringable /** * The constraints for the number rule. */ - protected array $constraints = ['numeric']; + protected array $constraints = []; + + /** + * Create a numeric rule instance. + */ + public function __construct(bool $strict = false) + { + $this->addRule($strict ? 'numeric:strict' : 'numeric'); + } /** * The field under validation must have a size between the given min and max (inclusive). diff --git a/src/view/src/FileViewFinder.php b/src/view/src/FileViewFinder.php index faccb1ff64..467f9f33fc 100755 --- a/src/view/src/FileViewFinder.php +++ b/src/view/src/FileViewFinder.php @@ -158,7 +158,7 @@ protected function resolvePath(string $path): string */ public function addNamespace(string $namespace, string|array $hints): void { - $hints = (array) $hints; + $hints = array_map($this->resolvePath(...), (array) $hints); if (isset($this->hints[$namespace])) { $hints = array_merge($this->hints[$namespace], $hints); @@ -175,7 +175,7 @@ public function addNamespace(string $namespace, string|array $hints): void */ public function prependNamespace(string $namespace, string|array $hints): void { - $hints = (array) $hints; + $hints = array_map($this->resolvePath(...), (array) $hints); if (isset($this->hints[$namespace])) { $hints = array_merge($hints, $this->hints[$namespace]); @@ -192,7 +192,7 @@ public function prependNamespace(string $namespace, string|array $hints): void */ public function replaceNamespace(string $namespace, string|array $hints): void { - $this->hints[$namespace] = (array) $hints; + $this->hints[$namespace] = array_map($this->resolvePath(...), (array) $hints); } /** diff --git a/tests/Ai/Feature/RemembersConversationsTest.php b/tests/Ai/Feature/RemembersConversationsTest.php index 361468feb9..1899c6d98b 100644 --- a/tests/Ai/Feature/RemembersConversationsTest.php +++ b/tests/Ai/Feature/RemembersConversationsTest.php @@ -169,7 +169,7 @@ public function testItResolvesTheParticipantIdViaGetKeyForModelsWithCustomPrimar $participant = new class extends Model { protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; protected string $keyType = 'string'; diff --git a/tests/Auth/AuthAccessGateScopeSelectTest.php b/tests/Auth/AuthAccessGateScopeSelectTest.php index 34f22dcb96..4ccd285995 100644 --- a/tests/Auth/AuthAccessGateScopeSelectTest.php +++ b/tests/Auth/AuthAccessGateScopeSelectTest.php @@ -726,5 +726,5 @@ class QueryAwareSoftDeletingPost extends Model class QueryAwareEmptyKeyPost extends ScopablePost { - protected string $primaryKey = ''; + protected ?string $primaryKey = ''; } diff --git a/tests/Database/ConnectionResolverTest.php b/tests/Database/ConnectionResolverTest.php index fea12f4c38..2a83bb0b17 100644 --- a/tests/Database/ConnectionResolverTest.php +++ b/tests/Database/ConnectionResolverTest.php @@ -603,6 +603,9 @@ public function testConnectionDateFormatPrefersTheGrammarOfAHeldConnection(): vo $connection->setQueryGrammar(new ConnectionResolverTestTimestampGrammar($connection)); $this->assertSame('U', $resolver->connectionDateFormat('sqlite')); + $this->assertSame($connection, $resolver->getHeldConnection()); + $this->assertSame($connection, $resolver->getHeldConnection('')); + $this->assertSame($connection, $resolver->getHeldConnection(ConnectionResolverTestName::Sqlite)); }); } @@ -669,6 +672,11 @@ protected function makeResolver( } } +enum ConnectionResolverTestName: string +{ + case Sqlite = 'sqlite'; +} + class ConnectionResolverTestTimestampGrammar extends SQLiteGrammar { /** diff --git a/tests/Database/DatabaseConnectionLeaseTest.php b/tests/Database/DatabaseConnectionLeaseTest.php index f4c95a348c..2602a8b74f 100644 --- a/tests/Database/DatabaseConnectionLeaseTest.php +++ b/tests/Database/DatabaseConnectionLeaseTest.php @@ -6,6 +6,7 @@ use Aws\Result; use Closure; +use ErrorException; use GuzzleHttp\Client; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; @@ -782,6 +783,53 @@ public function testDisconnectDropsThePhysicalPdoAndReusesTheLogicalConnection() $this->assertSame(2, $connection->selectOne('select 2 as value')->value); } + public function testReconnectReleasesWarningHandlesThroughThePhysicalConnection(): void + { + $this->app->instance('db.connector.pgsql', new class extends LeasePdoConnector { + private bool $first = true; + + /** + * Make the first physical handle warn when it is released. + */ + public function connect(array $config): PDO + { + if (! $this->first) { + return parent::connect($config); + } + + $this->first = false; + + return new class('sqlite::memory:') extends PDO { + /** + * Simulate a native teardown warning. + */ + public function __destruct() + { + trigger_error('SSL: Broken pipe', E_USER_WARNING); + } + }; + } + }); + + $connection = DB::connection('physical_leases'); + $original = WeakReference::create($connection->getPdo()); + + set_error_handler(static function (int $severity, string $message, string $file, int $line): void { + if (error_reporting() & $severity) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + }); + + try { + $connection->reconnect(); + + $this->assertNull($original->get()); + $this->assertSame(2, $connection->selectFromWriteConnection('select 2 as value')[0]->value); + } finally { + restore_error_handler(); + } + } + public function testCapturedMySqlInsertIdsSurviveEarlyRelease(): void { $connection = DB::connection('insert_id_leases'); diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index 5b6b6da6c9..4dd37215dc 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -77,6 +77,8 @@ public static function driverFailures(): iterable yield 'explicit signal' => [new LostConnectionException('Lost connection and no reconnector available.')]; yield 'mysqlnd packet warning' => [new ErrorException('Packets out of order. Expected 1 received 0. Packet size=145', 0, E_WARNING)]; yield 'mysqlnd send warning' => [new ErrorException('Error while sending QUERY packet. PID=1234', 0, E_WARNING)]; + yield 'PDO TLS broken pipe' => [new ErrorException('PDOStatement::execute(): SSL: Broken pipe', 0, E_WARNING)]; + yield 'PDO TLS record failure' => [new ErrorException('PDO::query(): SSL operation failed with code 1. OpenSSL Error messages: decryption failed or bad record mac', 0, E_WARNING)]; } #[DataProvider('nonDatabaseFailures')] @@ -99,5 +101,7 @@ public static function nonDatabaseFailures(): iterable yield 'filesystem warning' => [new ErrorException('fopen(missing): Failed to open stream: No such file or directory', 0, E_WARNING)]; yield 'embedded packet warning' => [new ErrorException('fopen(Packets out of order. Expected): Failed to open stream', 0, E_WARNING)]; yield 'unsupported PDO warning mode' => [new ErrorException('PDO::query(): SQLSTATE[HY000]: General error: 2006 MySQL server has gone away', 0, E_WARNING)]; + yield 'PDO certificate configuration' => [new ErrorException('PDO::__construct(): SSL operation failed with code 1. OpenSSL Error messages: certificate verify failed', 0, E_WARNING)]; + yield 'Redis TLS failure' => [new ErrorException('Redis::get(): SSL: Connection reset by peer', 0, E_WARNING)]; } } diff --git a/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php index 45d19ccdb4..5d137139dc 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyLazyByIdTest.php @@ -130,7 +130,7 @@ public function articles() class Article extends Eloquent { - protected string $primaryKey = 'aid'; + protected ?string $primaryKey = 'aid'; protected ?string $table = 'articles'; diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index 8e306e26a0..f24fc8c9ff 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -423,6 +423,49 @@ public function testCollectionMergeSkipsNullModelKeys(): void ); } + #[DataProvider('keylessDictionaryOperations')] + public function testCollectionDictionaryOperationsRejectModelsWithoutPrimaryKeys(string $method, bool $emptyReceiver): void + { + $model = (new CollectionModel)->setKeyName(null); + $collection = new Collection($emptyReceiver ? [] : [$model]); + $arguments = match ($method) { + 'unique' => [], + 'only', 'except' => [[1]], + default => [[$model]], + }; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No primary key defined on model.'); + + $collection->{$method}(...$arguments); + } + + /** + * Provide collection operations that build a primary-key dictionary. + */ + public static function keylessDictionaryOperations(): array + { + return [ + 'merge' => ['merge', false], + 'merge into empty collection' => ['merge', true], + 'unique' => ['unique', false], + 'only' => ['only', false], + 'except' => ['except', false], + 'diff' => ['diff', false], + 'intersect' => ['intersect', false], + ]; + } + + public function testKeylessCollectionsCanBeConcatenatedAndDeduplicatedByAnExplicitColumn(): void + { + $one = (new CollectionModel)->setKeyName(null)->forceFill(['name' => 'Taylor']); + $two = (new CollectionModel)->setKeyName(null)->forceFill(['name' => 'Abigail']); + $collection = (new Collection([$one]))->concat([$two, $one]); + + $this->assertSame([$one, $two, $one], $collection->all()); + $this->assertSame([$one, $two], $collection->unique('name')->values()->all()); + } + public function testMap(): void { $one = new CollectionModel; diff --git a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php index fbf2f834ff..ad29fb343f 100644 --- a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php +++ b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php @@ -123,5 +123,5 @@ class ComparesRelatedModelsStub extends Model { protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; } diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php index 6535c8e2f1..288ea89edc 100755 --- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php +++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php @@ -14,6 +14,7 @@ use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\TestWith; class DatabaseEloquentHasOneOfManyTest extends TestCase { @@ -97,10 +98,40 @@ public function testItGuessesRelationName(): void $this->assertSame('latest_login', $user->latest_login()->getRelationName()); } - public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(): void + #[TestWith([Login::class])] + #[TestWith([SchemaLogin::class])] + public function testItGuessesRelationNameAndAddsOfManyWhenTableNameIsRelationName(string $related): void { $model = TestModel::make(); - $this->assertSame('logins_of_many', $model->logins()->getRelationName()); + $relation = $model->logins($related); + + $this->assertSame('logins_of_many', $relation->getRelationName()); + $this->assertStringContainsString('as "logins_of_many"', $relation->toSql()); + } + + #[TestWith([Login::class])] + #[TestWith([SchemaLogin::class])] + public function testItUsesTheTableNameWhenOfManyIsCalledInsideAClosure(string $related): void + { + User::resolveRelationUsing('first_login_from_closure', function (User $user) use ($related): HasOne { + $relation = $user->hasOne($related, 'user_id'); + + if ($related === SchemaLogin::class) { + // SQLite requires explicit columns instead of schema.table.*. + $relation->select(['main.logins.id', 'main.logins.user_id']); + } + + return $relation->ofMany('id', 'min'); + }); + + $user = User::create(); + $first = $user->first_login_from_closure()->create(); + $user->first_login_from_closure()->create(); + + $this->assertTrue($first->is($user->first_login_from_closure)); + $this->assertTrue($first->is(User::with('first_login_from_closure')->first()->first_login_from_closure)); + $this->assertCount(1, User::has('first_login_from_closure')->get()); + $this->assertSame('logins_of_many', $user->first_login_from_closure()->getRelationName()); } public function testRelationNameCanBeSet(): void @@ -717,9 +748,14 @@ public function latest_updated_latest_created_state() class TestModel extends Eloquent { - public function logins() + /** + * Get the latest login. + * + * @param class-string $related + */ + public function logins(string $related = Login::class): HasOne { - return $this->hasOne(Login::class)->ofMany(); + return $this->hasOne($related)->ofMany(); } } @@ -732,6 +768,11 @@ class Login extends Eloquent public bool $timestamps = false; } +class SchemaLogin extends Login +{ + protected ?string $table = 'main.logins'; +} + class LoginWithSoftDeletes extends Eloquent { use SoftDeletes; diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 57ee279c03..b2487187bf 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1653,6 +1653,23 @@ public function testWithWhereHasOnSelfReferencingBelongsToManyRelationship(): vo $this->assertSame(['abigailotwell@gmail.com'], $results->first()->friends->pluck('email')->unique()->toArray()); } + public function testWithWhereHasWithSpecificColumnsAndCallback(): void + { + $user = User::create(['email' => 'taylorotwell@gmail.com']); + $user->friends()->create(['email' => 'abigailotwell@gmail.com']); + $user->friends()->create(['email' => 'foo@gmail.com']); + + $results = User::withWhereHas('friends:id,email', function ($query) { + $query->where('email', 'abigailotwell@gmail.com'); + })->get(); + + $this->assertCount(1, $results); + $this->assertSame('taylorotwell@gmail.com', $results->first()->email); + $this->assertCount(1, $results->first()->friends); + $this->assertSame(['id', 'email'], array_keys($results->first()->friends->first()->getAttributes())); + $this->assertSame('abigailotwell@gmail.com', $results->first()->friends->first()->email); + } + public function testHasOnNestedSelfReferencingBelongsToManyRelationship(): void { $user = User::create(['email' => 'taylorotwell@gmail.com']); @@ -3362,7 +3379,7 @@ class UniqueUserWithCustomKey extends UniqueUser { public bool $incrementing = false; - protected string $primaryKey = 'screen_name'; + protected ?string $primaryKey = 'screen_name'; protected string $keyType = 'string'; } diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php index e82165fc00..07766b06c8 100755 --- a/tests/Database/DatabaseEloquentInverseRelationTest.php +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -315,7 +315,7 @@ class HasInverseRelationParentStub extends Model { protected static bool $unguarded = true; - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; public function getForeignKey(): string { @@ -327,7 +327,7 @@ class HasInverseRelationRelatedStub extends Model { protected static bool $unguarded = true; - protected string $primaryKey = 'id'; + protected ?string $primaryKey = 'id'; public function getForeignKey(): string { diff --git a/tests/Database/DatabaseEloquentModelAttributesTest.php b/tests/Database/DatabaseEloquentModelAttributesTest.php index 317f198a06..0304ddd199 100644 --- a/tests/Database/DatabaseEloquentModelAttributesTest.php +++ b/tests/Database/DatabaseEloquentModelAttributesTest.php @@ -19,12 +19,17 @@ use Hypervel\Database\Eloquent\Attributes\Visible; use Hypervel\Database\Eloquent\Attributes\WithoutIncrementing; use Hypervel\Database\Eloquent\Attributes\WithoutTimestamps; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphPivot; use Hypervel\Database\Eloquent\Relations\Pivot; +use Hypervel\Database\Schema\Blueprint; use Hypervel\Tests\TestCase; +use LogicException; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; +use UnitEnum; class DatabaseEloquentModelAttributesTest extends TestCase { @@ -78,6 +83,19 @@ public function testChildInheritsParentTableAttribute(): void $this->assertSame('parent_attr', $model->getTable()); } + public function testGrandchildKeepsTableAttributeOverride(): void + { + $this->assertSame('child_attr', (new GrandchildModelWithTableAttribute)->getTable()); + } + + public function testTableAttributeWithoutNamePreservesInheritedTable(): void + { + $model = new ChildModelWithNamelessTableAttribute; + + $this->assertSame('parent_prop', $model->getTable()); + $this->assertFalse($model->usesTimestamps()); + } + public function testTableAttributeIsResolvedFromATrait(): void { $model = new ModelWithTableAttributeTrait; @@ -113,6 +131,137 @@ public function testPrimaryKeyAttribute(): void $this->assertSame('custom_id', $model->getKeyName()); } + public function testPrimaryKeyAttributeCanDisablePrimaryKey(): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + + $this->assertNull($model->getKeyName()); + } + + public function testPrimaryKeyCanBeDisabledByPropertyOrSetter(): void + { + $model = new class extends Model { + protected ?string $primaryKey = null; + }; + + $this->assertNull($model->getKey()); + $this->assertSame([], $model->getCasts()); + + $model->setKeyName('id'); + $this->assertSame(['id' => 'int'], $model->getCasts()); + + $model->setKeyName(null); + $this->assertNull($model->getKeyName()); + $this->assertSame([], $model->getCasts()); + } + + public function testIncrementingUsesTheOverriddenKeyName(): void + { + $keyless = new class extends Model { + /** + * Get the primary key for the model. + */ + public function getKeyName(): ?string + { + return null; + } + }; + + $this->assertFalse($keyless->getIncrementing()); + $this->assertSame([], $keyless->getCasts()); + + $keyed = new class extends Model { + protected ?string $primaryKey = null; + + /** + * Get the primary key for the model. + */ + public function getKeyName(): string + { + return 'row_id'; + } + }; + + $this->assertTrue($keyed->getIncrementing()); + $this->assertSame(['row_id' => 'int'], $keyed->getCasts()); + } + + public function testKeylessModelsCanBeInsertedAndRetrieved(): void + { + DB::schema()->create('keyless_records', function (Blueprint $table): void { + $table->string('name'); + }); + + $model = new ModelWithoutPrimaryKeyAttribute; + $model->setTable('keyless_records'); + $model->timestamps = false; + $model->name = 'Taylor'; + + $this->assertTrue($model->save()); + $this->assertTrue($model->exists); + $this->assertNull($model->getKey()); + $this->assertSame(['name' => 'Taylor'], $model->getAttributes()); + $this->assertSame(['name' => 'Taylor'], $model->newQuery()->first()->toArray()); + + $another = $model->replicate(); + $another->setTable('keyless_records'); + $another->timestamps = false; + $another->name = 'Abigail'; + + $this->assertTrue($another->saveOrIgnore()); + $this->assertSame(['Taylor', 'Abigail'], $model->newQuery()->pluck('name')->all()); + } + + #[TestWith(['save'])] + #[TestWith(['refresh'])] + #[TestWith(['delete'])] + public function testKeylessModelsRejectOperationsRequiringAnIdentity(string $operation): void + { + $model = new ModelWithoutPrimaryKeyAttribute; + $model->timestamps = false; + $model->exists = true; + $model->name = 'Taylor'; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('No primary key defined on model.'); + + $model->{$operation}(); + } + + public function testKeylessModelsCanSupplyTheirOwnSaveKeys(): void + { + DB::schema()->create('keyless_records', function (Blueprint $table): void { + $table->string('tenant'); + $table->string('name'); + $table->integer('visits'); + }); + + DB::table('keyless_records')->insert([ + ['tenant' => 'first', 'name' => 'Taylor', 'visits' => 1], + ['tenant' => 'second', 'name' => 'Taylor', 'visits' => 1], + ]); + + $model = new class extends ModelWithoutPrimaryKeyAttribute { + protected ?string $table = 'keyless_records'; + + public bool $timestamps = false; + + /** + * Set the keys for a save update query. + */ + protected function setKeysForSaveQuery(Builder $query): Builder + { + return $query->where('tenant', $this->tenant)->where('name', $this->name); + } + }; + + $record = $model->newQuery()->where('tenant', 'first')->first(); + $record->visits = 2; + + $this->assertTrue($record->save()); + $this->assertSame(['first' => 2, 'second' => 1], DB::table('keyless_records')->pluck('visits', 'tenant')->all()); + } + public function testPrimaryKeyPropertyTakesPrecedence(): void { $model = new ModelWithPrimaryKeyAttributeAndProperty; @@ -194,6 +343,77 @@ public function testConnectionAttributeWithIntegerBackedEnum(): void $this->assertSame('0', $model->getConnectionName()); } + public function testChildConnectionAttributeOverridesInheritedConnectionProperty(): void + { + $model = new ChildModelWithConnectionAttribute; + + $this->assertSame('secondary', $model->getConnectionName()); + } + + public function testChildInheritsParentConnectionAttribute(): void + { + $model = new ChildModelWithNoConnection; + + $this->assertSame('secondary', $model->getConnectionName()); + } + + public function testGrandchildKeepsConnectionAttributeOverride(): void + { + $this->assertSame('secondary', (new GrandchildModelWithConnectionAttribute)->getConnectionName()); + } + + public function testInheritedLocationAttributesPreserveValuesAssignedDuringConstruction(): void + { + $inherited = new class extends ModelWithLocationAttributes { + use SetsRuntimeModelLocation; + }; + + $this->assertSame('runtime_table', $inherited->getTable()); + $this->assertSame('runtime_connection', $inherited->getConnectionName()); + + $grandchild = new class extends ModelWithLocationAttributeOverrides { + /** + * Set runtime configuration before initializing the model. + */ + public function __construct(array $attributes = []) + { + $this->setTable('runtime_table'); + $this->setConnection('runtime_connection'); + + parent::__construct($attributes); + } + }; + + $this->assertSame('runtime_table', $grandchild->getTable()); + $this->assertSame('runtime_connection', $grandchild->getConnectionName()); + + $direct = new #[Table('attribute_table')] #[Connection('secondary')] class extends Model { + use SetsRuntimeModelLocation; + }; + + $this->assertSame('attribute_table', $direct->getTable()); + $this->assertSame('secondary', $direct->getConnectionName()); + } + + public function testChildConnectionPropertyOverridesParentConnectionAttribute(): void + { + $model = new ChildModelWithConnectionProperty; + + $this->assertSame('default', $model->getConnectionName()); + } + + public function testLocationAttributesInitializePropertiesWithoutDefaults(): void + { + $model = new #[Table('attribute_table')] #[Connection('secondary')] class extends Model { + protected ?string $table; + + protected UnitEnum|string|null $connection; + }; + + $this->assertSame('attribute_table', $model->getTable()); + $this->assertSame('secondary', $model->getConnectionName()); + } + public function testTimestampsAttribute(): void { $model = new ModelWithTimestampsFalseAttribute; @@ -412,28 +632,29 @@ public function testGuardAndTableMetadataCachesAreUsedAndCleared(): void $reflection = new ReflectionClass(Model::class); $guardConfigurationsProperty = $reflection->getProperty('guardConfigurations'); - $classDeclaredAttributesProperty = $reflection->getProperty('classDeclaredAttributes'); + $classAttributeOverridesProperty = $reflection->getProperty('classAttributeOverrides'); $classPropertyDeclarersProperty = $reflection->getProperty('classPropertyDeclarers'); $guardConfigurations = $guardConfigurationsProperty->getValue(); - $classDeclaredAttributes = $classDeclaredAttributesProperty->getValue(); + $classAttributeOverrides = $classAttributeOverridesProperty->getValue(); $classPropertyDeclarers = $classPropertyDeclarersProperty->getValue(); $guardConfigurations[ModelWithUnguardedAttribute::class] = [ 'guarded' => ['cached'], 'propertyDefault' => ['*'], ]; - $classDeclaredAttributes[ChildModelWithTableAttribute::class . '@' . Table::class] = false; + $cacheKey = ChildModelWithTableAttribute::class . '@' . Table::class . '@table'; + $classAttributeOverrides[$cacheKey] = false; $guardConfigurationsProperty->setValue(null, $guardConfigurations); - $classDeclaredAttributesProperty->setValue(null, $classDeclaredAttributes); + $classAttributeOverridesProperty->setValue(null, $classAttributeOverrides); $this->assertSame(['cached'], (new ModelWithUnguardedAttribute)->getGuarded()); $this->assertSame('parent_prop', (new ChildModelWithTableAttribute)->getTable()); - $classDeclaredAttributes[ChildModelWithTableAttribute::class . '@' . Table::class] = true; + unset($classAttributeOverrides[$cacheKey]); $classPropertyDeclarers[ChildModelWithTableAttribute::class . '@table'] = ChildModelWithTableAttribute::class; - $classDeclaredAttributesProperty->setValue(null, $classDeclaredAttributes); + $classAttributeOverridesProperty->setValue(null, $classAttributeOverrides); $classPropertyDeclarersProperty->setValue(null, $classPropertyDeclarers); $this->assertSame('parent_prop', (new ChildModelWithTableAttribute)->getTable()); @@ -441,7 +662,7 @@ public function testGuardAndTableMetadataCachesAreUsedAndCleared(): void Model::clearBootedModels(); $this->assertSame([], $guardConfigurationsProperty->getValue()); - $this->assertSame([], $classDeclaredAttributesProperty->getValue()); + $this->assertSame([], $classAttributeOverridesProperty->getValue()); $this->assertSame([], $classPropertyDeclarersProperty->getValue()); $this->assertSame([], (new ModelWithUnguardedAttribute)->getGuarded()); $this->assertSame('child_attr', (new ChildModelWithTableAttribute)->getTable()); @@ -804,6 +1025,15 @@ class ChildModelWithTableAttribute extends ParentModelWithTableProperty { } +class GrandchildModelWithTableAttribute extends ChildModelWithTableAttribute +{ +} + +#[Table(timestamps: false)] +class ChildModelWithNamelessTableAttribute extends ParentModelWithTableProperty +{ +} + #[Table(name: 'parent_attr')] class ParentModelWithTableAttribute extends Model { @@ -853,10 +1083,15 @@ class ModelWithPrimaryKeyAttribute extends Model { } +#[Table(key: false)] +class ModelWithoutPrimaryKeyAttribute extends Model +{ +} + #[Table(key: 'attribute_id')] class ModelWithPrimaryKeyAttributeAndProperty extends Model { - protected string $primaryKey = 'property_id'; + protected ?string $primaryKey = 'property_id'; } #[Table(key: 'uuid', keyType: 'string')] @@ -894,6 +1129,65 @@ class ModelWithIntegerBackedEnumConnectionAttribute extends Model { } +class ParentModelWithConnectionProperty extends Model +{ + protected UnitEnum|string|null $connection = 'default'; +} + +#[Connection('secondary')] +class ChildModelWithConnectionAttribute extends ParentModelWithConnectionProperty +{ +} + +class GrandchildModelWithConnectionAttribute extends ChildModelWithConnectionAttribute +{ +} + +#[Connection('secondary')] +class ParentModelWithConnectionAttribute extends Model +{ +} + +class ChildModelWithNoConnection extends ParentModelWithConnectionAttribute +{ +} + +class ChildModelWithConnectionProperty extends ParentModelWithConnectionAttribute +{ + protected UnitEnum|string|null $connection = 'default'; +} + +trait SetsRuntimeModelLocation +{ + /** + * Configure the model's table and connection during initialization. + */ + protected function initializeSetsRuntimeModelLocation(): void + { + $this->setTable('runtime_table'); + $this->setConnection('runtime_connection'); + } +} + +#[Table('attribute_table')] +#[Connection('secondary')] +class ModelWithLocationAttributes extends Model +{ +} + +class ModelWithLocationProperties extends Model +{ + protected ?string $table = 'parent_table'; + + protected UnitEnum|string|null $connection = 'default'; +} + +#[Table('attribute_table')] +#[Connection('secondary')] +class ModelWithLocationAttributeOverrides extends ModelWithLocationProperties +{ +} + #[Table(timestamps: false)] class ModelWithTimestampsFalseAttribute extends Model { diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index f9394dfc4e..0edcfde7b3 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -4364,7 +4364,7 @@ public function testFlushStateRestoresStaticState(): void $reflection->setStaticPropertyValue('relationResolvers', [ModelStub::class => ['foo' => fn () => null]]); $reflection->setStaticPropertyValue('guardableColumns', [ModelStub::class => ['id']]); $reflection->setStaticPropertyValue('guardConfigurations', [ModelStub::class => ['guarded' => [], 'propertyDefault' => ['*']]]); - $reflection->setStaticPropertyValue('classDeclaredAttributes', [ModelStub::class . '@attribute' => true]); + $reflection->setStaticPropertyValue('classAttributeOverrides', [ModelStub::class . '@attribute@property' => true]); $reflection->setStaticPropertyValue('classPropertyDeclarers', [ModelStub::class . '@table' => ModelStub::class]); $reflection->setStaticPropertyValue('mutatorCache', [ModelStub::class => ['foo']]); $reflection->setStaticPropertyValue('attributeMutatorCache', [ModelStub::class => ['foo' => true]]); @@ -4399,7 +4399,7 @@ public function testFlushStateRestoresStaticState(): void $this->assertSame([], $reflection->getStaticPropertyValue('relationResolvers')); $this->assertSame([], $reflection->getStaticPropertyValue('guardableColumns')); $this->assertSame([], $reflection->getStaticPropertyValue('guardConfigurations')); - $this->assertSame([], $reflection->getStaticPropertyValue('classDeclaredAttributes')); + $this->assertSame([], $reflection->getStaticPropertyValue('classAttributeOverrides')); $this->assertSame([], $reflection->getStaticPropertyValue('classPropertyDeclarers')); $this->assertTrue(Model::$snakeAttributes); $this->assertSame([], $reflection->getStaticPropertyValue('mutatorCache')); diff --git a/tests/Database/DatabaseEloquentMorphToTest.php b/tests/Database/DatabaseEloquentMorphToTest.php index 0f42afa586..6bf27bc2b9 100644 --- a/tests/Database/DatabaseEloquentMorphToTest.php +++ b/tests/Database/DatabaseEloquentMorphToTest.php @@ -461,7 +461,7 @@ class MorphToAssociateParentStub extends Model class MorphToAssociateRelatedStub extends Model { - protected string $primaryKey = 'model_id'; + protected ?string $primaryKey = 'model_id'; } class AccessibleMorphTo extends MorphTo diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php index 3e588ba9c0..cfedac16bc 100644 --- a/tests/Database/DatabaseManagerTest.php +++ b/tests/Database/DatabaseManagerTest.php @@ -80,6 +80,19 @@ public function testUsingConnectionAcceptsEnumsAndRestoresTheDefaultConnection() $this->assertSame('sqlite', $manager->getDefaultConnection()); } + public function testHeldConnectionFallsBackToTheResolverAndNormalizesEnumNames(): void + { + $this->db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ], 'analytics'); + + $manager = $this->db->getDatabaseManager(); + + $this->assertSame($manager->connection(), $manager->getHeldConnection()); + $this->assertSame($manager->connection('analytics'), $manager->getHeldConnection(DatabaseManagerTestUnitConnection::analytics)); + } + public function testReconnectorAcceptsArrayCallable(): void { $manager = $this->db->getDatabaseManager(); diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index 2e70b8b00c..608771296d 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -904,6 +904,57 @@ public function testQueriesRunWhileReconnectingDoNotReconnectAgain(): void } } + public function testUsingWritePdoUsesWritePdoForReadsWithinCallback(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + + $this->assertSame($readPdo, $connection->getReadPdo()); + + $this->assertSame('foo', $connection->usingWritePdo(function (MySqlConnection $connection) use ($writePdo): string { + $this->assertSame($writePdo, $connection->getReadPdo()); + + return 'foo'; + })); + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testUsingWritePdoRestoresPreviousStateWhenCallbackThrows(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + $failure = new Exception('Whoops!'); + + try { + $connection->usingWritePdo(static fn (): never => throw $failure); + + $this->fail('Exception was not thrown.'); + } catch (Exception $exception) { + $this->assertSame($failure, $exception); + $this->assertSame('Whoops!', $exception->getMessage()); + } + + $this->assertSame($readPdo, $connection->getReadPdo()); + } + + public function testUsingWritePdoPreservesAnAlreadyEnabledWriteConnectionPreference(): void + { + $writePdo = new PDOStub; + $readPdo = new PDOStub; + + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + $connection->useWriteConnectionWhenReading(); + + $connection->usingWritePdo(fn () => null); + + $this->assertSame($writePdo, $connection->getReadPdo()); + } + public function testExplicitPhysicalCommitFailureLeavesTheTransactionCallerOwned(): void { $failure = new RuntimeException('commit failure'); @@ -1391,6 +1442,117 @@ public function testDisconnectPreservesManagerFailureAfterLostPhysicalRollbackFa $this->assertNull($connection->getRawReadPdo()); } + public function testDisconnectReleasesEveryPdoWhenReleasingThemRaisesWarnings(): void + { + $connection = new PdoConnection(new PdoThatWarnsWhenReleased); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + $this->convertingWarningsToExceptions(fn () => $connection->disconnect()); + + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testLostConnectionIsRetriedWhenReleasingADeadReadPdoRaisesAWarning(): void + { + $statement = m::mock(PDOStatement::class); + $statement->expects('execute')->andThrow(new PDOException('server has gone away')); + $statement->expects('execute')->andReturn(true); + + $pdo = m::mock(PDO::class); + $pdo->expects('prepare')->times(2)->andReturn($statement); + $pdo->expects('inTransaction')->andReturnFalse(); + + $connection = new PdoConnection(fn () => $pdo); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + $connection->setReconnector(function (PdoConnection $connection) use ($pdo): void { + $connection->disconnect(); + $connection->setPdo(fn () => $pdo); + }); + + $this->assertTrue($this->convertingWarningsToExceptions(fn () => $connection->statement('foo'))); + $this->assertNull($connection->getRawReadPdo()); + } + + public function testLostCommitReleasesWarningHandlesAndPreservesTheOriginalFailure(): void + { + $failure = new ErrorException('PDO::commit(): SSL: Broken pipe', 0, E_WARNING); + $connection = new PdoConnection(new PdoThatWarnsWhenReleased($failure)); + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + + try { + $this->convertingWarningsToExceptions(fn () => $connection->transaction(static fn () => null)); + $this->fail('Expected the lost commit failure.'); + } catch (ErrorException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertNull($connection->getRawPdo()); + $this->assertNull($connection->getRawReadPdo()); + $this->assertSame(0, $connection->transactionLevel()); + } + + #[TestWith([false])] + #[TestWith([true])] + public function testFailedSessionConfigurationReleasesTheOldHandleDuringReplacement(bool $read): void + { + $failure = new RuntimeException('Session configuration failed.'); + PdoConnection::configureSessionUsing(new class($failure) implements SessionConfigurator { + /** + * Create a configurator that fails once. + */ + public function __construct(private ?RuntimeException $failure) + { + } + + /** + * Get the desired session state. + */ + public function state(PdoConnection $connection): ?string + { + return 'configured'; + } + + /** + * Apply the session state or throw the configured failure once. + */ + public function apply(PDO $pdo, string $state, PdoConnection $connection): void + { + if ($this->failure !== null) { + $failure = $this->failure; + $this->failure = null; + + throw $failure; + } + } + }); + + $connection = new PdoConnection($read ? new PDO('sqlite::memory:') : new PdoThatWarnsWhenReleased); + + if ($read) { + $connection->setReadPdo(new PdoThatWarnsWhenReleased); + } + + $getter = $read ? 'getReadPdo' : 'getPdo'; + $setter = $read ? 'setReadPdo' : 'setPdo'; + $replacement = new PDO('sqlite::memory:'); + $connection->setReconnector(static function (PdoConnection $connection) use ($setter, $replacement): void { + $connection->{$setter}($replacement); + }); + + $this->convertingWarningsToExceptions(function () use ($connection, $getter, $replacement, $failure): void { + try { + $connection->{$getter}(); + $this->fail('Expected session configuration to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame($replacement, $connection->{$getter}()); + }); + } + public function testRefreshTransfersOwnershipOfBothPhysicalHandles(): void { $connection = new PdoConnection(new PDO('sqlite::memory:')); @@ -1592,6 +1754,27 @@ static function () use (&$resolutions): PDO { $connection->endForeignKeyConstraintSuppression(); } + /** + * Convert unsuppressed warnings to exceptions during the callback. + */ + protected function convertingWarningsToExceptions(callable $callback): mixed + { + $errorReporting = error_reporting(E_ALL); + + set_error_handler(static function (int $level, string $message, string $file, int $line): void { + if (error_reporting() & $level) { + throw new ErrorException($message, 0, $level, $file, $line); + } + }); + + try { + return $callback(); + } finally { + restore_error_handler(); + error_reporting($errorReporting); + } + } + protected function getSqliteTransactionConnection(): PdoConnection { return new PdoConnection( @@ -1630,6 +1813,37 @@ public function __construct() } } +class PdoThatWarnsWhenReleased extends PDO +{ + /** + * Open a real handle with an optional commit failure. + */ + public function __construct(private ?Throwable $commitFailure = null) + { + parent::__construct('sqlite::memory:'); + } + + /** + * Simulate a native warning while releasing a dead connection. + */ + public function __destruct() + { + trigger_error('SSL: Broken pipe', E_USER_WARNING); + } + + /** + * Commit or raise the configured connection failure. + */ + public function commit(): bool + { + if ($this->commitFailure !== null) { + throw $this->commitFailure; + } + + return parent::commit(); + } +} + class VersionedPDOStub extends PDOStub { public function __construct(private readonly string $serverVersion) diff --git a/tests/Database/DatabasePdoReconnectTest.php b/tests/Database/DatabasePdoReconnectTest.php new file mode 100644 index 0000000000..2dbe831134 --- /dev/null +++ b/tests/Database/DatabasePdoReconnectTest.php @@ -0,0 +1,45 @@ +markTestSkipped('Swoole emits a transport notice before PDO can report the lost connection.'); + } + + $process = new Process([ + PHP_BINARY, + __DIR__ . '/Fixtures/ReconnectAfterSocketReset.php', + dirname(__DIR__, 2) . '/vendor/autoload.php', + $hooked ? 'yes' : 'no', + ]); + $process->setTimeout(10); + $process->run(); + + $this->assertSame(0, $process->getExitCode(), $process->getErrorOutput()); + $this->assertSame( + ['result' => true, 'reconnects' => 1], + json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR), + ); + } +} diff --git a/tests/Database/Eloquent/CustomBuilderForwardingTest.php b/tests/Database/Eloquent/CustomBuilderForwardingTest.php index 6b1d8cd103..18442299ff 100644 --- a/tests/Database/Eloquent/CustomBuilderForwardingTest.php +++ b/tests/Database/Eloquent/CustomBuilderForwardingTest.php @@ -28,7 +28,7 @@ public function testOrdinaryForwardingDiscardsResultsButPassthruAndNativeTermina $this->assertSame($builder->getModel(), $builder->models()->sole()); } - public function testRelationsDecorateFluentResultsButRetainTerminalsAndClonedBuilders(): void + public function testRelationsDecorateFluentResultsRetainTerminalsAndCloneTheirQueries(): void { $builder = $this->builder(new ForwardingTestModel); $parent = new ForwardingTestModel; @@ -40,9 +40,11 @@ public function testRelationsDecorateFluentResultsButRetainTerminalsAndClonedBui $this->assertSame(42, $relation->rawAnswer()); $this->assertSame($builder->getModel(), $relation->models()->sole()); $clone = $relation->clone(); - $this->assertInstanceOf(ForwardingTestBuilder::class, $clone); - $this->assertNotSame($builder, $clone); - $this->assertNotSame($builder->getQuery(), $clone->getQuery()); + $this->assertInstanceOf(HasMany::class, $clone); + $this->assertNotSame($relation, $clone); + $this->assertInstanceOf(ForwardingTestBuilder::class, $clone->getQuery()); + $this->assertNotSame($builder, $clone->getQuery()); + $this->assertNotSame($builder->getQuery(), $clone->getQuery()->getQuery()); } public function testScopesWinOverQueryForwardingButNotNativeEloquentOrRelationMethods(): void diff --git a/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php b/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php index 59e631c83e..9c16095dbf 100644 --- a/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php +++ b/tests/Database/Fixtures/Models/MorphEagerLoading/Video.php @@ -10,5 +10,5 @@ class Video extends Model { public bool $timestamps = false; - protected string $primaryKey = 'video_id'; + protected ?string $primaryKey = 'video_id'; } diff --git a/tests/Database/Fixtures/Models/User.php b/tests/Database/Fixtures/Models/User.php index b40ff2ba76..08535d38b6 100644 --- a/tests/Database/Fixtures/Models/User.php +++ b/tests/Database/Fixtures/Models/User.php @@ -11,5 +11,5 @@ class User extends Model { use Authenticatable; - protected string $primaryKey = 'internal_id'; + protected ?string $primaryKey = 'internal_id'; } diff --git a/tests/Database/Fixtures/ReconnectAfterSocketReset.php b/tests/Database/Fixtures/ReconnectAfterSocketReset.php new file mode 100644 index 0000000000..00caa602b8 --- /dev/null +++ b/tests/Database/Fixtures/ReconnectAfterSocketReset.php @@ -0,0 +1,175 @@ +> 16) . "\x15" . str_repeat("\0", 10) + . "abcdefghijkl\0mysql_native_password\0", 0); + receivePacket($socket); + packet($socket, "\0\0\0\x02\0\0\0", 2); + + return $socket; +} + +$listener = stream_socket_server('tcp://127.0.0.1:0', $errorCode, $errorMessage); +$address = stream_socket_get_name($listener, false); +$port = (int) substr(strrchr($address, ':'), 1); +[$parentControl, $childControl] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); +$child = pcntl_fork(); +if ($child === 0) { + fclose($parentControl); + $socket = null; + + try { + $socket = acceptConnection($listener); + stream_set_timeout($childControl, 3); + fread($childControl, 1); + socket_set_option(socket_import_stream($socket), SOL_SOCKET, SO_LINGER, ['l_onoff' => 1, 'l_linger' => 0]); + fclose($socket); + fwrite($childControl, 'x'); + + $socket = acceptConnection($listener); + + if (receivePacket($socket) !== "\x03SET @test = 1") { + throw new RuntimeException('The reconnect did not retry the expected statement.'); + } + + packet($socket, "\0\0\0\x02\0\0\0", 1); + receivePacket($socket); // COM_QUIT after the successful retry. + } finally { + if (is_resource($socket)) { + fclose($socket); + } + + fclose($childControl); + fclose($listener); + } + + exit(0); +} + +fclose($childControl); +fclose($listener); +$hooked = $argv[2] === 'yes'; +Runtime::enableCoroutine($hooked ? SWOOLE_HOOK_ALL : 0); + +$execute = static function () use ($port, $parentControl): void { + $connect = static fn (): PDO => new PDO("mysql:host=127.0.0.1;port={$port}", 'user', '', [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_EMULATE_PREPARES => true, + PDO::ATTR_TIMEOUT => 3, + ]); + $connection = new PdoConnection($connect()); + $reconnects = 0; + $connection->setReconnector(static function (PdoConnection $connection) use ($connect, &$reconnects): void { + ++$reconnects; + $connection->disconnect(); + $connection->setPdo($connect()); + }); + + set_error_handler(static function (int $severity, string $message, string $file, int $line): void { + if (error_reporting() & $severity) { + throw new ErrorException($message, 0, $severity, $file, $line); + } + }); + + try { + fwrite($parentControl, 'x'); + stream_set_timeout($parentControl, 3); + + if (fread($parentControl, 1) !== 'x') { + throw new RuntimeException('The server did not reset the connection.'); + } + + $result = $connection->statement('SET @test = 1'); + $connection->disconnect(); + + echo json_encode(['result' => $result, 'reconnects' => $reconnects], JSON_THROW_ON_ERROR); + } finally { + restore_error_handler(); + @$connection->disconnect(); + } +}; + +try { + $failure = null; + run(static function () use ($execute, &$failure): void { + try { + $execute(); + } catch (Throwable $exception) { + $failure = $exception; + } + }); + + if ($failure !== null) { + throw $failure; + } + + pcntl_waitpid($child, $status); + + if (! pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + throw new RuntimeException('The loopback server failed.'); + } +} finally { + fclose($parentControl); + + if (pcntl_waitpid($child, $status, WNOHANG) === 0) { + posix_kill($child, SIGKILL); + pcntl_waitpid($child, $status); + } +} diff --git a/tests/Foundation/Http/HealthCheckControllerTest.php b/tests/Foundation/Http/HealthCheckControllerTest.php deleted file mode 100644 index 95d240d1cd..0000000000 --- a/tests/Foundation/Http/HealthCheckControllerTest.php +++ /dev/null @@ -1,145 +0,0 @@ -shouldNotReceive('hasDebugModeEnabled'); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnFalse(); - $events->shouldNotReceive('dispatch'); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $response = new JsonResponse(['status' => 'up'], 200); - $responses = m::mock(ResponseFactory::class); - $responses->expects('json')->with(['status' => 'up'], 200)->andReturn($response); - $responses->shouldNotReceive('make'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - $this->assertSame($response, $controller(Request::create('/up', server: [ - 'HTTP_ACCEPT' => 'application/json', - ]))); - } - - public function testDiagnosisFailureIsReportedAndReturnsTheDownResponse(): void - { - $failure = new RuntimeException('Database connection refused.'); - $application = m::mock(Application::class); - $application->expects('hasDebugModeEnabled')->andReturnFalse(); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($failure); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->expects('report')->with($failure); - $response = new JsonResponse(['status' => 'down'], 500); - $responses = m::mock(ResponseFactory::class); - $responses->expects('json')->with(['status' => 'down'], 500)->andReturn($response); - $responses->shouldNotReceive('make'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - $this->assertSame($response, $controller(Request::create('/up', server: [ - 'HTTP_ACCEPT' => 'application/json', - ]))); - } - - public function testDiagnosisFailureEscapesWithoutReportingOrRenderingInDebugMode(): void - { - $failure = new RuntimeException('Database connection refused.'); - $application = m::mock(Application::class); - $application->expects('hasDebugModeEnabled')->andReturnTrue(); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($failure); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $responses = m::mock(ResponseFactory::class); - $responses->shouldNotReceive('make', 'json'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - try { - $controller(Request::create('/up')); - $this->fail('Expected the health diagnosis failure to escape in debug mode.'); - } catch (RuntimeException $exception) { - $this->assertSame($failure, $exception); - } - } - - public function testDiagnosisCancellationEscapesWithoutReportingOrRendering(): void - { - $cancellation = new CanceledException('cancelled'); - $application = m::mock(Application::class); - $application->shouldNotReceive('hasDebugModeEnabled'); - $events = m::mock(Dispatcher::class); - $events->expects('hasListeners')->with(DiagnosingHealth::class)->andReturnTrue(); - $events->expects('dispatch') - ->with(m::type(DiagnosingHealth::class)) - ->andThrow($cancellation); - $exceptions = m::mock(ExceptionHandler::class); - $exceptions->shouldNotReceive('report'); - $responses = m::mock(ResponseFactory::class); - $responses->shouldNotReceive('make', 'json'); - $views = m::mock(ViewFactory::class); - $views->shouldNotReceive('file'); - $controller = new HealthCheckController( - $application, - $events, - $exceptions, - $responses, - $views, - ); - - try { - $controller(Request::create('/up')); - $this->fail('Expected health diagnosis to preserve cancellation.'); - } catch (CanceledException $exception) { - $this->assertSame($cancellation, $exception); - } - } -} diff --git a/tests/Foundation/Http/HealthCheckTest.php b/tests/Foundation/Http/HealthCheckTest.php new file mode 100644 index 0000000000..52c7e9e614 --- /dev/null +++ b/tests/Foundation/Http/HealthCheckTest.php @@ -0,0 +1,78 @@ +once()->with(DiagnosingHealth::class)->andReturnFalse(); + Event::shouldReceive('dispatch')->never(); + Exceptions::fake(); + + $response = (new HealthCheck)(Request::create('/up', server: [ + 'HTTP_ACCEPT' => 'application/json', + ])); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame(['status' => 'up'], $response->getData(true)); + Exceptions::assertNothingReported(); + } + + public function testDiagnosisFailureEscapesWithoutReportingOrRenderingInDebugMode(): void + { + $failure = new RuntimeException('Database connection refused.'); + config(['app.debug' => true]); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); + Exceptions::fake(); + $this->mock(ResponseFactory::class)->shouldNotReceive('make', 'json'); + $caught = null; + + try { + (new HealthCheck)(Request::create('/up')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + Exceptions::assertNothingReported(); + } + + #[TestWith([false], 'direct cancellation')] + #[TestWith([true], 'cancellation through cleanup')] + public function testDiagnosisCancellationEscapesWithoutReportingOrRendering(bool $cleanupThrows): void + { + $cancellation = new CanceledException('cancelled'); + $failure = $cleanupThrows + ? new RuntimeException('Health probe cleanup failed.', previous: $cancellation) + : $cancellation; + config(['app.debug' => false]); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); + Exceptions::fake(); + $this->mock(ResponseFactory::class)->shouldNotReceive('make', 'json'); + $caught = null; + + try { + (new HealthCheck)(Request::create('/up')); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($failure, $caught); + Exceptions::assertNothingReported(); + } +} diff --git a/tests/Foundation/Http/Middleware/TransformsRequestTest.php b/tests/Foundation/Http/Middleware/TransformsRequestTest.php index eee0a5b4e8..53183afa99 100644 --- a/tests/Foundation/Http/Middleware/TransformsRequestTest.php +++ b/tests/Foundation/Http/Middleware/TransformsRequestTest.php @@ -4,14 +4,186 @@ namespace Hypervel\Tests\Foundation\Http\Middleware\TransformsRequestTest; +use Hypervel\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Hypervel\Foundation\Http\Middleware\TransformsRequest; +use Hypervel\Foundation\Http\Middleware\TrimStrings; +use Hypervel\Http\Exceptions\PostTooLargeException; use Hypervel\Http\Request; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request as SymfonyRequest; +use function Hypervel\Coroutine\parallel; + class TransformsRequestTest extends TestCase { + #[DataProvider('middlewareProvider')] + public function testUnchangedNestedArraysAreNotCopied(string $middlewareClass): void + { + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '[' . str_repeat('[0],', 19999) . '[0]]'); + $original = $request->json()->all(); + $middleware = new $middlewareClass; + $before = memory_get_usage(); + + $middleware->handle($request, fn () => null); + + // Copying the 20,000 unchanged inner arrays would require several MiB. + $this->assertLessThan(1024 * 1024, memory_get_usage() - $before); + $this->assertSame($original, $request->json()->all()); + } + + /** + * Provide middleware that transforms request input. + */ + public static function middlewareProvider(): iterable + { + yield [TransformsRequest::class]; + yield [TrimStrings::class]; + yield [ConvertEmptyStringsToNull::class]; + } + + #[DataProvider('amplifiedInputProvider')] + public function testRejectsAmplifiedJsonKeys(string $middlewareClass, mixed $value): void + { + $key = str_repeat('รจ', 197499); + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode([ + $key => array_fill(0, 100, $value), + ], JSON_UNESCAPED_UNICODE)); + + try { + (new $middlewareClass)->handle($request, function (): never { + $this->fail('An amplified request should not reach the next middleware.'); + }); + + $this->fail('An amplified request should be rejected.'); + } catch (PostTooLargeException $exception) { + $this->assertSame(413, $exception->getStatusCode()); + } + } + + /** + * Provide middleware and values that amplify nested input keys. + */ + public static function amplifiedInputProvider(): iterable + { + foreach ([TransformsRequest::class, TrimStrings::class, ConvertEmptyStringsToNull::class] as $middleware) { + foreach ([0, ' value ', '', []] as $value) { + yield [$middleware, $value]; + } + } + } + + public function testKeyBudgetIncludesNestedPathsAndPreservesDottedKeys(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + + /** + * Return the full input path for the value. + */ + protected function transform(string $key, mixed $value): mixed + { + return $key; + } + }; + + // The processed keys are "foo" (3 bytes) and "foo.bar" (7 bytes). + $middleware->handle(new Request(['foo' => ['bar' => 'value']]), function (Request $request): void { + $this->assertSame('foo.bar', $request->input('foo.bar')); + }); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle(new Request(['foo' => ['bars' => 'value']]), fn () => null); + } + + public function testKeyBudgetIsSharedAcrossParameterBags(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + $request = Request::create('/', 'POST', [], [], [], ['CONTENT_TYPE' => 'application/json'], '{"foobar": 0}'); + $request->query->set('hello', 0); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle($request, fn () => null); + } + + public function testKeyBudgetIsSharedAcrossQueryAndFormParameters(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + $request = Request::create('/?hello=0', 'POST', ['foobar' => 0]); + + $this->expectException(PostTooLargeException::class); + + $middleware->handle($request, fn () => null); + } + + public function testKeyBudgetResetsWhenMiddlewareIsReused(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + }; + + for ($i = 0; $i < 2; ++$i) { + $middleware->handle(new Request(['1234567890' => 'value']), function (Request $request): void { + $this->assertSame('value', $request->input('1234567890')); + }); + } + + try { + $middleware->handle(new Request(['12345678901' => 'value']), fn () => null); + + $this->fail('The key budget should be exceeded.'); + } catch (PostTooLargeException $exception) { + $this->assertSame(413, $exception->getStatusCode()); + } + + $middleware->handle(new Request(['1234567890' => 'value']), function (Request $request): void { + $this->assertSame('value', $request->input('1234567890')); + }); + } + + public function testYieldingTransformsKeepEachRequestsKeyBudget(): void + { + $middleware = new class extends TransformsRequest { + protected int $maxKeyBytes = 10; + + /** + * Yield during transformation to interleave requests. + */ + protected function transform(string $key, mixed $value): mixed + { + usleep(5000); + + return $value; + } + }; + $handle = static function (array $input) use ($middleware): int { + try { + return $middleware->handle(new Request($input), static fn (): int => 200); + } catch (PostTooLargeException $exception) { + return $exception->getStatusCode(); + } + }; + + $this->assertSame([200, 200], parallel([ + static fn (): int => $handle(['a' => ['b' => 1], 'c' => ['d' => 2]]), + static fn (): int => $handle(['a' => ['b' => 1], 'c' => ['d' => 2]]), + ])); + + $this->assertSame([413, 200], parallel([ + static fn (): int => $handle(['a' => ['bbbb' => 1], 'c' => ['dddd' => 2]]), + static fn (): int => $handle([]), + ])); + } + public function testEmptyParameterBagIsNotReadOrReplaced(): void { $bag = new TrackingParameterBag; diff --git a/tests/Foundation/Http/Middleware/TrimStringsTest.php b/tests/Foundation/Http/Middleware/TrimStringsTest.php index 7cab05b29e..71084139fd 100644 --- a/tests/Foundation/Http/Middleware/TrimStringsTest.php +++ b/tests/Foundation/Http/Middleware/TrimStringsTest.php @@ -11,9 +11,17 @@ class TrimStringsTest extends TestCase { - public function testNonStringValuesDoNotPerformExclusionMatching(): void + public function testNonStringValuesDoNotCheckExclusions(): void { $middleware = new TrimStringsTrackingExclusionMatches; + $data = ['values' => [[0], [false], [null], [1.5], []]]; + + $middleware->handle(new Request($data), function (Request $request) use ($data): void { + $this->assertSame($data, $request->query->all()); + }); + + $this->assertSame(0, $middleware->exclusionMatchCount); + $symfonyRequest = new SymfonyRequest([ 'integer' => 123, 'boolean' => true, diff --git a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php index 14f4a1165e..ac9f945f12 100644 --- a/tests/Foundation/Testing/DatabaseConnectionResolverTest.php +++ b/tests/Foundation/Testing/DatabaseConnectionResolverTest.php @@ -247,8 +247,16 @@ public function testIntegerBackedEnumConnectionNamesAreNormalizedInTestingAndPoo $resolver = $this->app->make(DatabaseConnectionResolver::class); $this->assertSame($resolver->connection(), $resolver->connection('')); - $this->assertSame('0', $resolver->connection(DatabaseTestingConnectionName::Zero)->getName()); - $this->assertSame('1', $resolver->connection(DatabaseTestingConnectionName::One)->getName()); + $this->assertSame($resolver->connection(), $resolver->getHeldConnection()); + $this->assertSame($resolver->getHeldConnection(), $resolver->getHeldConnection('')); + + $zero = $resolver->connection(DatabaseTestingConnectionName::Zero); + $one = $resolver->connection(DatabaseTestingConnectionName::One); + + $this->assertSame('0', $zero->getName()); + $this->assertSame('1', $one->getName()); + $this->assertSame($zero, $resolver->getHeldConnection(DatabaseTestingConnectionName::Zero)); + $this->assertSame($one, $resolver->getHeldConnection(DatabaseTestingConnectionName::One)); } public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): void @@ -259,6 +267,7 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v config(['database.connections.testing.database' => $database]); $resolver = $this->app->make(DatabaseConnectionResolver::class); $retained = $resolver->connection(); + $this->assertSame($retained, $resolver->getHeldConnection()); $pool = $this->app->make(PoolManager::class)->pool('testing'); $borrowed = $pool->getBorrowedCount(); $retained->beginTransaction(); @@ -267,6 +276,7 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v $this->assertTrue($resolver->hasApplicableTransaction()); config(['database.connections.testing.pool.testing_enabled' => true]); $this->assertFalse($resolver->hasApplicableTransaction()); + $this->assertNull($resolver->getHeldConnection()); $this->assertSame($borrowed, $pool->getBorrowedCount()); } finally { $retained->rollBack(); @@ -274,6 +284,8 @@ public function testPooledTransactionChecksIgnoreARetainedTestingConnection(): v $pooled = $resolver->connection(); $this->assertNotSame($retained, $pooled); + $this->assertSame($pooled, $resolver->getHeldConnection()); + $this->assertSame($retained, $resolver->getResolvedConnection('testing')); $pooled->beginTransaction(); try { diff --git a/tests/Image/Drivers/ImagickDriverTest.php b/tests/Image/Drivers/ImagickDriverTest.php index b8d5cb0f69..2f3598e28c 100644 --- a/tests/Image/Drivers/ImagickDriverTest.php +++ b/tests/Image/Drivers/ImagickDriverTest.php @@ -154,6 +154,43 @@ public function testProcessesHeicInput(): void $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); } + public function testProcessesOptimizeToJxl(): void + { + $this->ensureImageFormatCanBeEncoded('jxl'); + + $driver = new ImagickDriver; + + $result = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'jxl')); + + $header = substr($result, 0, 16); + + $this->assertTrue( + str_starts_with($result, "\xff\x0a") || str_contains($header, 'JXL '), + 'Expected a JPEG XL codestream or container signature.' + ); + } + + public function testProcessesJxlInput(): void + { + $this->ensureImageFormatCanBeEncoded('jxl'); + + $driver = new ImagickDriver; + $contents = $driver->process($this->fakeImageContents(), $this->pipeline(format: 'jxl')); + + $result = $driver->process($contents, $this->pipeline(new Cover(50, 25))); + + $header = substr($result, 0, 16); + + $this->assertTrue( + str_starts_with($result, "\xff\x0a") || str_contains($header, 'JXL '), + 'Expected a JPEG XL codestream or container signature.' + ); + + $result = $driver->process($result, $this->pipeline(format: 'jpg')); + + $this->assertSame([50, 25], array_slice(getimagesizefromstring($result), 0, 2)); + } + public function testDimensionsReturnsTheDecodedSize(): void { $driver = new ImagickDriver; diff --git a/tests/Image/ImageTest.php b/tests/Image/ImageTest.php index d67e2403b7..df72ebabe2 100644 --- a/tests/Image/ImageTest.php +++ b/tests/Image/ImageTest.php @@ -33,9 +33,12 @@ use Hypervel\Image\Transformations\Scale; use Hypervel\Image\Transformations\Sharpen; use Hypervel\Tests\TestCase; +use Imagick; +use ImagickException; use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use ReflectionProperty; use RuntimeException; use Stringable; @@ -174,6 +177,13 @@ public function testToHeicReturnsNewInstance(): void $this->assertNotSame($image, $image->toHeic()); } + public function testToJxlReturnsNewInstance(): void + { + $image = $this->makeImage(); + + $this->assertNotSame($image, $image->toJxl()); + } + public function testToBmpReturnsNewInstance(): void { $image = $this->makeImage(); @@ -343,6 +353,42 @@ public function testExtensionReturnsAvifForAvif(): void $this->assertSame('avif', $image->extension()); } + #[RequiresPhpExtension('imagick')] + public function testExtensionReturnsJxlForJxl(): void + { + if (Imagick::queryFormats('JXL') === []) { + $this->markTestSkipped('The Imagick extension was not compiled with jxl support.'); + } + + $imagick = new Imagick; + + try { + $imagick->newImage(10, 10, 'red'); + $imagick->setImageFormat('jxl'); + $contents = $imagick->getImageBlob(); + } catch (ImagickException) { + // A registered format may still lack its encoding delegate. + $contents = ''; + } finally { + $imagick->clear(); + $imagick->destroy(); + } + + if ($contents === '') { + $this->markTestSkipped('The Imagick extension cannot encode jxl images.'); + } + + $mimeType = (new finfo(FILEINFO_MIME_TYPE))->buffer($contents); + + if (! in_array($mimeType, ['image/jxl', 'image/x-jxl'], true)) { + $this->markTestSkipped('The installed fileinfo database does not recognize JXL.'); + } + + $image = new Image($contents); + + $this->assertSame('jxl', $image->extension()); + } + public function testDimensionsReturnsWidthAndHeight(): void { $image = new Image($this->fakeImageContents(300, 200)); @@ -402,6 +448,21 @@ public function transformUsing(string $transformation, callable $callback): stat } } + public function testDimensionsDefersToTheDriverForJxlImages(): void + { + // A JXL signature is recognized by fileinfo but cannot be read by getimagesizefromstring(). + $contents = "\xff\x0a\x00\x00"; + $driver = m::mock(Driver::class); + $driver->expects('dimensions')->once()->with($contents)->andReturn([123, 45]); + $this->registerDrivers(['fake' => $driver]); + + $image = (new Image($contents))->using('fake'); + + $this->assertSame([123, 45], $image->dimensions()); + $this->assertSame(123, $image->width()); + $this->assertSame(45, $image->height()); + } + public function testDimensionsPreservesDriverFailureForHeic(): void { $driverException = new ImageException('The driver cannot decode this image.'); @@ -709,6 +770,13 @@ public function testToHeicSetsFormat(): void $this->assertSame('heic', $this->getOptions($image->toHeic())->format); } + public function testToJxlSetsFormat(): void + { + $image = $this->makeImage(); + + $this->assertSame('jxl', $this->getOptions($image->toJxl())->format); + } + public function testToBmpSetsFormat(): void { $image = $this->makeImage(); @@ -729,6 +797,7 @@ public function testToFormatSupportsEveryPublicFormatSpelling(): void 'avif' => 'avif', 'heic' => 'heic', 'heif' => 'heic', + 'jxl' => 'jxl', 'bmp' => 'bmp', ] as $format => $expected) { $this->assertSame($expected, $this->getOptions($image->toFormat($format))->format); @@ -1374,6 +1443,13 @@ public function testOptimizeAllowsHeic(): void $this->assertSame('heic', $this->getOptions($result)->format); } + public function testOptimizeAllowsJxl(): void + { + $result = $this->makeImage()->optimize('jxl'); + + $this->assertSame('jxl', $this->getOptions($result)->format); + } + public function testOptimizeNormalizesHeifToHeic(): void { $result = $this->makeImage()->optimize('heif'); diff --git a/tests/Integration/Broadcasting/BroadcastManagerTest.php b/tests/Integration/Broadcasting/BroadcastManagerTest.php index 790a7998d9..9af9655418 100644 --- a/tests/Integration/Broadcasting/BroadcastManagerTest.php +++ b/tests/Integration/Broadcasting/BroadcastManagerTest.php @@ -48,6 +48,7 @@ use Hypervel\Http\Request; use Hypervel\ObjectPool\PoolManager; use Hypervel\Queue\Attributes\UniqueFor; +use Hypervel\Queue\Events\UniqueJobSkipped; use Hypervel\Queue\FailoverQueue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\SyncQueue; @@ -58,6 +59,7 @@ use Hypervel\Support\Facades\Broadcast; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Facades\Event; use Hypervel\Support\Facades\Queue; use Hypervel\Support\Testing\Fakes\QueueFake; use Hypervel\Testbench\TestCase; @@ -325,6 +327,69 @@ public function testUniqueEventsUseInheritedUniqueForAttribute(): void Queue::assertPushed(UniqueBroadcastEvent::class, 2); } + public function testUniqueEventsEmitUniqueJobSkippedEventWhenLockNotAcquired(): void + { + Queue::fake(); + + $skipped = []; + + Event::listen(UniqueJobSkipped::class, function (UniqueJobSkipped $event) use (&$skipped): void { + $skipped[] = $event->job; + }); + + Broadcast::queue(new TestEventUnique); + + $this->assertSame([], $skipped); + + Broadcast::queue(new TestEventUnique); + + Queue::assertPushed(UniqueBroadcastEvent::class, 1); + $this->assertCount(1, $skipped); + $this->assertInstanceOf(UniqueBroadcastEvent::class, $skipped[0]); + $this->assertInstanceOf(TestEventUnique::class, $skipped[0]->event); + } + + #[TestWith(['lock', true], 'rescued lock failure')] + #[TestWith(['lock', false], 'ordinary lock failure')] + #[TestWith(['listener', true], 'rescued skip-listener failure')] + #[TestWith(['listener', false], 'ordinary skip-listener failure')] + #[TestWith(['listener', true, true], 'skip listener propagates chained cancellation')] + public function testUniqueBroadcastPreparationHonorsRescue(string $phase, bool $rescued, bool $cancellation = false): void + { + Queue::fake(); + + $failure = new RuntimeException('Broadcast preparation failed.', previous: $cancellation ? new CanceledException : null); + $event = $rescued ? new TestEventUniqueRescue : new TestEventUnique; + $handler = m::mock(ExceptionHandler::class); + $this->app->instance(ExceptionHandler::class, $handler); + + if ($rescued && ! $cancellation) { + $handler->expects('report')->with($failure, []); + } else { + $handler->shouldNotReceive('report'); + } + + if ($phase === 'lock') { + $store = m::mock(ArrayStore::class)->makePartial(); + $store->expects('lock')->andThrow($failure); + $this->app->instance(Cache::class, new CacheRepository($store)); + } else { + Broadcast::queue($event); + Event::listen(UniqueJobSkipped::class, static fn () => throw $failure); + } + + $caught = null; + + try { + Broadcast::queue($event); + } catch (Throwable $exception) { + $caught = $exception; + } + + $this->assertSame($rescued && ! $cancellation ? null : $failure, $caught); + Queue::assertPushed(UniqueBroadcastEvent::class, $phase === 'lock' ? 0 : 1); + } + public function testUniqueForAttributeDoesNotMakeEventsUniqueWithoutTheContract(): void { Queue::fake(); diff --git a/tests/Integration/Console/UniqueJobSchedulingTest.php b/tests/Integration/Console/UniqueJobSchedulingTest.php index 811dd10963..0e63954f5a 100644 --- a/tests/Integration/Console/UniqueJobSchedulingTest.php +++ b/tests/Integration/Console/UniqueJobSchedulingTest.php @@ -9,15 +9,19 @@ use Hypervel\Bus\UniqueLock; use Hypervel\Console\Scheduling\Schedule; use Hypervel\Contracts\Bus\Dispatcher; +use Hypervel\Contracts\Queue\Queue as QueueContract; use Hypervel\Contracts\Queue\ShouldBeUnique; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Foundation\Bus\Dispatchable; +use Hypervel\Queue\Connectors\ConnectorInterface; use Hypervel\Queue\InteractsWithQueue; +use Hypervel\Queue\NullQueue; use Hypervel\Support\Facades\Bus; use Hypervel\Support\Facades\Queue; use Hypervel\Testbench\TestCase; use Mockery as m; use RuntimeException; +use UnitEnum; class UniqueJobSchedulingTest extends TestCase { @@ -47,6 +51,46 @@ public function testUniqueJobsPushedToQueue(): void Queue::assertPushed(UniqueTestJob::class, 1); } + public function testUniqueLockIsReleasedWhenPushingToTheQueueFails(): void + { + Queue::extend('failing', fn () => new class implements ConnectorInterface { + /** + * Establish a queue connection. + */ + public function connect(array $config): QueueContract + { + return new class extends NullQueue { + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): never + { + throw new RuntimeException('Queue unavailable.'); + } + }; + } + }); + + config()->set('queue.connections.failing', ['driver' => 'failing']); + + $event = $this->app->make(Schedule::class)->job(UniqueTestJob::class, null, 'failing')->name('')->everyMinute(); + $caught = null; + + try { + $event->run($this->app); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertSame('Queue unavailable.', $caught->getMessage()); + + Queue::fake(); + $this->dispatchJobs(UniqueTestJob::class); + + Queue::assertPushed(UniqueTestJob::class, 1); + } + public function testUniqueJobsRemainLockedWhenRecordedByBusFake(): void { Bus::fake(); @@ -75,34 +119,6 @@ public function testUniqueLockIsReleasedWhenDispatchFailsBeforePayloadCreation() $exception = new RuntimeException('Dispatch failed before payload creation.'); $calls = 0; - $dispatcher = m::mock(Dispatcher::class); - $dispatcher->shouldReceive('dispatch') - ->twice() - ->andReturnUsing(function () use (&$calls, $exception): void { - if (++$calls === 1) { - throw $exception; - } - }); - - $this->app->instance(Dispatcher::class, $dispatcher); - - $schedule = $this->scheduleUniqueJobs(2); - - try { - $schedule->events()[0]->run($this->app); - $this->fail('Expected the first dispatch to fail.'); - } catch (RuntimeException $throwable) { - $this->assertSame($exception, $throwable); - } - - $schedule->events()[1]->run($this->app); - } - - public function testUniqueLockIsReleasedWhenDispatchFailsAfterPayloadCreationBegins(): void - { - $exception = new RuntimeException('Dispatch failed after payload creation began.'); - $calls = 0; - $dispatcher = m::mock(Dispatcher::class); $dispatcher->shouldReceive('dispatch') ->twice() diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 4328b427ac..0f70775b46 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -297,6 +297,19 @@ public function testAttachMethod(): void $this->assertEquals($tag8->name, $post->tags[7]->name); } + public function testCloneKeepsTheRelationQuery(): void + { + $user = User::create(['name' => Str::random()]); + $other = User::create(['name' => Str::random()]); + $post = Post::create(['title' => Str::random()]); + $otherPost = Post::create(['title' => Str::random()]); + + $other->posts()->attach($otherPost); + $user->posts()->attach($post); + + $this->assertEquals([$post->id], $user->posts()->clone()->get()->modelKeys()); + } + public function testDetachMethod(): void { $post = Post::create(['title' => Str::random()]); diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index 8594fcae33..e3037aee74 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -155,6 +155,16 @@ public function testHasSelfCustomOwnerKey(): void $this->assertCount(1, $users); } + public function testCloneKeepsTheRelationQuery(): void + { + $team = Team::create(); + User::create(['name' => Str::random()]); + $user = User::create(['name' => Str::random(), 'team_id' => $team->id]); + $article = Article::create(['user_id' => $user->id, 'title' => Str::random()]); + + $this->assertEquals([$article->id], $team->articles()->clone()->get()->modelKeys()); + } + public function testHasSameParentAndThroughParentTable(): void { Category::create(); diff --git a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php index 355cab6cf0..c01d36bfb7 100644 --- a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php @@ -155,7 +155,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; /** * Get the post's user. diff --git a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php index 009848adf4..95dfae4f82 100644 --- a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php @@ -74,7 +74,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; /** * Get the post's user. diff --git a/tests/Integration/Database/EloquentMorphToEagerLoadTest.php b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php index 4926d949e4..9937eee534 100644 --- a/tests/Integration/Database/EloquentMorphToEagerLoadTest.php +++ b/tests/Integration/Database/EloquentMorphToEagerLoadTest.php @@ -118,7 +118,7 @@ class Article extends Model { public bool $timestamps = false; - protected string $primaryKey = 'slug'; + protected ?string $primaryKey = 'slug'; protected string $keyType = 'string'; diff --git a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php index f0fdf982cf..85d4281094 100644 --- a/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphToLazyEagerLoadingTest.php @@ -69,7 +69,7 @@ class Post extends Model { public bool $timestamps = false; - protected string $primaryKey = 'post_id'; + protected ?string $primaryKey = 'post_id'; protected array $with = ['user']; diff --git a/tests/Integration/Database/EloquentMorphToTargetKeysTest.php b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php index 6d3c138210..35051a845a 100644 --- a/tests/Integration/Database/EloquentMorphToTargetKeysTest.php +++ b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php @@ -109,7 +109,7 @@ class Video extends Model { public bool $timestamps = false; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; protected string $keyType = 'string'; diff --git a/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php b/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php index 8103d0c22c..7d4d302f79 100644 --- a/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php +++ b/tests/Integration/Database/EloquentUniqueStringPrimaryKeysTest.php @@ -200,5 +200,5 @@ class ModelWithCustomUuidPrimaryKeyName extends Eloquent protected array $guarded = []; - protected string $primaryKey = 'uuid'; + protected ?string $primaryKey = 'uuid'; } diff --git a/tests/Integration/Database/Fixtures/PostStringyKey.php b/tests/Integration/Database/Fixtures/PostStringyKey.php index de1ba44660..22279dbc1a 100644 --- a/tests/Integration/Database/Fixtures/PostStringyKey.php +++ b/tests/Integration/Database/Fixtures/PostStringyKey.php @@ -10,5 +10,5 @@ class PostStringyKey extends Model { protected ?string $table = 'my_posts'; - protected string $primaryKey = 'my_id'; + protected ?string $primaryKey = 'my_id'; } diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index a782e6c05c..f273684cd0 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -220,6 +220,29 @@ public function testAlterTableAddForeignKeyWithPrefix(): void ); } + public function testAlterTableWithForeignKeyToTableNameThatNeedsQuoting(): void + { + Schema::create('group', function (Blueprint $table): void { + $table->id(); + }); + + Schema::create('members', function (Blueprint $table): void { + $table->id(); + $table->foreignId('group_id')->constrained('group'); + $table->string('name'); + }); + + Schema::table('members', function (Blueprint $table): void { + $table->string('name')->nullable()->change(); + }); + + $this->assertTrue(collect(Schema::getForeignKeys('members'))->contains( + fn ($foreignKey) => $foreignKey['foreign_table'] === 'group' + && $foreignKey['foreign_columns'] === ['id'] + && $foreignKey['columns'] === ['group_id'] + )); + } + public function testAlterTableAddForeignKeyWithExpressionDefault(): void { Schema::create('items', function (Blueprint $table) { diff --git a/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php b/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php new file mode 100644 index 0000000000..e12e0c790e --- /dev/null +++ b/tests/Integration/Foundation/Http/Middleware/PreventStaleDatabaseReadsTest.php @@ -0,0 +1,188 @@ +directory = ParallelTesting::tempDir('PreventStaleDatabaseReadsTest'); + $files = $app->make('files'); + $files->deleteDirectory($this->directory); + $files->ensureDirectoryExists($this->directory); + $files->put($this->directory . '/read.sqlite', ''); + $files->put($this->directory . '/write.sqlite', ''); + + // In-memory SQLite deliberately shares one PDO; distinct files exercise read routing. + $connection = [ + 'driver' => 'sqlite', + 'database' => $this->directory . '/write.sqlite', + 'read' => ['database' => $this->directory . '/read.sqlite'], + 'write' => ['database' => $this->directory . '/write.sqlite'], + ]; + + $app->make('config')->set([ + 'database.default' => 'testing', + 'database.connections.testing' => $connection, + 'database.connections.other' => $connection, + 'session.driver' => 'array', + ]); + } + + protected function defineRoutes(Router $router): void + { + $router->middleware([StartSession::class, PreventStaleDatabaseReads::class])->group(function (Router $router): void { + $router->get('read', fn () => DB::select('select 1')); + $router->post('write', fn () => DB::statement('select 1')); + $router->get('no-query', fn () => (string) $this->app->make(PoolManager::class)->pool('testing')->getBorrowedCount()); + $router->post('change-default', function (): string { + DB::statement('select 1'); + DB::setDefaultConnection('other'); + + return 'ok'; + }); + }); + + $router->middleware([StartSession::class, PreventStaleDatabaseReads::for(10)])->group(function (Router $router): void { + $router->get('read-later', fn () => DB::select('select 1')); + }); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->freezeTime(); + } + + protected function tearDown(): void + { + try { + parent::tearDown(); + } finally { + (new Filesystem)->deleteDirectory($this->directory); + } + } + + public function testReadsUseTheReadConnectionWithoutARecentWrite(): void + { + DB::enableQueryLog(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + public function testReadsUseTheWriteConnectionAfterAWrite(): void + { + $this->assertNull(DB::getHeldConnection()); + + $this->post('write')->assertOk() + ->assertSessionHas('_database_modified_at', Date::now()->getTimestamp()); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(2)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('write', DB::getQueryLog()[0]['readWriteType']); + } + + public function testReadsUseTheReadConnectionOnceTheDelayHasPassed(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(3)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + public function testTheDelayCanBeCustomized(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + DB::enableQueryLog(); + + $this->travel(10)->seconds(); + + $this->get('read-later')->assertOk(); + + $this->assertSame('write', DB::getQueryLog()[0]['readWriteType']); + } + + public function testTheConnectionStopsBeingStickyOnceTheDelayHasPassed(): void + { + $this->post('write')->assertOk(); + + DB::forgetRecordModificationState(); + + $this->get('read')->assertOk(); + + DB::enableQueryLog(); + + $this->travel(3)->seconds(); + + $this->get('read')->assertOk(); + + $this->assertSame('read', DB::getQueryLog()[0]['readWriteType']); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testRequestsWithoutQueriesDoNotBorrowAConnection(): void + { + $pool = $this->app->make(PoolManager::class)->pool('testing'); + + $this->get('no-query')->assertOk()->assertContent('0'); + + $this->assertSame(0, $pool->getBorrowedCount()); + $this->assertNull(DB::getHeldConnection()); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testWritesAreTrackedOnTheOriginalDefaultConnection(): void + { + $this->post('change-default')->assertOk() + ->assertSessionHas('_database_modified_at', Date::now()->getTimestamp()); + } + + #[WithConfig('database.connections.testing.pool.testing_enabled', true)] + public function testAnAlreadyHeldConnectionStopsUsingTheWriterWithoutARecentWrite(): void + { + $connection = DB::connection(); + $connection->useWriteConnectionWhenReading(); + $connection->enableQueryLog(); + $request = Request::create('/read'); + $request->setHypervelSession($this->app->make('session')->driver()); + + $this->app->make(PreventStaleDatabaseReads::class)->handle($request, function () use ($connection): array { + return $connection->select('select 1'); + }); + + $this->assertSame('read', $connection->getQueryLog()[0]['readWriteType']); + } +} diff --git a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php index 3bb7752919..d08a382a60 100644 --- a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php +++ b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php @@ -7,9 +7,11 @@ use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\DiagnosingHealth; +use Hypervel\Http\Request; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Event; +use Hypervel\Support\Facades\Exceptions; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; @@ -81,13 +83,15 @@ public function testItReturnsJsonWhenRequestExpectsJson(): void public function testItReturnsJsonFailureStatusWhenDiagnosisReportsAProblem(): void { - Event::listen(DiagnosingHealth::class, static function (): never { - throw new RuntimeException('Database connection refused.'); - }); + $failure = new RuntimeException('Database connection refused.'); + Exceptions::fake(); + Event::listen(DiagnosingHealth::class, static fn () => throw $failure); $this->getJson('/up') ->assertStatus(500) ->assertExactJson(['status' => 'down']); + + Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception === $failure); } public function testItRendersHtmlFailurePageWhenDiagnosisReportsAProblem(): void @@ -101,6 +105,31 @@ public function testItRendersHtmlFailurePageWhenDiagnosisReportsAProblem(): void ->assertSee('experiencing problems'); } + public function testHealthRouteCanBeCachedWithoutAbsolutePaths(): void + { + $route = $this->app->make('router')->getRoutes()->match(Request::create('/up')); + + $route->prepareForSerialization(); + + $this->assertStringNotContainsString( + realpath(__DIR__ . '/../../../../../src'), + $route->getAction('uses') + ); + + $this->get('/up')->assertOk(); + } + + public function testHealthRouteHonorsEventFakesAfterAnEarlierRequest(): void + { + $this->getJson('/up')->assertOk(); + + Event::fake([DiagnosingHealth::class]); + + $this->getJson('/up')->assertOk(); + + Event::assertDispatched(DiagnosingHealth::class); + } + public function testEmptyDiagnosisMessagesStillProduceFailureResponses(): void { Event::listen(DiagnosingHealth::class, static function (): never { diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 0a8a9a4abb..94b3022f65 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -543,6 +543,43 @@ public function push(object|string $job, mixed $data = '', UnitEnum|string|null $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()); } + public function testListenerLockIsReleasedWhenPushingToTheQueueFails(): void + { + Queue::extend('failing', fn () => new class implements ConnectorInterface { + /** + * Establish a queue connection. + */ + public function connect(array $config): QueueContract + { + return new class extends NullQueue { + /** + * Push a new job onto the queue. + */ + public function push(object|string $job, mixed $data = '', UnitEnum|string|null $queue = null): never + { + throw new RuntimeException('Queue unavailable.'); + } + }; + } + }); + + config()->set('queue.connections.failing', ['driver' => 'failing']); + + Event::listen(UniqueTestEvent::class, UniqueTestFailingPushListener::class); + + try { + event(new UniqueTestEvent); + + $this->fail('Expected pushing the listener to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Queue unavailable.', $exception->getMessage()); + } + + $lockKey = 'laravel_unique_job:' . hash('xxh128', UniqueTestFailingPushListener::class) . ':'; + + $this->assertTrue($this->app->get(Cache::class)->lock($lockKey, 10)->get()); + } + /** * Get the unique lock key for the given job. */ @@ -826,6 +863,18 @@ public function handle(UniqueTestEvent $event): void } } +class UniqueTestFailingPushListener implements ShouldQueue, ShouldBeUnique +{ + public string $connection = 'failing'; + + /** + * Handle the event. + */ + public function handle(UniqueTestEvent $event): void + { + } +} + class NestedUniqueParentJob implements ShouldQueue, ShouldBeUnique { use Dispatchable; diff --git a/tests/Integration/View/BladeTest.php b/tests/Integration/View/BladeTest.php index 6a8867fb3d..c8af3bc9ed 100644 --- a/tests/Integration/View/BladeTest.php +++ b/tests/Integration/View/BladeTest.php @@ -14,6 +14,7 @@ use Hypervel\View\Component; use Mockery as m; use Override; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use Symfony\Component\Finder\Finder; use Symfony\Component\Finder\SplFileInfo; @@ -303,6 +304,16 @@ public function testViewCacheCommandDeduplicatesPathsBeforeCompiling(): void $this->artisan('view:cache'); } + #[RequiresOperatingSystem('Linux|Darwin')] + public function testViewCacheCommandCompilesNamespacedViewsAtThePathTheyRenderFrom(): void + { + View::addNamespace('components', join_paths(__DIR__, 'Fixtures', 'templates', 'components', '..', 'components')); + + $this->artisan('view:cache'); + + $this->assertFileExists(Blade::getCompiledPath(View::getFinder()->find('components::panel'))); + } + /** * Configure the Blade fixture templates. */ diff --git a/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php b/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php index 8ddae65449..d41dcb342b 100644 --- a/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php +++ b/tests/Mcp/Feature/Client/OAuthCallbackRouteTest.php @@ -55,6 +55,7 @@ private function fakeOAuthSession(): array 'token_endpoint' => 'https://auth.test/token', 'redirect_uri' => 'https://app.test/callback', 'return_to' => null, + 'resource' => 'https://mcp.test/mcp', ]]; } @@ -102,7 +103,8 @@ public function testExchangesTheCodeAndInvokesTheHandlerWithTheClientName(): voi Http::assertSent(fn ($request): bool => $request->url() === 'https://auth.test/token' && ($request['grant_type'] ?? null) === 'authorization_code' - && ($request['code'] ?? null) === 'auth-code'); + && ($request['code'] ?? null) === 'auth-code' + && ($request['resource'] ?? null) === 'https://mcp.test/mcp'); } public function testRegistersAConnectRouteThatRedirectsToTheAuthorizationServer(): void diff --git a/tests/Notifications/NotificationFailedEventTest.php b/tests/Notifications/NotificationFailedEventTest.php index 2d15374d74..98588efef8 100644 --- a/tests/Notifications/NotificationFailedEventTest.php +++ b/tests/Notifications/NotificationFailedEventTest.php @@ -111,29 +111,6 @@ public function testSequentialAttemptsDoNotShareFailureState(): void $this->assertSame(['channel-owned', 'sender-owned'], $dispatched()); } - public function testReportedFailureWithoutAnExceptionDoesNotAffectTheNextAttempt(): void - { - $events = $this->app->make(Dispatcher::class); - $manager = $this->app->make(ChannelManager::class); - $notifiable = new NotificationFailedEventNotifiable; - $notification = new NotificationFailedEventNotification; - $dispatched = $this->recordFailures($events); - - $manager->extend('reported', fn () => new NotificationFailedEventReportingChannel($events)); - $manager->extend('throwing', fn () => new NotificationFailedEventThrowingChannel); - - $manager->sendNow($notifiable, $notification, ['reported']); - - // Check cleanup before another attempt can overwrite a leaked failure marker. - $this->assertNull(CoroutineContext::get(NotificationSender::FAILED_EVENT_DISPATCHED_CONTEXT_KEY)); - - $this->captureRuntimeException( - fn () => $manager->sendNow($notifiable, $notification, ['throwing']) - ); - - $this->assertSame(['reported', 'throwing'], $dispatched()); - } - public function testSuccessfulAndExceptionalAttemptsRemoveTheirContextState(): void { $manager = $this->app->make(ChannelManager::class); @@ -248,24 +225,6 @@ public function send(mixed $notifiable, Notification $notification): never } } -class NotificationFailedEventReportingChannel -{ - /** - * Create a channel that reports a failure without throwing. - */ - public function __construct(private readonly Dispatcher $events) - { - } - - /** - * Report the delivery failure without an exception. - */ - public function send(mixed $notifiable, Notification $notification): void - { - $this->events->dispatch(new NotificationFailed($notifiable, $notification, 'reported')); - } -} - class NotificationFailedEventSuccessfulChannel { /** diff --git a/tests/Notifications/NotificationSenderTest.php b/tests/Notifications/NotificationSenderTest.php index 83f3680759..c0c0748668 100644 --- a/tests/Notifications/NotificationSenderTest.php +++ b/tests/Notifications/NotificationSenderTest.php @@ -9,10 +9,12 @@ use Hypervel\Bus\Queueable; use Hypervel\Config\Repository as Config; use Hypervel\Container\Container; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Bus\Dispatcher as BusDispatcherContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Events\Dispatcher as EventDispatcher; +use Hypervel\Foundation\Application; use Hypervel\Notifications\AnonymousNotifiable; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Events\NotificationDelivered; @@ -23,6 +25,7 @@ use Hypervel\Notifications\Notifiable; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; +use Hypervel\Notifications\NotificationServiceProvider; use Hypervel\Notifications\SendQueuedNotifications; use Hypervel\Queue\Attributes\Delay; use Hypervel\Queue\Attributes\Queue; @@ -551,6 +554,62 @@ public function testNotificationFailedSentWithoutHttpTransportException(): void $this->assertSame(TransportException::class, $failed->data['exception']::class); } + public function testNotificationFailedIsDispatchedAfterAChannelDispatchedItWithoutThrowing(): void + { + $notifiable = new AnonymousNotifiable; + $container = new Application; + $container->instance('config', new Config(['app' => ['name' => 'Name', 'logo' => 'Logo']])); + $manager = m::mock(ChannelManager::class . '[driver]', [$container]); + $bus = new BusFake(new BusDispatcher(new Container)); + $events = new EventDispatcher; + $container->instance('events', $events); + (new NotificationServiceProvider($container))->boot(); + + $failed = []; + $events->listen(NotificationFailed::class, function (NotificationFailed $event) use (&$failed): void { + $failed[] = $event->channel; + }); + + $pushChannel = new class($events) { + /** + * Create a channel that reports failures without throwing. + */ + public function __construct(protected Dispatcher $events) + { + } + + /** + * Send the notification. + */ + public function send(mixed $notifiable, Notification $notification): void + { + $this->events->dispatch(new NotificationFailed($notifiable, $notification, 'push')); + } + }; + + $smsChannel = new ChannelSpy; + $smsChannel->exception = new RuntimeException('SMS gateway down'); + + $manager->expects('driver')->with('push')->andReturn($pushChannel); + $manager->expects('driver')->with('sms')->andReturn($smsChannel); + + $sender = new NotificationSender($manager, $bus, $events); + $sender->sendNow($notifiable, new DummyNotificationWithViaConnections, ['push']); + + // Check cleanup before another attempt can overwrite a leaked failure marker. + $this->assertNull(CoroutineContext::get(NotificationSender::FAILED_EVENT_DISPATCHED_CONTEXT_KEY)); + $caught = null; + + try { + $sender->sendNow($notifiable, new DummyNotificationWithViaConnections, ['sms']); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($smsChannel->exception, $caught); + $this->assertSame(['push', 'sms'], $failed); + } + public function testItPreservesNotificationStateMutatedInViaMethod(): void { $notifiable = new AnonymousNotifiable; diff --git a/tests/Passkeys/Feature/PasskeyTest.php b/tests/Passkeys/Feature/PasskeyTest.php index 3b99c6dc47..86f7eca0ec 100644 --- a/tests/Passkeys/Feature/PasskeyTest.php +++ b/tests/Passkeys/Feature/PasskeyTest.php @@ -58,7 +58,7 @@ class UserWithCustomPrimaryKey extends Authenticatable implements PasskeyUser protected ?string $table = 'users_with_custom_primary_keys'; - protected string $primaryKey = 'user_id'; + protected ?string $primaryKey = 'user_id'; public bool $incrementing = false; diff --git a/tests/Passport/RelationConnectionsTest.php b/tests/Passport/RelationConnectionsTest.php index 17067133f5..9f88d9fdd8 100644 --- a/tests/Passport/RelationConnectionsTest.php +++ b/tests/Passport/RelationConnectionsTest.php @@ -162,5 +162,5 @@ class PassportConnectionClient extends Client class PassportCustomKeyClient extends Client { - protected string $primaryKey = 'client_uuid'; + protected ?string $primaryKey = 'client_uuid'; } diff --git a/tests/Permission/Fixtures/Models/Permission.php b/tests/Permission/Fixtures/Models/Permission.php index c7b70f8f18..9f4dce1554 100644 --- a/tests/Permission/Fixtures/Models/Permission.php +++ b/tests/Permission/Fixtures/Models/Permission.php @@ -12,7 +12,7 @@ class Permission extends BasePermission { use SoftDeletes; - protected string $primaryKey = 'permission_test_id'; + protected ?string $primaryKey = 'permission_test_id'; protected array $visible = [ 'permission_test_id', diff --git a/tests/Permission/Fixtures/Models/Role.php b/tests/Permission/Fixtures/Models/Role.php index 7475f84537..f42024a162 100644 --- a/tests/Permission/Fixtures/Models/Role.php +++ b/tests/Permission/Fixtures/Models/Role.php @@ -16,7 +16,7 @@ class Role extends BaseRole public const string HIERARCHY_TABLE = 'roles_hierarchy'; - protected string $primaryKey = 'role_test_id'; + protected ?string $primaryKey = 'role_test_id'; protected array $visible = [ 'role_test_id', diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 1a359f02e1..dd074ce647 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -1642,6 +1642,8 @@ public static function connectionFailureProvider(): array 'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true], 'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false], 'warning-shaped reset' => [new ErrorException('Redis::get(): SSL: Connection reset by peer'), 'get', ['foo'], false], + 'broken pipe while reading' => [new ErrorException('Redis::lLen(): SSL: Broken pipe'), 'llen', ['foo'], false], + 'broken pipe during a non-idempotent write' => [new ErrorException('Redis::rPush(): SSL: Broken pipe'), 'rpush', ['foo', 'bar'], false], 'stream write failure' => [new ErrorException('Redis::get(): Send of 30 bytes failed with errno=32 Broken pipe'), 'get', ['foo'], false], 'TLS protocol failure' => [new ErrorException('Redis::get(): SSL operation failed with code 1. OpenSSL Error messages: bad length'), 'get', ['foo'], false], 'transaction opening' => [new RedisException('Connection lost'), 'multi', [], false], diff --git a/tests/Sanctum/PersonalAccessTokenCacheTest.php b/tests/Sanctum/PersonalAccessTokenCacheTest.php index 2b9975c24e..72d55d253e 100644 --- a/tests/Sanctum/PersonalAccessTokenCacheTest.php +++ b/tests/Sanctum/PersonalAccessTokenCacheTest.php @@ -1828,7 +1828,7 @@ class NamespacedPersonalAccessToken extends PersonalAccessToken { protected ?string $table = 'namespaced_personal_access_tokens'; - protected string $primaryKey = 'token_id'; + protected ?string $primaryKey = 'token_id'; /** * Get cache key for token and tokenable. diff --git a/tests/Validation/ValidationNumericRuleTest.php b/tests/Validation/ValidationNumericRuleTest.php index b8dd2b18f1..836de19f85 100644 --- a/tests/Validation/ValidationNumericRuleTest.php +++ b/tests/Validation/ValidationNumericRuleTest.php @@ -23,6 +23,15 @@ public function testDefaultNumericRule(): void $this->assertSame('numeric', (string) $rule); } + public function testStrictRule(): void + { + $rule = Rule::numeric(strict: true); + $this->assertSame('numeric:strict', (string) $rule); + + $rule = new Numeric(strict: true); + $this->assertSame('numeric:strict', (string) $rule); + } + public function testBetweenRule(): void { $rule = Rule::numeric()->between(1, 10); diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 9f102c19cc..e1116403fe 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -275,7 +275,7 @@ class EloquentModelStub extends Model { protected ?string $table = 'table'; - protected string $primaryKey = 'id_column'; + protected ?string $primaryKey = 'id_column'; protected array $guarded = []; } @@ -284,7 +284,7 @@ class PrefixedTableEloquentModelStub extends Model { protected ?string $table = 'public.table'; - protected string $primaryKey = 'id_column'; + protected ?string $primaryKey = 'id_column'; protected array $guarded = []; } diff --git a/tests/View/ViewFileViewFinderTest.php b/tests/View/ViewFileViewFinderTest.php index ed517dd1f5..e6a527208f 100755 --- a/tests/View/ViewFileViewFinderTest.php +++ b/tests/View/ViewFileViewFinderTest.php @@ -163,6 +163,17 @@ public static function pathsProvider(): array ]; } + public function testNormalizedNamespaceHints(): void + { + $finder = $this->getFinder(); + $finder->addNamespace('foo', __DIR__ . '/Fixtures/../Fixtures/nested'); + $finder->prependNamespace('foo', 'incorrect_path'); + $finder->replaceNamespace('bar', [__DIR__ . '/Fixtures/nested/../namespaced']); + + $this->assertSame(['incorrect_path', realpath(__DIR__ . '/Fixtures/nested')], $finder->getHints()['foo']); + $this->assertSame([realpath(__DIR__ . '/Fixtures/namespaced')], $finder->getHints()['bar']); + } + /** * Create a view finder with a mocked filesystem. */ diff --git a/tests/Wayfinder/BindingResolverTest.php b/tests/Wayfinder/BindingResolverTest.php index 050739bf93..8ee3f3fde2 100644 --- a/tests/Wayfinder/BindingResolverTest.php +++ b/tests/Wayfinder/BindingResolverTest.php @@ -173,7 +173,7 @@ class NonIncrementingBindingResolverModel extends Model protected ?string $table = 'external_binding_models'; - protected string $primaryKey = 'external_id'; + protected ?string $primaryKey = 'external_id'; protected string $keyType = 'string'; diff --git a/tests/Workbench/Fixtures/Member.php b/tests/Workbench/Fixtures/Member.php index 5f72b35dc2..edefd59db4 100644 --- a/tests/Workbench/Fixtures/Member.php +++ b/tests/Workbench/Fixtures/Member.php @@ -13,7 +13,7 @@ class Member extends Authenticatable protected ?string $table = 'workbench_members'; - protected string $primaryKey = 'member_id'; + protected ?string $primaryKey = 'member_id'; protected string $rememberTokenName = 'member_remember_token'; diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 87f9a73bc7..52bb5d50c4 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -1257,7 +1257,6 @@ public function getWithoutZebras(): Collection assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', Animal, Hypervel\\Support\\Collection>", $coll->takeWhile); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', Animal, Hypervel\\Support\\Collection>", $coll->unique); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', Animal, Hypervel\\Support\\Collection>", $coll->unless); -assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', Animal, Hypervel\\Support\\Collection>", $coll->until); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', Animal, Hypervel\\Support\\Collection>", $coll->when); enum Digit diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php index e62a6557b9..5e360bf865 100644 --- a/types/Collections/LazyCollection.php +++ b/types/Collections/LazyCollection.php @@ -1045,5 +1045,4 @@ public function getWithoutZebras(): LazyCollection assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->takeWhile); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unique); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unless); -assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->until); assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->when); diff --git a/types/Database/Eloquent/CustomBuilderForwarding.php b/types/Database/Eloquent/CustomBuilderForwarding.php index 871e209749..a392fd2fef 100644 --- a/types/Database/Eloquent/CustomBuilderForwarding.php +++ b/types/Database/Eloquent/CustomBuilderForwarding.php @@ -42,7 +42,7 @@ function test(User $user, ScopedUser $scoped, FixedUser $fixed): void assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->tenant('one')->published()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->ignoredAnswer()); assertType('Hypervel\Support\Collection', $user->children()->tenant('one')->models()); - assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->clone()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->children()->clone()); assertType('Hypervel\Types\CustomBuilderForwarding\UserBuilder', $user->children()->applyScopes()); assertType('int', $user->children()->rawAnswer()); assertType('Hypervel\Types\CustomBuilderForwarding\UserQuery', $user->children()->dump()); diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 0ce3e917ed..f4dad7dc81 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -41,7 +41,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Types\Relations\Post|null', $user->posts()->useWritePdo()->first()); assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dump()); assertType("Hypervel\\Database\\Query\\Builder", $user->posts()->dumpRawSql()); - assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->clone()); + assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->clone()); assertType('Hypervel\Database\Eloquent\Builder', $user->posts()->applyScopes()); assertType('Hypervel\Database\Eloquent\Relations\HasMany', $user->posts()->whereIn('id', [1])); assertType('Hypervel\Database\Eloquent\Collection', $user->posts()->whereIn('id', [1])->get());