diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index b2c13ce4d4..13d7aa9411 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -23,8 +23,6 @@ jobs: env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "mysqladmin ping -h localhost" --health-interval 10s @@ -79,8 +77,6 @@ jobs: env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "mysqladmin ping -h localhost" --health-interval 10s @@ -135,8 +131,6 @@ jobs: env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s @@ -191,8 +185,6 @@ jobs: env: MARIADB_ROOT_PASSWORD: password MARIADB_DATABASE: testing - ports: - - 3306:3306 options: >- --health-cmd "healthcheck.sh --connect --innodb_initialized" --health-interval 10s @@ -247,8 +239,6 @@ jobs: env: POSTGRES_PASSWORD: password POSTGRES_DB: testing - ports: - - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s @@ -303,8 +293,6 @@ jobs: env: POSTGRES_PASSWORD: password POSTGRES_DB: testing - ports: - - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 10s diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 3f0f19060f..e43058063f 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -20,8 +20,6 @@ jobs: services: redis: image: redis:8 - ports: - - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s @@ -253,8 +251,6 @@ jobs: services: valkey: image: valkey/valkey:9 - ports: - - 6379:6379 options: >- --health-cmd "valkey-cli ping" --health-interval 10s diff --git a/.github/workflows/reverb.yml b/.github/workflows/reverb.yml index d551e7a336..ba8bb53440 100644 --- a/.github/workflows/reverb.yml +++ b/.github/workflows/reverb.yml @@ -26,8 +26,6 @@ jobs: services: redis: image: redis:8 - ports: - - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml index 392ed9debf..484cffb09f 100644 --- a/.github/workflows/scout.yml +++ b/.github/workflows/scout.yml @@ -23,8 +23,6 @@ jobs: env: MEILI_MASTER_KEY: secret MEILI_NO_ANALYTICS: true - ports: - - 7700:7700 options: >- --health-cmd "curl -f http://localhost:7700/health" --health-interval 10s @@ -78,8 +76,6 @@ jobs: env: TYPESENSE_API_KEY: secret TYPESENSE_DATA_DIR: /tmp - ports: - - 8108:8108 container: image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2 diff --git a/src/auth/src/AuthServiceProvider.php b/src/auth/src/AuthServiceProvider.php index 0f7bc5e6fb..586d252741 100755 --- a/src/auth/src/AuthServiceProvider.php +++ b/src/auth/src/AuthServiceProvider.php @@ -11,6 +11,7 @@ use Hypervel\Contracts\Auth\Access\Gate as GateContract; use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract; use Hypervel\Contracts\Config\Repository as ConfigRepository; +use Hypervel\Contracts\Database\Query\Expression as ExpressionContract; use Hypervel\Core\Events\AfterWorkerStart; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Collection as EloquentCollection; @@ -135,9 +136,19 @@ private function registerQueryBuilderMacros(GateContract $gate): void $casts = []; foreach ($resolvedAbilities as [$ability, $alias]) { - $this->addSelect([ - $alias => $queryGate->select($ability, $this), - ]); + $selection = $queryGate->select($ability, $this); + + if ($selection instanceof ExpressionContract) { + // Raw authorization selections must retain the model columns, like subqueries do. + if ($this->getQuery()->columns === null) { + $this->select($this->getQuery()->getDefaultSelectColumn()); + } + + $this->selectExpression($selection, $alias); + } else { + $this->addSelect([$alias => $selection]); + } + $casts[$alias] = 'bool'; } diff --git a/src/bus/src/Dispatcher.php b/src/bus/src/Dispatcher.php index 64dbcef26b..add4b27666 100644 --- a/src/bus/src/Dispatcher.php +++ b/src/bus/src/Dispatcher.php @@ -263,7 +263,7 @@ protected function pushCommandToQueue(Queue $queue, mixed $command): mixed public function dispatchAfterResponse(mixed $command, mixed $handler = null): void { if (! $this->allowsDispatchingAfterResponses) { - $this->dispatchSync($command); + $this->dispatchSync($command, $handler); return; } diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 50b4d5f198..b7cb3da6c4 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -119,24 +119,18 @@ public function acquire(): bool */ public function release(): bool { - if ($this->isOwnedByCurrentProcess()) { - try { - $this->connection()->table($this->table) - ->where('key', $this->name) - ->where('owner', $this->owner) - ->delete(); - + try { + return $this->connection()->table($this->table) + ->where('key', $this->name) + ->where('owner', $this->owner) + ->delete() > 0; + } catch (Throwable $e) { + if ($this->causedByConcurrencyError($e)) { return true; - } catch (Throwable $e) { - if ($this->causedByConcurrencyError($e)) { - return true; - } - - throw $e; } - } - return false; + throw $e; + } } /** diff --git a/src/console/src/QuestionHelper.php b/src/console/src/QuestionHelper.php index 0356ae938a..70158ec337 100644 --- a/src/console/src/QuestionHelper.php +++ b/src/console/src/QuestionHelper.php @@ -69,7 +69,7 @@ protected function writePrompt(OutputInterface $output, Question $question): voi */ protected function ensureEndsWithPunctuation(string $string): string { - if (! (new Stringable($string))->endsWith(['?', ':', '!', '.'])) { + if ((new Stringable($string))->doesntEndWith(['?', ':', '!', '.'])) { return "{$string}:"; } diff --git a/src/console/src/View/Components/Mutators/EnsurePunctuation.php b/src/console/src/View/Components/Mutators/EnsurePunctuation.php index 14820cd225..d8c16f1911 100644 --- a/src/console/src/View/Components/Mutators/EnsurePunctuation.php +++ b/src/console/src/View/Components/Mutators/EnsurePunctuation.php @@ -13,7 +13,7 @@ class EnsurePunctuation */ public function __invoke(string $string): string { - if (! (new Stringable($string))->endsWith(['.', '?', '!', ':'])) { + if ((new Stringable($string))->doesntEndWith(['.', '?', '!', ':'])) { return "{$string}."; } diff --git a/src/contracts/src/Database/ModelIdentifier.php b/src/contracts/src/Database/ModelIdentifier.php index e07b4ead0c..3cfcad86c5 100644 --- a/src/contracts/src/Database/ModelIdentifier.php +++ b/src/contracts/src/Database/ModelIdentifier.php @@ -4,18 +4,16 @@ namespace Hypervel\Contracts\Database; +use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Relations\Relation; /** - * NOTE: Do not use constructor property promotion here. + * Do not use constructor property promotion here. * * The order these properties are declared in is part of the serialized output, * and Laravel expects that exact order. If this class is switched to constructor * property promotion, PHP will change the property declaration order and the * serialized string will no longer match Laravel. - * - * Keep these properties explicitly declared in this exact order: - * class, id, relations, connection, collectionClass. */ class ModelIdentifier { @@ -25,9 +23,9 @@ class ModelIdentifier protected static bool $useMorphMap = false; /** - * The class name of the model, or its morph-map alias when enabled. + * The class name of the model, or its string or integer morph-map alias when enabled. */ - public ?string $class; + public int|string|null $class; /** * The unique identifier of the model. @@ -51,7 +49,7 @@ class ModelIdentifier /** * The class name of the model collection. * - * @var null|class-string<\Hypervel\Database\Eloquent\Collection> + * @var null|class-string */ public ?string $collectionClass = null; @@ -66,7 +64,7 @@ class ModelIdentifier public function __construct(?string $class, mixed $id, array $relations, ?string $connection = null) { if ($class !== null && static::$useMorphMap) { - $class = (string) Relation::getMorphAlias($class); + $class = Relation::getMorphAlias($class); } $this->class = $class; @@ -78,7 +76,7 @@ public function __construct(?string $class, mixed $id, array $relations, ?string /** * Specify the collection class that should be used when serializing / restoring collections. * - * @param null|class-string $collectionClass + * @param null|class-string $collectionClass */ public function useCollectionClass(?string $collectionClass): static { @@ -92,11 +90,14 @@ public function useCollectionClass(?string $collectionClass): static */ public function getClass(): ?string { - if ($this->class === null) { - return null; + $class = $this->class; + + if (static::$useMorphMap && $class !== null) { + $class = Relation::getMorphedModel($class) ?? $class; } - return Relation::getMorphedModel($this->class) ?? $this->class; + // Unmapped integer aliases still follow the nullable-string getter contract. + return $class === null ? null : (string) $class; } /** diff --git a/src/database/src/Eloquent/Casts/AsVector.php b/src/database/src/Eloquent/Casts/AsVector.php new file mode 100644 index 0000000000..6e4a53b12a --- /dev/null +++ b/src/database/src/Eloquent/Casts/AsVector.php @@ -0,0 +1,116 @@ +, array|Arrayable> + */ + public static function castUsing(array $arguments): CastsAttributes + { + return new class implements CastsAttributes, ComparesCastableAttributes { + // Eloquent otherwise caches an assigned Arrayable and returns it instead of a float array. + public bool $withoutObjectCaching = true; + + /** + * Transform the attribute from the underlying model values. + * + * @return null|array + * + * @throws JsonException + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?array + { + if ($value === null) { + return null; + } + + $grammar = $model->getConnection()->getQueryGrammar(); + + // Decode a MariaDB expression assigned to the model before it is persisted... + if ($value instanceof ExpressionContract) { + $value = Str::between($value->getValue($grammar), "('", "')"); + + return array_map(floatval(...), json_decode($value, true, flags: JSON_THROW_ON_ERROR)); + } + + // MariaDB vector columns return little-endian float32 bytes... + if ($grammar instanceof MariaDbGrammar) { + return array_values(unpack('g*', $value)); + } + + // PostgreSQL (pgvector) returns JSON text... + return array_map(floatval(...), json_decode($value, true, flags: JSON_THROW_ON_ERROR)); + } + + /** + * Transform the attribute to its underlying model values. + * + * @return array + * + * @throws InvalidArgumentException + * @throws JsonException + */ + public function set(Model $model, string $key, mixed $value, array $attributes): array + { + if ($value === null) { + return [$key => null]; + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + if (! is_array($value)) { + throw new InvalidArgumentException( + sprintf('The [%s] attribute must be an array of floats or an Arrayable instance.', $key) + ); + } + + $vector = json_encode(array_values(array_map(floatval(...), $value)), JSON_THROW_ON_ERROR); + + // MariaDB requires vectors to be converted from JSON text server-side... + return [ + $key => $model->getConnection()->getQueryGrammar() instanceof MariaDbGrammar + ? new Expression("vec_fromtext('{$vector}')") + : $vector, + ]; + } + + /** + * Determine if the given values are equal. + * + * @throws JsonException + */ + public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + $first = $this->get($model, $key, $firstValue, []); + $second = $this->get($model, $key, $secondValue, []); + + if ($first === null || $second === null) { + return $first === $second; + } + + // Both supported engines store 32-bit floats, so compare the values as they are stored. + return pack('g*', ...$first) === pack('g*', ...$second); + } + }; + } +} diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 5bb3ca9eda..0752d96e2b 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -802,7 +802,8 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum $relations = is_array($relations) ? $relations : [$relations]; - foreach ($this->parseWithRelations($relations) as $name => $constraints) { + // Aggregate aliases are not relationship paths and need no eager-load parent expansion. + foreach ($this->prepareNestedWithRelationships($relations) as $name => $constraints) { // First we will determine if the name has been aliased using an "as" clause on the name // and if it has we will extract the actual relationship name and the desired name of // the resulting column. This allows multiple aggregates on the same relationships. @@ -878,7 +879,7 @@ public function withAggregate(mixed $relations, ExpressionContract|string $colum if ($function === 'exists') { $this->selectRaw( - sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)), + sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrapIdentifier($alias)), $query->getBindings() )->withCasts([$alias => 'bool']); // @phpstan-ignore method.notFound (selectRaw returns Eloquent\Builder $this, not Query\Builder) } else { diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 10c383b3ba..9c9f6de1cd 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -162,7 +162,7 @@ public function addEagerConstraints(array $models): void * Build model dictionary keyed by the relation's foreign key. * * @param \Hypervel\Database\Eloquent\Collection $results - * @return array> + * @return array> */ protected function buildDictionary(EloquentCollection $results): array { diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index 34224c8b75..1654d61794 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -21,7 +21,6 @@ use Hypervel\Database\ConnectionInterface; use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\Relations\Relation; -use Hypervel\Database\PostgresConnection; use Hypervel\Database\Query\Grammars\Grammar; use Hypervel\Database\Query\Processors\Processor; use Hypervel\Pagination\Cursor; @@ -30,11 +29,12 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\LazyCollection; -use Hypervel\Support\Str; use Hypervel\Support\StrCache; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\ForwardsCalls; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; +use JsonException; use LogicException; use RuntimeException; use SortDirection; @@ -269,9 +269,7 @@ public function select(mixed $columns = ['*']): static $columns = is_array($columns) ? $columns : func_get_args(); foreach ($columns as $as => $column) { - if (is_string($as) && $column instanceof ExpressionContract) { - $this->selectExpression($column, $as); - } elseif (is_string($as) && $this->isQueryable($column)) { + if (is_string($as) && $this->isQueryable($column)) { $this->selectSub($column, $as); } else { $this->columns[] = $column; @@ -293,18 +291,18 @@ public function selectSub(Closure|self|EloquentBuilder|Relation|string $query, s [$query, $bindings] = $this->createSub($query); return $this->selectRaw( - '(' . $query . ') as ' . $this->grammar->wrap($as), + '(' . $query . ') as ' . $this->grammar->wrapIdentifier($as), $bindings ); } /** - * Add an expression to the select clause. + * Add a select expression to the query. */ - public function selectExpression(ExpressionContract $expression, string $as): static + public function selectExpression(ExpressionContract|string $expression, string $as): static { return $this->selectRaw( - '(' . $expression->getValue($this->grammar) . ') as ' . $this->grammar->wrap($as) + '(' . $this->grammar->getValue($expression) . ') as ' . $this->grammar->wrapIdentifier($as) ); } @@ -432,13 +430,7 @@ public function addSelect(mixed $column): static $columns = is_array($column) ? $column : func_get_args(); foreach ($columns as $as => $column) { - if (is_string($as) && $column instanceof ExpressionContract) { - if (is_null($this->columns)) { - $this->select($this->getDefaultSelectColumn()); - } - - $this->selectExpression($column, $as); - } elseif (is_string($as) && $this->isQueryable($column)) { + if (is_string($as) && $this->isQueryable($column)) { if (is_null($this->columns)) { $this->select($this->getDefaultSelectColumn()); } @@ -469,14 +461,16 @@ public function getDefaultSelectColumn(): string /** * Add a vector-similarity selection to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function selectVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, ?string $as = null): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->addBinding( @@ -489,10 +483,13 @@ public function selectVectorDistance(ExpressionContract|string $column, Collecti 'select', ); - $as = $this->getGrammar()->wrap($as ?? $column . '_distance'); + // An alias is a single identifier, even when derived from a qualified column or expression. + $as = $this->getGrammar()->wrapIdentifier( + $as ?? last(explode('.', (string) $this->getGrammar()->getValue($column))) . '_distance' + ); return $this->addSelect( - new Expression("({$this->getGrammar()->wrap($column)} <=> ?) as {$as}") + new Expression("{$this->getGrammar()->compileVectorDistanceExpression($column)} as {$as}") ); } @@ -1043,13 +1040,17 @@ public function orWhereColumn(ExpressionContract|string|array $first, Expression /** * Add a vector similarity clause to the query, filtering by minimum similarity and ordering by similarity. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + * + * @throws JsonException */ public function whereVectorSimilarTo(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $minSimilarity = 0.6, bool $order = true): static { + $this->ensureConnectionSupportsVectors(); + if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); @@ -1064,18 +1065,20 @@ public function whereVectorSimilarTo(ExpressionContract|string $column, Collecti /** * Add a vector distance "where" clause to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function whereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance, string $boolean = 'and'): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } return $this->whereRaw( - "({$this->getGrammar()->wrap($column)} <=> ?) <= ?", + "{$this->getGrammar()->compileVectorDistanceExpression($column)} <= ?", [ json_encode( $vector instanceof Arrayable @@ -1092,7 +1095,9 @@ public function whereVectorDistanceLessThan(ExpressionContract|string $column, C /** * Add a vector distance "or where" clause to the query. * - * @param array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Support\Collection|string $vector + * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function orWhereVectorDistanceLessThan(ExpressionContract|string $column, Collection|Arrayable|array|string $vector, float $maxDistance): static { @@ -2533,13 +2538,15 @@ public function oldest(Closure|self|EloquentBuilder|Relation|ExpressionContract| * Add a vector-distance "order by" clause to the query. * * @param array|Arrayable|Collection|string $vector + * + * @throws JsonException */ public function orderByVectorDistance(ExpressionContract|string $column, Collection|Arrayable|array|string $vector): static { $this->ensureConnectionSupportsVectors(); if (is_string($vector)) { - $vector = Str::of($vector)->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) + $vector = (new Stringable($vector))->toEmbeddings(cache: true); // @phpstan-ignore method.notFound (optional AI SDK macro, matching Laravel) } $this->addBinding( @@ -2553,7 +2560,7 @@ public function orderByVectorDistance(ExpressionContract|string $column, Collect ); $this->{$this->unions ? 'unionOrders' : 'orders'}[] = [ - 'column' => new Expression("({$this->getGrammar()->wrap($column)} <=> ?)"), + 'column' => new Expression($this->getGrammar()->compileVectorDistanceExpression($column)), 'direction' => 'asc', ]; @@ -4197,8 +4204,8 @@ public function getConnection(): ConnectionInterface */ protected function ensureConnectionSupportsVectors(): void { - if (! $this->connection instanceof PostgresConnection) { - throw new RuntimeException('Vector distance queries are only supported by Postgres.'); + if (! $this->getGrammar()->supportsVectorDistance()) { + throw new RuntimeException('Vector distance queries are only supported by Postgres and MariaDB.'); } } diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index 94776b4e14..74fafcb89d 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -697,6 +697,24 @@ public function whereExpression(Builder $query, array $where): string return $where['column']->getValue($this); } + /** + * Compile a vector distance expression for the given column. + * + * @throws RuntimeException + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + throw new RuntimeException('This database engine does not support vector distance queries.'); + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return false; + } + /** * Compile the "group by" portions of the query. */ diff --git a/src/database/src/Query/Grammars/MariaDbGrammar.php b/src/database/src/Query/Grammars/MariaDbGrammar.php index 193ad00f65..cc9bc4b98d 100755 --- a/src/database/src/Query/Grammars/MariaDbGrammar.php +++ b/src/database/src/Query/Grammars/MariaDbGrammar.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Query\Grammars; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\JoinLateralClause; use Override; @@ -46,6 +47,22 @@ public function compileThreadCount(): string return 'select variable_value as `Value` from information_schema.global_status where variable_name = \'THREADS_CONNECTED\''; } + /** + * Compile a vector distance expression for the given column. + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + return "vec_distance_cosine({$this->wrap($column)}, vec_fromtext(?))"; + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return true; + } + /** * Determine whether to use a legacy group limit clause for MySQL < 8.0. */ diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index 255a2c83ae..fbda4d351f 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -4,6 +4,7 @@ namespace Hypervel\Database\Query\Grammars; +use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\JoinLateralClause; use Hypervel\Support\Arr; @@ -197,6 +198,22 @@ protected function validFullTextLanguages(): array ]; } + /** + * Compile a vector distance expression for the given column. + */ + public function compileVectorDistanceExpression(Expression|string $column): string + { + return "({$this->wrap($column)} <=> ?)"; + } + + /** + * Determine if the grammar supports vector distance queries. + */ + public function supportsVectorDistance(): bool + { + return true; + } + /** * Compile the "select *" portion of the query. */ diff --git a/src/database/src/Schema/ColumnDefinition.php b/src/database/src/Schema/ColumnDefinition.php index a62b767957..3a7d66eaa8 100644 --- a/src/database/src/Schema/ColumnDefinition.php +++ b/src/database/src/Schema/ColumnDefinition.php @@ -17,7 +17,7 @@ * @method $this comment(string $comment) Add a comment to the column (MySQL/PostgreSQL) * @method $this default(mixed $value) Specify a "default" value for the column * @method $this first() Place the column "first" in the table (MySQL) - * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL / PostgreSQL) + * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) * @method $this fulltext(bool|string $indexName = null) Add a fulltext index * @method $this generatedAs(string|\Hypervel\Contracts\Database\Query\Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) * @method $this instant() Specify that algorithm=instant should be used for the column operation (MySQL) diff --git a/src/docs/authorization.md b/src/docs/authorization.md index f368fe5596..6235621270 100644 --- a/src/docs/authorization.md +++ b/src/docs/authorization.md @@ -765,6 +765,8 @@ $selection = Gate::select('edit', $query); Both methods use the same symmetric fallback behavior as the fluent builder methods. +`Gate::select` returns either a database expression or a query builder. For an expression, select any model columns you need before calling `$query->selectExpression($selection, 'can_edit')`; for a query builder, use `$query->addSelect(['can_edit' => $selection])`. The `withCan` method handles both forms for you. + #### Query-Aware Policy Behavior diff --git a/src/docs/cache.md b/src/docs/cache.md index 0ecf65d715..bf35992467 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -367,6 +367,21 @@ Cache::put(CacheKey::Visits, 10, 600); $visits = Cache::get(CacheKey::Visits); ``` + +#### Retrieving Typed Values + +You may use the `string`, `integer`, `float`, `boolean`, and `array` methods to retrieve a cache item as a specific type: + +```php +$name = Cache::string('user:display_name', 'Guest'); +$attempts = Cache::integer('login:attempts', 0); +$rating = Cache::float('product:rating', 0.0); +$active = Cache::boolean('user:active', false); +$settings = Cache::array('user:settings', []); +``` + +Like `get`, these methods accept a default value or a closure that returns the default when the item is missing. An `InvalidArgumentException` is thrown if the retrieved value has an incompatible type, or if the item is missing and no suitable default is provided. The `integer` and `float` methods also accept numeric strings that represent valid integer or float values, respectively. + #### Determining Item Existence diff --git a/src/docs/eloquent-mutators.md b/src/docs/eloquent-mutators.md index d49afced78..74a8a6326d 100644 --- a/src/docs/eloquent-mutators.md +++ b/src/docs/eloquent-mutators.md @@ -6,6 +6,7 @@ - [Defining a Mutator](#defining-a-mutator) - [Attribute Casting](#attribute-casting) - [Array and JSON Casting](#array-and-json-casting) + - [Vector Casting](#vector-casting) - [Binary Casting](#binary-casting) - [Date Casting](#date-casting) - [Enum Casting](#enum-casting) @@ -228,6 +229,7 @@ The `casts` method should return an array where the key is the name of the attri - `AsHtmlString::class` - `AsStringable::class` - `AsUri::class` +- `AsVector::class` - `boolean` - `collection` - `date` @@ -300,7 +302,7 @@ $user->mergeCasts([ ``` > [!WARNING] -> Attributes that are `null` will not be cast. In addition, you should never define a cast (or an attribute) that has the same name as a relationship or assign a cast to the model's primary key. +> Attributes that are `null` remain `null` when using casts such as `integer`, `boolean`, or `array`. Custom cast classes handle `null` values themselves. In addition, you should never define a cast (or an attribute) that has the same name as a relationship or assign a cast to the model's primary key. #### Stringable Casting @@ -580,6 +582,29 @@ class Option implements Arrayable, JsonSerializable } ``` + +### Vector Casting + +You may use the `Hypervel\Database\Eloquent\Casts\AsVector` cast class to cast a database vector column to and from a PHP array: + +```php +use Hypervel\Database\Eloquent\Casts\AsVector; + +/** + * Get the attributes that should be cast. + * + * @return array + */ +protected function casts(): array +{ + return [ + 'embedding' => AsVector::class, + ]; +} +``` + +When setting the attribute, the cast accepts a PHP array, an `Arrayable` instance such as a Hypervel collection, or `null`. When retrieving the attribute, the cast returns an array of floats, or `null` if the stored value is `null`. + ### Binary Casting diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 24833e26df..dd3a8c36e9 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -2175,6 +2175,22 @@ $users = User::with(['posts' => function ($query) { }])->get(); ``` + +#### Preserving Related Collection Keys + +The `afterQuery` method registers a callback that receives the query results after they have been retrieved. You may return a collection from the callback to replace those results. For example, you may key the posts by their unique slugs: + +```php +use App\Models\User; +use Hypervel\Database\Eloquent\Collection; + +$users = User::with(['posts' => function ($query) { + $query->afterQuery(fn (Collection $posts) => $posts->keyBy('slug')); +}])->get(); +``` + +The associative keys are preserved in each user's related `posts` collection when the relationship is eager loaded. + #### Constraining Eager Loading of `morphTo` Relationships diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index 4ab568f769..c83e0b9f7b 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -264,6 +264,12 @@ Route::get('/users', function () { }); ``` +You may also preserve keys for an individual collection by calling the `preserveKeys` method on the collection returned by `UserResource::collection`: + +```php +return UserResource::collection(User::all()->keyBy->id)->preserveKeys(); +``` + #### Customizing the Underlying Resource Class diff --git a/src/docs/events.md b/src/docs/events.md index f70646cc79..8bb0a71068 100644 --- a/src/docs/events.md +++ b/src/docs/events.md @@ -600,7 +600,7 @@ class AcquireProductKey implements ShouldQueue, ShouldBeUnique } ``` -In the example above, the `AcquireProductKey` listener is unique. So, the listener will not be queued if another instance of the listener is already on the queue and has not finished processing. This ensures that only one product key is acquired for each license, even if the license is saved multiple times in quick succession. +In the example above, the `AcquireProductKey` listener is unique. So, the listener will not be queued if another instance of the listener is already on the queue and has not finished processing. This applies to all instances of the listener, regardless of which license triggered the event. In certain cases, you may want to define a specific "key" that makes the listener unique or you may want to specify a timeout beyond which the listener no longer stays unique. To accomplish this, you may define `uniqueId` and `uniqueFor` properties or methods on your listener class. The methods receive the event instance, allowing you to use event data to construct the return value: @@ -674,6 +674,8 @@ namespace App\Listeners; use App\Events\LicenseSaved; use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Support\Facades\Cache; class AcquireProductKey implements ShouldQueue, ShouldBeUnique diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 8991740c94..2b638ea746 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -49,9 +49,10 @@ The `get` method returns an instance of `Hypervel\Http\Client\Response`, which p ```php $response->body() : string; $response->json($key = null, $default = null, $flags = null) : mixed; -$response->object() : array|object|null; -$response->collect($key = null) : Hypervel\Support\Collection; -$response->fluent($key = null) : Hypervel\Support\Fluent; +$response->object($flags = null) : mixed; +$response->collect($key = null, $flags = null) : Hypervel\Support\Collection; +$response->fluent($key = null, $flags = null) : Hypervel\Support\Fluent; +$response->decodeUsing($callback) : Hypervel\Http\Client\Response; $response->resource() : resource; $response->status() : int; $response->successful() : bool; @@ -74,12 +75,30 @@ The `Hypervel\Http\Client\Response` object also implements the PHP `ArrayAccess` return Http::get('http://example.com/users/1')['name']; ``` -The optional third argument accepted by the `json` method is passed to `json_decode` as its decoding flags: +The `json`, `object`, `collect`, and `fluent` methods accept an optional `flags` argument, which is passed to `json_decode`: ```php $value = $response->json('value', flags: JSON_BIGINT_AS_STRING); ``` +When `flags` is omitted or `null`, these methods use `Response::$defaultJsonDecodingFlags`. You may configure this default in a service provider's `boot` method: + +```php +use Hypervel\Http\Client\Response; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + Response::$defaultJsonDecodingFlags = JSON_BIGINT_AS_STRING; +} +``` + +The default is shared by all requests handled by the worker, so configure it only during startup. Pass `flags: 0` to use no flags for an individual call. + +If you set a custom decoder using `decodeUsing`, that callback replaces JSON decoding and the flags do not apply. The callback receives the response body and a boolean indicating whether `object` was called. + In addition to the response methods listed above, the following methods may be used to determine if the response has a specific status code: ```php @@ -355,9 +374,9 @@ $response = Http::retry(3, 100)->post(/* ... */); If you would like to manually calculate the number of milliseconds to sleep between attempts, you may pass a closure as the second argument to the `retry` method: ```php -use Exception; +use Throwable; -$response = Http::retry(3, function (int $attempt, Exception $exception) { +$response = Http::retry(3, function (int $attempt, Throwable $exception) { return $attempt * 100; })->post(/* ... */); ``` @@ -368,9 +387,10 @@ For convenience, you may also provide an array as the first argument to the `ret $response = Http::retry([100, 200])->post(/* ... */); ``` -If needed, you may pass a third argument to the `retry` method. The third argument should be a callable that determines if the retries should actually be attempted. For example, you may wish to only retry the request if the initial request encounters an `ConnectionException`: +If needed, you may pass a third argument to the `retry` method. The third argument should be a callable that determines if the retries should actually be attempted. For example, you may wish to only retry the request if the initial request encounters a `ConnectionException`: ```php +use Hypervel\Http\Client\ConnectionException; use Hypervel\Http\Client\PendingRequest; use Throwable; diff --git a/src/docs/migrations.md b/src/docs/migrations.md index 1a5fa7f6ff..aa4cdcd08e 100644 --- a/src/docs/migrations.md +++ b/src/docs/migrations.md @@ -1554,7 +1554,7 @@ Hypervel's schema builder blueprint class provides methods for creating each typ -When using PostgreSQL, chaining `index` onto a `vector` column definition will create a vector index instead of a regular index. +Chaining `index` onto a `vector` column definition will create a vector index instead of a regular index. #### Online Index Creation diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 3b5e1e0670..6c89797200 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -1997,3 +1997,5 @@ public function via(object $notifiable): array return ['voice']; } ``` + +The `Notification::channel` method resolves a channel instance by name. It also accepts an enum whose value is a channel name or a custom channel class. diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 2a03232831..1751d0133c 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -22,6 +22,7 @@ - [Configuration](#configuration) - [Other API Differences](#other-api-differences) - [HTTP Client and Concurrency](#http-client-and-concurrency) + - [CSRF Protection](#csrf-protection) - [Scout](#scout) - [JSON Schema](#json-schema) - [Validation](#validation) @@ -474,6 +475,11 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver). + +### CSRF Protection + +Replace references to Laravel's deprecated `VerifyCsrfToken` and `ValidateCsrfToken` middleware with `Hypervel\Foundation\Http\Middleware\PreventRequestForgery`. If your application extends either class, extend `PreventRequestForgery` instead and declare any overridden exclusions as `protected array $except`. Replace `validateCsrfTokens()` configuration calls with `preventRequestForgery()`. See the [CSRF protection documentation](/docs/{{version}}/csrf). + ### Scout diff --git a/src/docs/queries.md b/src/docs/queries.md index ac61025c9e..2ef8b581aa 100644 --- a/src/docs/queries.md +++ b/src/docs/queries.md @@ -376,6 +376,15 @@ $users = DB::table('users') ->get(); ``` +When using MariaDB or MySQL, you may specify multiple indexes by separating their names with commas: + +```php +$users = DB::table('users') + ->useIndex('users_email_index, users_name_index') + ->where('email', 'taylor@example.com') + ->get(); +``` + SQLite supports the `forceIndex` method, which compiles to SQLite's `indexed by` clause: ```php @@ -417,6 +426,19 @@ $orders = DB::table('orders') ->get(); ``` + +#### `selectExpression` + +The `selectExpression` method adds a raw SQL expression with an alias. It accepts a string or an expression created by `DB::raw`, wraps the expression in parentheses, and quotes the alias as a single identifier: + +```php +$orders = DB::table('orders') + ->selectExpression('price * 1.0825', 'price_with_tax') + ->get(); +``` + +This method does not accept parameter bindings. Use `selectRaw` when you need bindings or want to insert the SQL expression as written. + #### `whereRaw / orWhereRaw` @@ -1302,7 +1324,7 @@ $users = DB::table('users') ### Vector Similarity Clauses > [!NOTE] -> Vector similarity clauses are currently only supported on PostgreSQL connections using the `pgvector` extension. For information on defining vector columns and indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-column-types). +> Vector similarity clauses are currently supported on PostgreSQL connections using the `pgvector` extension and MariaDB 11.7 or later. For information on defining vector columns and indexes, consult the [migration documentation](/docs/{{version}}/migrations#available-column-types). The `whereVectorSimilarTo` method filters results by cosine similarity to a given vector and orders the results by relevance. The `minSimilarity` threshold should be a value between `0.0` and `1.0`, where `1.0` is identical: diff --git a/src/docs/queues.md b/src/docs/queues.md index 3da121d80a..e40f3922be 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -325,6 +325,25 @@ In this example, note that we were able to pass an [Eloquent model](/docs/{{vers If your queued job accepts an Eloquent model in its constructor, only the identifier for the model will be serialized onto the queue. When the job is actually handled, the queue system will automatically re-retrieve the full model instance and its loaded relationships from the database. This approach to model serialization allows for much smaller job payloads to be sent to your queue driver. + +#### Serializing Models Using Morph Maps + +By default, queued models are identified by their fully qualified class names. A stable morph alias lets queued models be restored after their class is renamed or moved, provided the morph map points to the new class. If you have defined a [morph map](/docs/{{version}}/eloquent-relationships#custom-polymorphic-types), you may use its aliases instead by calling `ModelIdentifier::useMorphMap` in the `boot` method of your application's `AppServiceProvider`: + +```php +use Hypervel\Contracts\Database\ModelIdentifier; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + ModelIdentifier::useMorphMap(); +} +``` + +Applications that dispatch or process the same jobs must use the same morph map and enable this setting. Keep the aliases and setting in place while jobs using them remain queued. + #### `handle` Method Dependency Injection @@ -383,7 +402,7 @@ public function __construct( ) {} ``` -For convenience, if you wish to serialize all models without relationships, you may apply the `WithoutRelations` attribute to the entire class instead of applying the attribute to each model: +For convenience, if you wish to serialize all models without relationships, you may apply the `WithoutRelations` attribute to the entire class instead of applying the attribute to each model. The attribute may also be applied to a parent job class: ```php ### Unique Jobs @@ -499,6 +520,8 @@ Behind the scenes, when a `ShouldBeUnique` job is dispatched, Hypervel attempts ```php use Hypervel\Contracts\Cache\Repository; +use Hypervel\Contracts\Queue\ShouldBeUnique; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Support\Facades\Cache; class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique @@ -1755,6 +1778,7 @@ You may take a more granular approach by defining the maximum number of times a namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Tries; #[Tries(5)] @@ -1894,6 +1918,7 @@ You may also define the maximum number of seconds a job should be allowed to run namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Timeout; #[Timeout(120)] @@ -1918,6 +1943,7 @@ If you would like to indicate that a job should be marked as [failed](#dealing-w namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\FailOnTimeout; #[FailOnTimeout] @@ -3074,6 +3100,7 @@ If you would like to configure how many seconds Hypervel should wait before retr namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Backoff; #[Backoff(3)] @@ -3102,6 +3129,7 @@ You may easily configure "exponential" backoffs by defining an array of backoff namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\Backoff; #[Backoff([1, 5, 10])] @@ -3253,6 +3281,7 @@ For convenience, you may choose to automatically delete jobs with missing models namespace App\Jobs; +use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Queue\Attributes\DeleteWhenMissingModels; #[DeleteWhenMissingModels] @@ -3853,6 +3882,8 @@ Hypervel dispatches a `JobQueueing` event immediately before a job is sent to it The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. +When a worker releases a job back onto the queue after an exception, the `JobReleasedAfterException` event provides the `connectionName`, `job`, `backoff` delay in seconds, and the original `exception`. + Using the `looping` method on the `Queue` [facade](/docs/{{version}}/facades), you may specify callbacks that execute before the worker attempts to fetch a job from a queue. For example, you might register a closure to rollback any transactions that were left open by a previously failed job: ```php @@ -3866,6 +3897,10 @@ Queue::looping(function () { }); ``` +The `JobPopping` event is dispatched before a worker attempts to retrieve a job. Its `connectionName` and `queue` properties identify the configured connection and queue selection, which may be a comma-separated list. After a job is retrieved, the `JobPopped` event provides the `connectionName` and `job`. + +Long-running queue workers dispatch a `WorkerStarting` event when they start. Its `connectionName`, `queue`, and `workerOptions` properties describe the worker. You may register a listener using `Queue::starting` in the `boot` method of a service provider. + Hypervel also dispatches a `Hypervel\Queue\Events\WorkerIdle` event when a queue worker is unable to retrieve a job from the queue: ```php @@ -3881,4 +3916,4 @@ Event::listen(function (WorkerIdle $event) { When an interrupting signal is delivered to running jobs, Hypervel dispatches a `Hypervel\Queue\Events\JobInterrupted` event once for each job that was notified. Its `connectionName`, `job`, and `signal` properties identify the interrupted work. -Queue workers also dispatch a `WorkerStopping` event before they stop. Its `connectionName` and `queue` properties identify the worker, while `terminatesImmediately` is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. +Queue workers also dispatch a `WorkerStopping` event before they stop. You may register a listener using `Queue::stopping` in the `boot` method of a service provider. Its `connectionName` and `queue` properties identify the worker, while `terminatesImmediately` is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. diff --git a/src/docs/routing.md b/src/docs/routing.md index 623a6d56ee..ba87ef5aac 100644 --- a/src/docs/routing.md +++ b/src/docs/routing.md @@ -32,6 +32,7 @@ - [Form Method Spoofing](#form-method-spoofing) - [Accessing the Current Route](#accessing-the-current-route) - [Cross-Origin Resource Sharing (CORS)](#cors) + - [Skipping CORS Handling](#skipping-cors-handling) - [Dynamic CORS Configuration](#dynamic-cors-configuration) - [Route Caching](#route-caching) @@ -1126,6 +1127,26 @@ This command will place a `cors.php` configuration file within your application' > [!NOTE] > For more information on CORS and CORS headers, please consult the [MDN web documentation on CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#The_HTTP_response_headers). + +### Skipping CORS Handling + +You may skip CORS handling for selected requests by calling the `HandleCors` middleware's `skipWhen` method from the `boot` method of your application's `App\Providers\AppServiceProvider` class: + +```php +use Hypervel\Http\Middleware\HandleCors; +use Hypervel\Http\Request; + +/** + * Bootstrap any application services. + */ +public function boot(): void +{ + HandleCors::skipWhen(static fn (Request $request): bool => $request->is('webhooks/*')); +} +``` + +If any registered callback returns `true`, the middleware skips CORS handling for that request, including the dynamic configuration resolver described below. Keep these callbacks inexpensive, since they are checked before CORS path matching. + ### Dynamic CORS Configuration @@ -1151,7 +1172,7 @@ public function boot(): void } ``` -The closure receives the current HTTP request instance and should return the full CORS options array, including `paths`. Except for requests explicitly excluded with `skipWhen`, it runs before path matching because its returned paths determine whether CORS applies. Keep the resolver inexpensive since it runs for every request that reaches the middleware. +The closure receives the current HTTP request instance and should return the full CORS options array, including `paths`. Except for requests explicitly excluded with [`skipWhen`](#skipping-cors-handling), it runs before path matching because its returned paths determine whether CORS applies. Keep the resolver inexpensive since it runs for every request that reaches the middleware. If you only need to override a few options, you may merge your changes with the values defined in your `cors.php` configuration file: diff --git a/src/docs/search.md b/src/docs/search.md index e7df411ba3..bc174ad86a 100644 --- a/src/docs/search.md +++ b/src/docs/search.md @@ -30,7 +30,7 @@ When you need keyword relevance ranking — where the database scores and sorts #### Semantic / Vector Search -For semantic search that matches results by *meaning* rather than exact keywords, the `whereVectorSimilarTo` query builder method uses vector embeddings stored in PostgreSQL with the `pgvector` extension. For example, a search for "best wineries in Napa Valley" can surface an article titled "Top Vineyards to Visit" — even though the words don't overlap. Vector search requires PostgreSQL with the `pgvector` extension and pre-computed vector embeddings. +For semantic search that matches results by *meaning* rather than exact keywords, the `whereVectorSimilarTo` query builder method uses vector embeddings stored in PostgreSQL with the `pgvector` extension or MariaDB. For example, a search for "best wineries in Napa Valley" can surface an article titled "Top Vineyards to Visit" — even though the words don't overlap. Vector search requires PostgreSQL with the `pgvector` extension or MariaDB 11.7 or later, as well as pre-computed vector embeddings. #### Hypervel Scout Search @@ -100,7 +100,7 @@ Full-text search relies on matching keywords — the words in the query must app The basic workflow for vector search is: generate an embedding (a numeric array) for each piece of content and store it alongside your data, then at search time, generate an embedding for the user's query and find the stored embeddings that are closest to it in vector space. Hypervel does not generate embeddings for you; provide pre-computed vectors from your own embedding pipeline or provider. > [!NOTE] -> Vector search requires a PostgreSQL database with the `pgvector` extension. +> Vector search is supported by PostgreSQL with the `pgvector` extension and MariaDB 11.7 or later. ### Storing and Indexing Vectors @@ -108,7 +108,7 @@ The basic workflow for vector search is: generate an embedding (a numeric array) To store vector embeddings, define a `vector` column in your migration, specifying the number of dimensions in your vectors. You should also call `index` on the column to create an HNSW (Hierarchical Navigable Small World) index, which dramatically speeds up similarity searches on large datasets: ```php -Schema::ensureVectorExtensionExists(); +Schema::ensureVectorExtensionExists(); // PostgreSQL only. Schema::create('documents', function (Blueprint $table) { $table->id(); @@ -119,15 +119,17 @@ Schema::create('documents', function (Blueprint $table) { }); ``` -The `Schema::ensureVectorExtensionExists` method ensures the `pgvector` extension is enabled on your PostgreSQL database before creating the table. +The `Schema::ensureVectorExtensionExists` method ensures the `pgvector` extension is enabled on your PostgreSQL database before creating the table. Omit this call when using MariaDB. -On your Eloquent model, cast the vector column to an `array` so that Hypervel automatically handles the conversion between PHP arrays and the database's vector format: +On your Eloquent model, use the `AsVector` cast so that Hypervel automatically handles the conversion between PHP arrays and the database's vector format: ```php +use Hypervel\Database\Eloquent\Casts\AsVector; + protected function casts(): array { return [ - 'embedding' => 'array', + 'embedding' => AsVector::class, ]; } ``` diff --git a/src/foundation/README.md b/src/foundation/README.md index a237adc201..34f11728c4 100644 --- a/src/foundation/README.md +++ b/src/foundation/README.md @@ -14,7 +14,7 @@ Laravel's real-time facades are intentionally not supported. Define explicit fac The application locale setters do not change the `app.locale` or `app.fallback_locale` configuration values. `App::setLocale()` applies only to the current request, while `App::setFallbackLocale()` is intended for application boot and changes the fallback shared by the worker. -Laravel's deprecated `Middleware::validateCsrfTokens()` alias is intentionally not ported. Configure request-forgery protection with `preventRequestForgery()`. +Laravel's deprecated `VerifyCsrfToken` and `ValidateCsrfToken` middleware aliases and `Middleware::validateCsrfTokens()` method are intentionally not ported. Use `PreventRequestForgery` and configure request-forgery protection with `preventRequestForgery()`. The default `dev` server process runs `php artisan watch` so the Watcher package can own and restart the long-running Swoole server. Official Hypervel skeletons and starter kits include `hypervel/watcher` as a development dependency. diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 4eec398fb3..1adaf3afe9 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -140,7 +140,7 @@ protected function gatherApplicationInformation(): void 'Composer Version' => $this->composer->getVersion() ?? '-', 'Environment' => $this->hypervel->environment(), 'Debug Mode' => static::format(config()->boolean('app.debug'), console: $formatEnabledStatus), - 'URL' => Str::of(config('app.url'))->replace(['http://', 'https://'], ''), + 'URL' => (new Stringable(config('app.url')))->replace(['http://', 'https://'], ''), 'Maintenance Mode' => static::format($this->hypervel->isDownForMaintenance(), console: $formatEnabledStatus), 'Timezone' => config()->string('app.timezone'), 'Locale' => config()->string('app.locale'), diff --git a/src/foundation/src/Console/DevListCommand.php b/src/foundation/src/Console/DevListCommand.php index f3bf3ecfbd..b2bccf9497 100644 --- a/src/foundation/src/Console/DevListCommand.php +++ b/src/foundation/src/Console/DevListCommand.php @@ -8,6 +8,7 @@ use Hypervel\Foundation\DevCommand; use Hypervel\Foundation\DevCommands; use Hypervel\Prompts\Prompt; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -75,7 +76,7 @@ public function handle(): int ); $source = $availableSourceWidth >= 2 - ? str($source)->limit($availableSourceWidth - 1, '…')->toString() + ? (new Stringable($source))->limit($availableSourceWidth - 1, '…')->value() : ''; } diff --git a/src/foundation/src/Console/ModelMakeCommand.php b/src/foundation/src/Console/ModelMakeCommand.php index 3f6e1393b5..805dbc669f 100644 --- a/src/foundation/src/Console/ModelMakeCommand.php +++ b/src/foundation/src/Console/ModelMakeCommand.php @@ -8,6 +8,7 @@ use Hypervel\Console\GeneratorCommand; use Hypervel\Support\Collection; use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -236,7 +237,7 @@ protected function buildFactoryReplacements(): array $replacements = []; if ($this->option('factory') || $this->option('all')) { - $modelPath = Str::of($this->argument('name'))->studly()->replace('/', '\\')->toString(); + $modelPath = (new Stringable($this->argument('name')))->studly()->replace('/', '\\')->toString(); $factoryNamespace = '\Database\Factories\\' . $modelPath . 'Factory'; diff --git a/src/foundation/src/Console/ViewMakeCommand.php b/src/foundation/src/Console/ViewMakeCommand.php index 53fdc2b99b..22f9ced6ee 100644 --- a/src/foundation/src/Console/ViewMakeCommand.php +++ b/src/foundation/src/Console/ViewMakeCommand.php @@ -95,7 +95,7 @@ protected function resolveStubPath(string $stub): string protected function getTestPath(): string { return base_path( - Str::of($this->testClassFullyQualifiedName()) + (new Stringable($this->testClassFullyQualifiedName())) ->replace('\\', '/') ->replaceFirst('Tests/Feature', 'tests/Feature') ->append('Test.php') @@ -132,7 +132,7 @@ protected function handleTestCreation(string $path): bool */ protected function testNamespace(): string { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->beforeLast('\\') ->value(); } @@ -142,7 +142,7 @@ protected function testNamespace(): string */ protected function testClassName(): string { - return Str::of($this->testClassFullyQualifiedName()) + return (new Stringable($this->testClassFullyQualifiedName())) ->afterLast('\\') ->append('Test') ->value(); @@ -153,15 +153,15 @@ protected function testClassName(): string */ protected function testClassFullyQualifiedName(): string { - $name = Str::of(Str::lower($this->getNameInput()))->replace('.' . $this->option('extension'), ''); + $name = (new Stringable(Str::lower($this->getNameInput())))->replace('.' . $this->option('extension'), ''); - $namespacedName = Str::of( + $namespacedName = (new Stringable( (new Stringable($name)) ->replace('/', ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) ->implode('\\') - ) + )) ->replace(['-', '_'], ' ') ->explode(' ') ->map(fn ($part) => (new Stringable($part))->ucfirst()) @@ -187,7 +187,7 @@ protected function getTestStub(): string */ protected function testViewName(): string { - return Str::of($this->getNameInput()) + return (new Stringable($this->getNameInput())) ->replace('/', '.') ->lower() ->value(); diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index af0a06a4ba..4f00b4ac2f 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -41,7 +41,7 @@ use Hypervel\Support\Facades\Auth; use Hypervel\Support\Lottery; use Hypervel\Support\Reflector; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\ReflectsClosures; use Hypervel\Support\ViewErrorBag; use Hypervel\Validation\ValidationException; @@ -1160,7 +1160,7 @@ public function renderForConsole(OutputInterface $output, Throwable $e): void } if ($e instanceof CommandNotFoundException) { - $message = Str::of($e->getMessage())->explode('.')->first(); + $message = (new Stringable($e->getMessage()))->explode('.')->first(); if (! empty($alternatives = $e->getAlternatives())) { $message .= '. Did you mean one of these?'; diff --git a/src/foundation/src/Http/Middleware/PreventRequestForgery.php b/src/foundation/src/Http/Middleware/PreventRequestForgery.php index 978df5a3ea..0d7094da67 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestForgery.php +++ b/src/foundation/src/Http/Middleware/PreventRequestForgery.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Response; +// REMOVED: Deprecated VerifyCsrfToken and ValidateCsrfToken aliases; use this middleware directly. class PreventRequestForgery { use ExcludesPaths; @@ -66,8 +67,8 @@ public function __construct( /** * Handle an incoming request. * - * @throws \Hypervel\Session\TokenMismatchException - * @throws \Hypervel\Http\Exceptions\OriginMismatchException + * @throws TokenMismatchException + * @throws OriginMismatchException */ public function handle(Request $request, Closure $next): Response { @@ -93,7 +94,7 @@ public function handle(Request $request, Closure $next): Response */ protected function isReading(Request $request): bool { - return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS']); + return in_array($request->method(), ['HEAD', 'GET', 'OPTIONS'], true); } /** @@ -107,7 +108,7 @@ protected function runningUnitTests(): bool /** * Determine if the request has a valid origin based on the Sec-Fetch-Site header. * - * @throws \Hypervel\Http\Exceptions\OriginMismatchException + * @throws OriginMismatchException */ protected function hasValidOrigin(Request $request): bool { @@ -142,8 +143,10 @@ protected function tokensMatch(Request $request): bool /** * Get the CSRF token from the request. + * + * Preserve raw input so tokensMatch can reject non-string tokens. */ - protected function getTokenFromRequest(Request $request): ?string + protected function getTokenFromRequest(Request $request): mixed { $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN'); diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index 24fb01a789..edc4ce1f72 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -256,6 +256,8 @@ protected function registerDeferHandler(): void $this->app->scoped(DeferredCallbackCollection::class); $events = $this->app->make('events'); + // Deferred jobs use coroutine-exit callbacks, not this collection, so their + // JobAttempted events can drain it without recursively running the same job. $events->listen(function (JobAttempted $event) { if ($event->connectionName === 'sync' || ! BaseContainer::getInstance()->resolvedScoped(DeferredCallbackCollection::class) diff --git a/src/foundation/src/resources/health-up.blade.php b/src/foundation/src/resources/health-up.blade.php index 10843b1128..964ffb6d54 100644 --- a/src/foundation/src/resources/health-up.blade.php +++ b/src/foundation/src/resources/health-up.blade.php @@ -14,7 +14,7 @@ diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 2381a60140..177b6bb669 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -136,6 +136,8 @@ class PendingRequest implements Transient /** * The number of milliseconds to wait between retries. + * + * @var (Closure(int, Throwable): int)|int */ protected Closure|int $retryDelay = 100; @@ -147,7 +149,7 @@ class PendingRequest implements Transient /** * The callback that will determine if the request should be retried. * - * @var null|callable + * @var null|(callable(null|Throwable, static, null|string): bool) */ protected $retryWhenCallback; @@ -580,7 +582,7 @@ public function connectTimeout(float|int $seconds): static /** * Specify the number of times the request should be attempted. * - * @param (Closure(int, mixed): int)|int $sleepMilliseconds + * @param (Closure(int, Throwable): int)|int $sleepMilliseconds * @param null|(callable(null|Throwable, static, null|string): bool) $when */ public function retry( @@ -591,8 +593,8 @@ public function retry( ): static { $this->tries = $times; $this->retryDelay = $sleepMilliseconds; - $this->retryThrow = $throw; $this->retryWhenCallback = $when; + $this->retryThrow = $throw; return $this; } @@ -1154,9 +1156,11 @@ protected function handlePromiseResponse( } try { + $exception = $response instanceof Response ? $response->toException() : $response; + $shouldRetry = $this->retryWhenCallback ? call_user_func( $this->retryWhenCallback, - $response instanceof Response ? $response->toException() : $response, + $exception, $this, $this->request?->toPsrRequest()->getMethod() ) : true; @@ -1166,9 +1170,8 @@ protected function handlePromiseResponse( return $exception; } - $exception = $response instanceof Response ? $response->toException() : $response; - - if ($attempt < $this->getMaximumAttempts() && $shouldRetry) { + // Non-error responses have no exception to retry, just as on the synchronous path. + if ($exception !== null && $attempt < $this->getMaximumAttempts() && $shouldRetry) { $options['delay'] = $this->retryDelayInMilliseconds($attempt, $exception); return $this->makePromise($method, $url, $options, $attempt + 1); @@ -1187,7 +1190,7 @@ protected function handlePromiseResponse( } if ($this->getMaximumAttempts() > 1 && $this->retryThrow) { - return $response instanceof Response ? $response->toException() : $response; + return $exception ?? $response; } return $response; diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index f074789afb..826eebc49f 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -41,6 +41,8 @@ class Response implements ArrayAccess, Stringable /** * The flags that were used when decoding the JSON response. + * + * @var int-mask */ protected int $decodingFlags = 0; @@ -91,6 +93,8 @@ public function body(): string /** * Get the JSON decoded body of the response as an array or scalar value. + * + * @param null|int-mask $flags */ public function json(?string $key = null, mixed $default = null, ?int $flags = null): mixed { @@ -123,6 +127,8 @@ public function json(?string $key = null, mixed $default = null, ?int $flags = n * * This method will return an array of objects. Scalar JSON values remain * their decoded scalar type. + * + * @param null|int-mask $flags */ public function object(?int $flags = null): mixed { @@ -152,6 +158,8 @@ public function decodeUsing(?Closure $callback): static /** * Decode the given response body. + * + * @param int-mask $flags */ protected function decode(string $body, bool $asObject = false, int $flags = 0): mixed { @@ -164,6 +172,8 @@ protected function decode(string $body, bool $asObject = false, int $flags = 0): /** * Get the JSON decoded body of the response as a collection. + * + * @param null|int-mask $flags */ public function collect(?string $key = null, ?int $flags = null): Collection { @@ -172,6 +182,8 @@ public function collect(?string $key = null, ?int $flags = null): Collection /** * Get the JSON decoded body of the response as a fluent object. + * + * @param null|int-mask $flags */ public function fluent(?string $key = null, ?int $flags = null): Fluent { diff --git a/src/mail/src/Mailables/Headers.php b/src/mail/src/Mailables/Headers.php index 6f4c524b70..a9b811919a 100644 --- a/src/mail/src/Mailables/Headers.php +++ b/src/mail/src/Mailables/Headers.php @@ -5,7 +5,7 @@ namespace Hypervel\Mail\Mailables; use Hypervel\Support\Collection; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable; use Hypervel\Support\Traits\Conditionable; class Headers @@ -62,7 +62,7 @@ public function text(array $text): static public function referencesString(): string { return (new Collection($this->references)) - ->map(fn ($messageId) => Str::of($messageId)->start('<')->finish('>')->value()) + ->map(fn ($messageId) => (new Stringable($messageId))->start('<')->finish('>')->value()) ->implode(' '); } } diff --git a/src/mail/src/Transport/LogTransport.php b/src/mail/src/Transport/LogTransport.php index accde79a06..f8b6d74f21 100644 --- a/src/mail/src/Transport/LogTransport.php +++ b/src/mail/src/Transport/LogTransport.php @@ -4,7 +4,7 @@ namespace Hypervel\Mail\Transport; -use Hypervel\Support\Str; +use Hypervel\Support\Stringable as SupportStringable; use Psr\Log\LoggerInterface; use Stringable; use Symfony\Component\Mailer\Envelope; @@ -22,9 +22,12 @@ public function __construct( ) { } + /** + * Send the given message. + */ public function send(RawMessage $message, ?Envelope $envelope = null): ?SentMessage { - $string = Str::of($message->toString()); + $string = new SupportStringable($message->toString()); if ($string->contains('Content-Type: multipart/')) { $boundary = $string diff --git a/src/queue/src/Console/BatchesTableCommand.php b/src/queue/src/Console/BatchesTableCommand.php index 5ce5bc8bc9..5a6808f88e 100644 --- a/src/queue/src/Console/BatchesTableCommand.php +++ b/src/queue/src/Console/BatchesTableCommand.php @@ -56,6 +56,7 @@ protected function migrationExists(string $table): bool foreach ([ join_paths($this->hypervel->databasePath('migrations'), '*_*_*_*_create_' . $table . '_table.php'), + // Laravel applications may keep job_batches in the bundled jobs migration. join_paths($this->hypervel->databasePath('migrations'), '0001_01_01_000002_create_jobs_table.php'), ] as $path) { if ($this->matchingMigrationFiles($path) !== []) { diff --git a/src/queue/src/Console/FailedTableCommand.php b/src/queue/src/Console/FailedTableCommand.php index 0eb4bb8771..8a033277e5 100644 --- a/src/queue/src/Console/FailedTableCommand.php +++ b/src/queue/src/Console/FailedTableCommand.php @@ -56,6 +56,7 @@ protected function migrationExists(string $table): bool foreach ([ join_paths($this->hypervel->databasePath('migrations'), '*_*_*_*_create_' . $table . '_table.php'), + // Laravel applications may keep failed_jobs in the bundled jobs migration. join_paths($this->hypervel->databasePath('migrations'), '0001_01_01_000002_create_jobs_table.php'), ] as $path) { if ($this->matchingMigrationFiles($path) !== []) { diff --git a/src/queue/src/Console/stubs/failed_jobs.stub b/src/queue/src/Console/stubs/failed_jobs.stub index f8c2b8ce30..87a9a2a77a 100644 --- a/src/queue/src/Console/stubs/failed_jobs.stub +++ b/src/queue/src/Console/stubs/failed_jobs.stub @@ -15,12 +15,16 @@ return new class extends Migration { Schema::create('{{table}}', function (Blueprint $table) { $table->id(); - $table->uuid()->unique(); - $table->text('connection'); - $table->text('queue'); - $table->jsonb('payload'); + // Payloads may supply identifiers that are not UUIDs. + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + // Failed jobs include malformed payloads; keep their original bytes. + $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } diff --git a/src/queue/src/Console/stubs/jobs.stub b/src/queue/src/Console/stubs/jobs.stub index 718b75b406..5eb65cdcd0 100644 --- a/src/queue/src/Console/stubs/jobs.stub +++ b/src/queue/src/Console/stubs/jobs.stub @@ -16,7 +16,8 @@ return new class extends Migration Schema::create('{{table}}', function (Blueprint $table) { $table->id(); $table->string('queue')->index(); - $table->jsonb('payload'); + // Preserve raw payloads without JSON validation or normalization. + $table->longText('payload'); $table->unsignedSmallInteger('attempts'); $table->unsignedInteger('reserved_at')->nullable(); $table->unsignedInteger('available_at'); diff --git a/src/queue/src/SerializesModels.php b/src/queue/src/SerializesModels.php index 8421452569..bc3478d20e 100644 --- a/src/queue/src/SerializesModels.php +++ b/src/queue/src/SerializesModels.php @@ -22,7 +22,7 @@ public function __serialize(): array [$class, $properties, $classLevelWithoutRelations] = [ get_class($this), ClassMetadataCache::properties($this), - ClassMetadataCache::hasClassAttribute($this, WithoutRelations::class), + ClassMetadataCache::hasClassAttribute($this, WithoutRelations::class, ascend: true), ]; foreach ($properties as $property) { diff --git a/src/reflection/src/ClassMetadataCache.php b/src/reflection/src/ClassMetadataCache.php index 9725f7acac..68d12f215d 100644 --- a/src/reflection/src/ClassMetadataCache.php +++ b/src/reflection/src/ClassMetadataCache.php @@ -38,7 +38,7 @@ class ClassMetadataCache protected static array $attributes = []; /** - * @var array> + * @var array>> */ protected static array $classAttributePresence = []; @@ -133,27 +133,32 @@ public static function getAttribute(object|string $target, string $attributeClas } /** - * Determine if the given class has the given concrete class attribute. + * Determine if the given class has the given attribute, optionally checking its parents. * * @param class-string|object $target * @param class-string $attributeClass * * @throws ReflectionException */ - public static function hasClassAttribute(object|string $target, string $attributeClass): bool + public static function hasClassAttribute(object|string $target, string $attributeClass, bool $ascend = false): bool { $class = static::className($target); + $cacheKey = (int) $ascend; - if (! array_key_exists($class, static::$classAttributePresence)) { - static::$classAttributePresence[$class] = []; + if (isset(static::$classAttributePresence[$class][$attributeClass][$cacheKey])) { + return static::$classAttributePresence[$class][$attributeClass][$cacheKey]; } - if (array_key_exists($attributeClass, static::$classAttributePresence[$class])) { - return static::$classAttributePresence[$class][$attributeClass]; - } + $reflection = static::reflectClass($class); + + // Presence checks do not instantiate attributes or inherit attributes from traits. + do { + if ($reflection->getAttributes($attributeClass) !== []) { + return static::$classAttributePresence[$class][$attributeClass][$cacheKey] = true; + } + } while ($ascend && ($reflection = $reflection->getParentClass()) !== false); - return static::$classAttributePresence[$class][$attributeClass] - = static::reflectClass($class)->getAttributes($attributeClass) !== []; + return static::$classAttributePresence[$class][$attributeClass][$cacheKey] = false; } /** diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 32c8244743..68625df40c 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -30,7 +30,7 @@ * @method static \Hypervel\Http\Request dump(mixed $keys = []) * @method static \Hypervel\Http\Request duplicate(array|null $query = null, array|null $request = null, array|null $attributes = null, array|null $cookies = null, array|null $files = null, array|null $server = null) * @method static void enableHttpMethodParameterOverride() - * @method static null|\BackedEnum enum(string $key, string $enumClass, null|\BackedEnum $default = null) + * @method static \BackedEnum|null enum(string $key, string $enumClass, \BackedEnum|null $default = null) * @method static \BackedEnum[] enums(string $key, string $enumClass) * @method static array except(mixed $keys) * @method static bool exists(array|string $key) diff --git a/src/support/src/Number.php b/src/support/src/Number.php index e0cec1f034..fd075e4249 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -95,7 +95,7 @@ public static function parseFloat(string $string, ?string $locale = null): float /** * Spell out the given number in the given locale. */ - public static function spell(float|int $number, ?string $locale = null, ?int $after = null, ?int $until = null): string + public static function spell(float|int $number, ?string $locale = null, ?int $after = null, ?int $until = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -115,7 +115,7 @@ public static function spell(float|int $number, ?string $locale = null, ?int $af /** * Spell out the given number in the given locale in ordinal form. */ - public static function spellOrdinal(float|int $number, ?string $locale = null): string + public static function spellOrdinal(float|int $number, ?string $locale = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -129,7 +129,7 @@ public static function spellOrdinal(float|int $number, ?string $locale = null): /** * Convert the given number to ordinal form. */ - public static function ordinal(float|int $number, ?string $locale = null): string + public static function ordinal(float|int $number, ?string $locale = null): false|string { static::ensureIntlExtensionIsInstalled(); @@ -195,7 +195,7 @@ public static function fileSize(float|int $bytes, int $precision = 0, ?int $maxP /** * Convert the number to its human-readable equivalent. */ - public static function abbreviate(float|int $number, int $precision = 0, ?int $maxPrecision = null): bool|string + public static function abbreviate(float|int $number, int $precision = 0, ?int $maxPrecision = null): false|string { return static::forHumans($number, $precision, $maxPrecision, abbreviate: true); } @@ -223,7 +223,7 @@ public static function forHumans(float|int $number, int $precision = 0, ?int $ma /** * Convert the number to its human-readable equivalent. * - * @phpstan-return ($number is INF ? '∞' : ($number is NAN ? 'NaN' : ($number is 0 ? ($precision is non-positive-int ? '0' : non-empty-string|false) : non-empty-string|false))) + * @phpstan-return non-empty-string|false */ protected static function summarize(float|int $number, int $precision = 0, ?int $maxPrecision = null, array $units = []): false|string { @@ -243,16 +243,22 @@ protected static function summarize(float|int $number, int $precision = 0, ?int switch (true) { case (float) $number === 0.0: - return $precision > 0 ? static::format(0, $precision, $maxPrecision) : '0'; + return static::format(0, $precision, $maxPrecision); case $number < 0: - return sprintf('-%s', static::summarize(abs($number), $precision, $maxPrecision, $units)); + $summary = static::summarize(abs($number), $precision, $maxPrecision, $units); + + // Compare with zero at the same precision and locale so a magnitude + // that rounds to zero does not retain its minus sign. + return $summary === static::summarize(0, $precision, $maxPrecision, $units) + ? $summary + : sprintf('-%s', $summary); case $number >= 1e15: return sprintf('%s' . end($units), static::summarize($number / 1e15, $precision, $maxPrecision, $units)); } $numberExponent = (int) floor(log10($number)); - $displayExponent = $numberExponent - ($numberExponent % 3); - $number /= pow(10, $displayExponent); + $displayExponent = max(0, $numberExponent - ($numberExponent % 3)); + $number /= 10 ** $displayExponent; $formatted = static::format($number, $precision, $maxPrecision); @@ -275,6 +281,8 @@ public static function clamp(float|int $number, float|int $min, float|int $max): /** * Split the given number into pairs of min/max values. + * + * @return list */ public static function pairs(float|int $to, float|int $by, float|int $start = 0, float|int $offset = 1): array { @@ -313,6 +321,11 @@ public static function trim(float|int $number): float|int /** * Execute the given callback using the given locale. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withLocale(string $locale, callable $callback): mixed { @@ -333,6 +346,11 @@ public static function withLocale(string $locale, callable $callback): mixed /** * Execute the given callback using the given currency. + * + * @template TReturn + * + * @param callable(): TReturn $callback + * @return TReturn */ public static function withCurrency(string $currency, callable $callback): mixed { diff --git a/src/support/src/ServiceProvider.php b/src/support/src/ServiceProvider.php index 895abf61a9..9872490f61 100644 --- a/src/support/src/ServiceProvider.php +++ b/src/support/src/ServiceProvider.php @@ -553,12 +553,13 @@ protected function reloads(string $reload, ?string $key = null): void */ protected function getProviderKey(?string $key = null): string { - $key ??= (string) Str::of(get_class($this)) + $key ??= (new Stringable(get_class($this))) ->classBasename() ->before('ServiceProvider') ->kebab() ->lower() - ->trim(); + ->trim() + ->value(); if (empty($key)) { $key = class_basename(get_class($this)); diff --git a/src/support/src/Str.php b/src/support/src/Str.php index f907508db2..d4cf973f3c 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -90,13 +90,13 @@ public static function afterLast(string $subject, string|int|float|bool|BaseStri return $subject; } - $position = strrpos($subject, $search); + $position = mb_strrpos($subject, $search); if ($position === false) { return $subject; } - return substr($subject, $position + strlen($search)); + return mb_substr($subject, $position + mb_strlen($search)); } /** @@ -148,7 +148,8 @@ public static function beforeLast(string $subject, string|int|float|bool|BaseStr return $subject; } - return static::substr($subject, 0, $pos); + // Character offsets use the internal encoding, which can differ from Str::substr's UTF-8 default. + return mb_substr($subject, 0, $pos); } /** @@ -230,7 +231,12 @@ public static function chopEnd(string $subject, string|array $needle): string { foreach ((array) $needle as $n) { if ($n !== '' && str_ends_with($subject, $n)) { - return mb_substr($subject, 0, -mb_strlen($n)); + $length = mb_strlen($n); + + // A byte suffix match can start inside a multibyte character. + if (mb_substr($subject, -$length) === $n) { + return mb_substr($subject, 0, -$length); + } } } @@ -397,14 +403,14 @@ public static function excerpt(string|int|float|bool|BaseStringable|null $text, $start = ltrim($matches[1]); - $start = Str::of(mb_substr($start, max(mb_strlen($start, 'UTF-8') - $radius, 0), $radius, 'UTF-8'))->ltrim()->unless( + $start = (new Stringable(mb_substr($start, max(mb_strlen($start, 'UTF-8') - $radius, 0), $radius, 'UTF-8')))->ltrim()->unless( fn ($startWithRadius) => $startWithRadius->exactly($start), fn ($startWithRadius) => $startWithRadius->prepend($omission), ); $end = rtrim($matches[3]); - $end = Str::of(mb_substr($end, 0, $radius, 'UTF-8'))->rtrim()->unless( + $end = (new Stringable(mb_substr($end, 0, $radius, 'UTF-8')))->rtrim()->unless( fn ($endWithRadius) => $endWithRadius->exactly($end), fn ($endWithRadius) => $endWithRadius->append($omission), ); @@ -440,11 +446,16 @@ public static function wrap(string $value, string $before, ?string $after = null public static function unwrap(string $value, string $before, ?string $after = null): string { if (static::startsWith($value, $before)) { - $value = static::substr($value, static::length($before)); + $value = mb_substr($value, mb_strlen($before)); } if (static::endsWith($value, $after ??= $before)) { - $value = static::substr($value, 0, -static::length($after)); + $length = mb_strlen($after); + + // Unlike a prefix, a byte suffix match can start inside a character. + if (mb_substr($value, -$length) === $after) { + $value = mb_substr($value, 0, -$length); + } } return $value; @@ -724,7 +735,7 @@ public static function inlineMarkdown(string $string, array $options = [], array } /** - * Masks a portion of a string with a repeated character. + * Mask a portion of a string with a repeated character. */ public static function mask(string $string, string|BaseStringable $character, int $index, ?int $length = null, string $encoding = 'UTF-8'): string { @@ -749,7 +760,7 @@ public static function mask(string $string, string|BaseStringable $character, in $start = mb_substr($string, 0, $startIndex, $encoding); $segmentLen = mb_strlen($segment, $encoding); - $end = mb_substr($string, $startIndex + $segmentLen); + $end = mb_substr($string, $startIndex + $segmentLen, null, $encoding); return $start . str_repeat(mb_substr($character, 0, 1, $encoding), $segmentLen) . $end; } diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 16bf85150f..4efc0241b4 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -4,6 +4,7 @@ namespace Hypervel\Support\Traits; +use BackedEnum; use Carbon\CarbonInterface; use Carbon\CarbonInterval; use Carbon\Unit; @@ -11,7 +12,6 @@ use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\Number; -use Hypervel\Support\Str; use Hypervel\Support\Stringable; use stdClass; use Stringable as BaseStringable; @@ -169,7 +169,7 @@ public function whenFilled(string $key, callable $callback, ?callable $default = /** * Apply the callback if the instance contains a valid enum value for the given key. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum * @template TReturn * @template TReturnDefault = never * @@ -249,7 +249,7 @@ public function str(string $key, mixed $default = null): Stringable */ public function string(string $key, mixed $default = null): Stringable { - return Str::of($this->data($key, $default)); + return new Stringable($this->data($key, $default)); } /** @@ -336,11 +336,12 @@ public function interval(string $key, Unit|string|null $unit = null): ?CarbonInt /** * Retrieve data from the instance as an enum. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum + * @template TDefault of TEnum|null * * @param class-string $enumClass - * @param null|TEnum $default - * @return null|TEnum + * @param TDefault $default + * @return TDefault|TEnum */ public function enum(string $key, string $enumClass, mixed $default = null): mixed { @@ -354,7 +355,7 @@ public function enum(string $key, string $enumClass, mixed $default = null): mix /** * Retrieve data from the instance as an array of enums. * - * @template TEnum of \BackedEnum + * @template TEnum of BackedEnum * * @param class-string $enumClass * @return TEnum[] diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php index 1c0ec4d93f..0906e3a46d 100644 --- a/src/support/src/Uri.php +++ b/src/support/src/Uri.php @@ -342,7 +342,7 @@ public function redirect(int $status = 302, array $headers = []): RedirectRespon */ public function toStringable(): Stringable { - return Str::of($this->value()); + return new Stringable($this->value()); } /** diff --git a/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php b/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php index 2051f9d4cf..ce5b3d5d1b 100644 --- a/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php +++ b/src/testbench/hypervel/migrations/0001_01_01_000007_testbench_create_failed_jobs_table.php @@ -15,11 +15,13 @@ public function up(): void Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); $table->string('uuid')->unique(); - $table->text('connection'); - $table->text('queue'); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } diff --git a/tests/Auth/AuthEloquentBuilderCanTest.php b/tests/Auth/AuthEloquentBuilderCanTest.php index 628860c0da..e3d9245a30 100644 --- a/tests/Auth/AuthEloquentBuilderCanTest.php +++ b/tests/Auth/AuthEloquentBuilderCanTest.php @@ -149,6 +149,24 @@ public function testWithCanAddsOneOrMultipleStrictBooleanAttributesAndKeepsModel } } + public function testWithCanPreservesExplicitColumnsAndSelectionBindings(): void + { + $this->gate()->before(fn (): bool => true); + + $post = Post::query() + ->select('id') + ->selectRaw('? as title', ['Selected title']) + ->withCan('edit', $this->user(1)) + ->orderBy('id') + ->firstOrFail(); + + $this->assertSame([ + 'id' => 1, + 'title' => 'Selected title', + 'can_edit' => true, + ], $post->toArray()); + } + public function testWithCanGeneratesDashedCamelExplicitAndDottedAliases(): void { $user = $this->user(1); diff --git a/tests/Bus/BusDispatcherTest.php b/tests/Bus/BusDispatcherTest.php index 89a1ecaa6a..a60605300d 100644 --- a/tests/Bus/BusDispatcherTest.php +++ b/tests/Bus/BusDispatcherTest.php @@ -207,6 +207,22 @@ public function testDispatcherCanDispatchStandAloneHandler() $this->assertInstanceOf(StandAloneCommand::class, $response); } + public function testDisabledDispatchAfterResponseUsesExplicitHandler(): void + { + $dispatcher = new Dispatcher(new Container); + $dispatcher->withoutDispatchingAfterResponses(); + + $command = new BusDispatcherImmediateCommand; + $handledCommand = null; + + $dispatcher->dispatchAfterResponse($command, static function (BusDispatcherImmediateCommand $receivedCommand) use (&$handledCommand): void { + $handledCommand = $receivedCommand; + }); + + $this->assertSame($command, $handledCommand); + $this->assertFalse($command->handled); + } + public function testOnConnectionOnJobWhenDispatching() { Container::setInstance($container = new Container); diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index b93ae4f62d..50932d4303 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -138,37 +138,20 @@ public function testLockCanBeReleased(): void [$lock, $table] = $this->getLock(); $owner = $lock->owner(); - // Check ownership - $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn((object) ['owner' => $owner]); - - // Delete $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); - $table->shouldReceive('delete')->once(); + $table->shouldReceive('delete')->once()->andReturn(1); $this->assertTrue($lock->release()); } - public function testLockCannotBeReleasedIfNotOwned(): void + public function testReleaseReturnsFalseWhenNoOwnedRowMatches(): void { [$lock, $table] = $this->getLock(); $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn((object) ['owner' => 'different-owner']); - - $this->assertFalse($lock->release()); - } - - public function testLockCannotBeReleasedIfNotExists(): void - { - [$lock, $table] = $this->getLock(); - - $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); - $table->shouldReceive('where')->once()->with('expiration', '>', m::type('int'))->andReturn($table); - $table->shouldReceive('first')->once()->andReturn(null); + $table->shouldReceive('where')->once()->with('owner', $lock->owner())->andReturn($table); + $table->shouldReceive('delete')->once()->andReturn(0); $this->assertFalse($lock->release()); } diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php index c11f8e7110..7deef807e2 100644 --- a/tests/Cache/CacheRepositoryTest.php +++ b/tests/Cache/CacheRepositoryTest.php @@ -1356,7 +1356,7 @@ public function testTaggedPutManyHandlesIntegerArrayKeys() $this->assertSame('string-value', $repo->get('a')); } - public function testStringTypedGetter() + public function testStringTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); @@ -1364,34 +1364,35 @@ public function testStringTypedGetter() $this->assertSame('bar', $repo->string('foo')); } - public function testStringTypedGetterThrowsExceptionForNonString() + public function testStringTypedGetterThrowsExceptionForNonString(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a string, integer given.'); $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(1); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(123); $repo->string('foo'); } - public function testStringTypedGetterReturnsDefaultWhenKeyNotFound() + public function testStringTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('default'); + $repo->getStore()->shouldReceive('get')->twice()->with('foo')->andReturn(null); $this->assertSame('default', $repo->string('foo', 'default')); + $this->assertSame('resolved', $repo->string('foo', fn (): string => 'resolved')); } - public function testIntegerTypedGetter() + public function testIntegerTypedGetter(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(42); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(123); - $this->assertSame(42, $repo->integer('foo')); + $this->assertSame(123, $repo->integer('foo')); } - public function testIntegerTypedGetterParsesNumericString() + public function testIntegerTypedGetterParsesNumericString(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('123'); @@ -1399,7 +1400,7 @@ public function testIntegerTypedGetterParsesNumericString() $this->assertSame(123, $repo->integer('foo')); } - public function testIntegerTypedGetterThrowsExceptionForNonInteger() + public function testIntegerTypedGetterThrowsExceptionForNonInteger(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an integer, array given.'); @@ -1410,15 +1411,24 @@ public function testIntegerTypedGetterThrowsExceptionForNonInteger() $repo->integer('foo'); } - public function testIntegerTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonIntegerAsInteger(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be an integer, string given.')); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->integer('foo'); + } + + public function testIntegerTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(100); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); - $this->assertSame(100, $repo->integer('foo', 100)); + $this->assertSame(456, $repo->integer('foo', 456)); } - public function testItThrowsExceptionWhenGettingFloatStringAsInteger() + public function testItThrowsExceptionWhenGettingFloatStringAsInteger(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an integer, string given.'); @@ -1428,23 +1438,23 @@ public function testItThrowsExceptionWhenGettingFloatStringAsInteger() $repo->integer('foo'); } - public function testFloatTypedGetter() + public function testFloatTypedGetter(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(3.14); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(1.5); - $this->assertSame(3.14, $repo->float('foo')); + $this->assertSame(1.5, $repo->float('foo')); } - public function testFloatTypedGetterParsesNumericString() + public function testFloatTypedGetterParsesNumericString(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('3.14'); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('1.5'); - $this->assertSame(3.14, $repo->float('foo')); + $this->assertSame(1.5, $repo->float('foo')); } - public function testFloatTypedGetterThrowsExceptionForNonFloat() + public function testFloatTypedGetterThrowsExceptionForNonFloat(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a float, array given.'); @@ -1455,15 +1465,24 @@ public function testFloatTypedGetterThrowsExceptionForNonFloat() $repo->float('foo'); } - public function testFloatTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonFloatAsFloat(): void { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be a float, string given.')); + $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(2.5); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->float('foo'); + } + + public function testFloatTypedGetterReturnsDefaultWhenKeyNotFound(): void + { + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); $this->assertSame(2.5, $repo->float('foo', 2.5)); } - public function testBooleanTypedGetter() + public function testBooleanTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(true); @@ -1471,7 +1490,7 @@ public function testBooleanTypedGetter() $this->assertTrue($repo->boolean('foo')); } - public function testBooleanTypedGetterReturnsFalse() + public function testBooleanTypedGetterReturnsFalse(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(false); @@ -1479,7 +1498,7 @@ public function testBooleanTypedGetterReturnsFalse() $this->assertFalse($repo->boolean('foo')); } - public function testBooleanTypedGetterThrowsExceptionForNonBoolean() + public function testBooleanTypedGetterThrowsExceptionForNonBoolean(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be a boolean, string given.'); @@ -1490,15 +1509,24 @@ public function testBooleanTypedGetterThrowsExceptionForNonBoolean() $repo->boolean('foo'); } - public function testBooleanTypedGetterReturnsDefaultWhenKeyNotFound() + public function testItThrowsExceptionWhenGettingNonBooleanAsBoolean(): void + { + $this->expectExceptionObject(new InvalidArgumentException('Cache value for key [foo] must be a boolean, string given.')); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $repo->boolean('foo'); + } + + public function testBooleanTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(true); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); - $this->assertTrue($repo->boolean('foo', true)); + $this->assertFalse($repo->boolean('foo', false)); } - public function testArrayTypedGetter() + public function testArrayTypedGetter(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['bar', 'baz']); @@ -1506,7 +1534,7 @@ public function testArrayTypedGetter() $this->assertSame(['bar', 'baz'], $repo->array('foo')); } - public function testArrayTypedGetterReturnsAssociativeArray() + public function testArrayTypedGetterReturnsAssociativeArray(): void { $repo = $this->getRepository(); $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['key' => 'value']); @@ -1514,7 +1542,7 @@ public function testArrayTypedGetterReturnsAssociativeArray() $this->assertSame(['key' => 'value'], $repo->array('foo')); } - public function testArrayTypedGetterThrowsExceptionForNonArray() + public function testArrayTypedGetterThrowsExceptionForNonArray(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Cache value for key [foo] must be an array, string given.'); @@ -1525,14 +1553,40 @@ public function testArrayTypedGetterThrowsExceptionForNonArray() $repo->array('foo'); } - public function testArrayTypedGetterReturnsDefaultWhenKeyNotFound() + public function testArrayTypedGetterReturnsDefaultWhenKeyNotFound(): void { $repo = $this->getRepository(); - $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(['default']); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null); $this->assertSame(['default'], $repo->array('foo', ['default'])); } + #[DataProvider('typedGetterTypeMismatchProvider')] + public function testTypedGettersReportTypeMismatchesForEnumKeys(string $method, int|string $value, string $message): void + { + $this->expectExceptionObject(new InvalidArgumentException($message)); + + $repo = $this->getRepository(); + $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn($value); + $repo->{$method}(TestCacheKey::Foo); + } + + /** + * Provide typed getter values and their expected mismatch messages. + * + * @return array + */ + public static function typedGetterTypeMismatchProvider(): array + { + return [ + ['string', 123, 'Cache value for key [foo] must be a string, integer given.'], + ['integer', 'bar', 'Cache value for key [foo] must be an integer, string given.'], + ['float', 'bar', 'Cache value for key [foo] must be a float, string given.'], + ['boolean', 'bar', 'Cache value for key [foo] must be a boolean, string given.'], + ['array', 'bar', 'Cache value for key [foo] must be an array, string given.'], + ]; + } + public function testRememberFiresEventsWithRedisStore() { $store = m::mock(RedisStore::class); diff --git a/tests/Contracts/Database/ModelIdentifierTest.php b/tests/Contracts/Database/ModelIdentifierTest.php index 0e2964bee5..0c7cbb7121 100644 --- a/tests/Contracts/Database/ModelIdentifierTest.php +++ b/tests/Contracts/Database/ModelIdentifierTest.php @@ -7,32 +7,77 @@ use Hypervel\Contracts\Database\ModelIdentifier; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Tests\TestCase; +use stdClass; class ModelIdentifierTest extends TestCase { public function testFlushStateRestoresRawClassSerialization(): void { - try { - Relation::morphMap([ - 'model-identifier-user' => ModelIdentifierTestUser::class, - ]); - ModelIdentifier::useMorphMap(); - - $this->assertSame( - 'model-identifier-user', - (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class - ); - - ModelIdentifier::flushState(); - - $this->assertSame( - ModelIdentifierTestUser::class, - (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class - ); - } finally { - Relation::morphMap([], false); - ModelIdentifier::flushState(); - } + Relation::morphMap([ + 'model-identifier-user' => ModelIdentifierTestUser::class, + ]); + ModelIdentifier::useMorphMap(); + + $this->assertSame( + 'model-identifier-user', + (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class + ); + + ModelIdentifier::flushState(); + + $this->assertSame( + ModelIdentifierTestUser::class, + (new ModelIdentifier(ModelIdentifierTestUser::class, 1, []))->class + ); + } + + public function testClassNamesAreNotRemappedUnlessMorphMapsAreEnabled(): void + { + Relation::morphMap([ModelIdentifierTestUser::class => stdClass::class]); + + $identifier = new ModelIdentifier(ModelIdentifierTestUser::class, 1, []); + + $this->assertSame(ModelIdentifierTestUser::class, $identifier->getClass()); + + ModelIdentifier::useMorphMap(); + + $this->assertSame(stdClass::class, $identifier->getClass()); + } + + public function testIntegerMorphAliasesKeepTheirSerializedType(): void + { + Relation::morphMap([1 => ModelIdentifierTestUser::class]); + ModelIdentifier::useMorphMap(); + + $identifier = new ModelIdentifier(ModelIdentifierTestUser::class, 1, []); + $serialized = sprintf( + 'O:%d:"%s":5:{s:5:"class";i:1;s:2:"id";i:1;s:9:"relations";a:0:{}s:10:"connection";N;s:15:"collectionClass";N;}', + strlen(ModelIdentifier::class), + ModelIdentifier::class, + ); + + $this->assertSame($serialized, serialize($identifier)); + + $restored = unserialize($serialized); + + $this->assertInstanceOf(ModelIdentifier::class, $restored); + $this->assertSame(1, $restored->class); + $this->assertSame(ModelIdentifierTestUser::class, $restored->getClass()); + + Relation::morphMap([], false); + + $this->assertSame('1', $restored->getClass()); + } + + public function testNullClassIsPreserved(): void + { + $identifier = new ModelIdentifier(null, [], []); + + $this->assertNull($identifier->getClass()); + + ModelIdentifier::useMorphMap(); + + $this->assertNull($identifier->getClass()); } } diff --git a/tests/Database/DatabaseEloquentAsVectorCastTest.php b/tests/Database/DatabaseEloquentAsVectorCastTest.php new file mode 100644 index 0000000000..05f6b65a01 --- /dev/null +++ b/tests/Database/DatabaseEloquentAsVectorCastTest.php @@ -0,0 +1,216 @@ +useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => pack('g*', 0.5, -1.25, 3)]); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testGetDecodesBinaryVectorBeginningWithOpeningBracketByte(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => pack('g*', 1.0000108480453491)]); + + $this->assertSame([1.0000108480453491], $model->embedding); + } + + public function testGetDecodesTextVector(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => '[0.5,-1.25,3]']); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testGetReturnsNullForNullValue(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => null]); + + $this->assertNull($model->embedding); + } + + public function testSetOnMariaDbWrapsVectorInVecFromText(): void + { + $grammar = $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3.75]; + + $attribute = $model->getAttributes()['embedding']; + + $this->assertInstanceOf(Expression::class, $attribute); + $this->assertSame("vec_fromtext('[0.5,-1.25,3.75]')", $attribute->getValue($grammar)); + } + + public function testSetOnPostgresStoresJson(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3.75]; + + $this->assertSame('[0.5,-1.25,3.75]', $model->getAttributes()['embedding']); + } + + public function testSetAcceptsArrayable(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = new Collection([0.5, -1.25, 3.75]); + + $this->assertSame('[0.5,-1.25,3.75]', $model->getAttributes()['embedding']); + $this->assertSame([0.5, -1.25, 3.75], $model->embedding); + } + + public function testSetAcceptsArrayableOnMariaDb(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = new Collection([0.5, -1.25, 3]); + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testSetStoresNullAsNull(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = null; + + $this->assertNull($model->getAttributes()['embedding']); + } + + public function testSetRejectsNonArrayValues(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The [embedding] attribute must be an array of floats or an Arrayable instance.'); + + $model = new AsVectorTestModel; + $model->embedding = 'not a vector'; + } + + public function testVectorCanBeReadBackBeforeSavingOnMariaDb(): void + { + $this->useGrammar(MariaDbGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3]; + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + public function testVectorCanBeReadBackBeforeSavingOnPostgres(): void + { + $this->useGrammar(PostgresGrammar::class); + + $model = new AsVectorTestModel; + $model->embedding = [0.5, -1.25, 3]; + + $this->assertSame([0.5, -1.25, 3.0], $model->embedding); + } + + #[DataProvider('storedVectorProvider')] + public function testDirtyTrackingUsesStoredVectorPrecision(string $grammar, string $stored): void + { + $this->useGrammar($grammar); + + $model = new AsVectorTestModel; + $model->setRawAttributes(['embedding' => $stored], true); + + $model->embedding = $model->embedding; + $this->assertFalse($model->isDirty('embedding')); + + // Recomputing the same embedding must compare at the database's float32 precision. + $model->embedding = [0.1, 0.2, 0.30000001]; + $this->assertFalse($model->isDirty('embedding')); + + // The next representable float32 value must still count as a change. + $model->embedding = [0.1000000089407, 0.2, 0.30000001]; + $this->assertTrue($model->isDirty('embedding')); + + $model->embedding = [0.1, 0.2]; + $this->assertTrue($model->isDirty('embedding')); + + $model->embedding = null; + $this->assertTrue($model->isDirty('embedding')); + + $model->syncOriginal(); + $model->embedding = [0.1, 0.2, 0.30000001]; + $this->assertTrue($model->isDirty('embedding')); + } + + /** + * Provide the database representations of the same vector. + */ + public static function storedVectorProvider(): array + { + return [ + 'MariaDB' => [MariaDbGrammar::class, pack('g*', 0.1, 0.2, 0.30000001)], + 'PostgreSQL' => [PostgresGrammar::class, '[0.1,0.2,0.3]'], + ]; + } + + /** + * Use the given query grammar for model connections. + * + * @param class-string $grammar + */ + protected function useGrammar(string $grammar): Grammar + { + $connection = m::mock(Connection::class); + $grammar = new $grammar($connection); + $connection->shouldReceive('getQueryGrammar')->andReturn($grammar); + + $resolver = m::mock(ConnectionResolverInterface::class); + $resolver->shouldReceive('connection')->andReturn($connection); + + Model::setConnectionResolver($resolver); + + return $grammar; + } +} + +class AsVectorTestModel extends Model +{ + protected array $guarded = []; + + protected array $casts = [ + 'embedding' => AsVector::class, + ]; +} diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index ae715f1111..ab85160738 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -1762,6 +1762,18 @@ public function testWithCountAndRename() $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); } + public function testWithCountWithConstrainedDottedAlias(): void + { + $model = new ModelParentStub; + + $builder = $model->withCount(['foo as a.b' => function ($query): void { + $query->where('active', true); + }]); + + $this->assertSame('select "model_parent_stubs".*, (select count(*) from "model_close_related_stubs" where ("model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") and ("active" = ?)) as "a.b" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([true], $builder->getBindings()); + } + public function testWithCountMultipleAndPartialRename() { $model = new ModelParentStub; @@ -1909,6 +1921,18 @@ public function testWithExistsAndRename() $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "foo_bar" from "model_parent_stubs"', $builder->toSql()); } + public function testWithExistsWithLiteralAliases(): void + { + foreach (['a.b', 'data->x'] as $alias) { + $model = new ModelParentStub; + + $builder = $model->withExists('foo as ' . $alias); + + $this->assertSame('select "model_parent_stubs".*, exists(select * from "model_close_related_stubs" where "model_parent_stubs"."foo_id" = "model_close_related_stubs"."id") as "' . $alias . '" from "model_parent_stubs"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } + } + public function testWithExistsMultipleAndPartialRename() { $model = new ModelParentStub; diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 54ba5e3193..1f601307c7 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -165,29 +165,29 @@ public function testBasicSelectWithPrefix() public function testDefaultSelectionUsesTheLogicalSourceAlias(): void { $builder = $this->getBuilder(prefix: 'prefix_'); - $builder->from('users', '0')->addSelect(['bonus' => new Raw(42)]); + $builder->from('users', '0')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); - $this->assertSame('select "prefix_0".*, (42) as "bonus" from "prefix_users" as "prefix_0"', $builder->toSql()); + $this->assertSame('select "prefix_0".*, (select 42) as "bonus" from "prefix_users" as "prefix_0"', $builder->toSql()); $builder = $this->getBuilder(prefix: 'prefix_'); - $builder->from('users AS people')->addSelect(['bonus' => new Raw(42)]); + $builder->from('users AS people')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); - $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + $this->assertSame('select "prefix_people".*, (select 42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); } public function testReplacingTheSourceResetsItsDefaultSelectionAlias(): void { $builder = $this->getBuilder(prefix: 'prefix_')->fromSub('select 1 as id', 'old'); - $plain = (clone $builder)->from('users')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_users".*, (42) as "bonus" from "prefix_users"', $plain->toSql()); + $plain = (clone $builder)->from('users')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_users".*, (select 42) as "bonus" from "prefix_users"', $plain->toSql()); - $subquery = (clone $builder)->fromSub('select 2 as id', 'new')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_new".*, (42) as "bonus" from (select 2 as id) as "prefix_new"', $subquery->toSql()); + $subquery = (clone $builder)->fromSub('select 2 as id', 'new')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_new".*, (select 42) as "bonus" from (select 2 as id) as "prefix_new"', $subquery->toSql()); $this->expectException(TypeError::class); - $builder->fromRaw('users')->addSelect(['bonus' => new Raw(42)]); + $builder->fromRaw('users')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); } public function testContractExpressionsAreAcceptedAsQuerySources(): void @@ -204,8 +204,8 @@ public function getValue(BaseGrammar $grammar): string $this->assertSame('select * from "prefix_users"', $builder->toSql()); $this->assertSame($expression, $builder->from); - $builder->from($expression, 'people')->addSelect(['bonus' => new Raw(42)]); - $this->assertSame('select "prefix_people".*, (42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); + $builder->from($expression, 'people')->addSelect(['bonus' => $this->getBuilder()->selectRaw('42')]); + $this->assertSame('select "prefix_people".*, (select 42) as "bonus" from "prefix_users" as "prefix_people"', $builder->toSql()); $builder->from($expression); $this->assertSame($expression, $builder->from); @@ -6177,39 +6177,52 @@ public function testSubSelectResetBindings() $this->assertEquals([], $builder->getBindings()); } - public function testSelectExpression() + public function testSelectExpression(): void { $builder = $this->getBuilder(); - $builder->from('one')->selectExpression(new Raw('1 + 1'), 'expr'); + $builder->from('one') + ->selectExpression(new Raw('1 + 1'), 'expr') + ->selectExpression('2 + 2', 'expr2'); - $this->assertSame('select (1 + 1) as "expr" from "one"', $builder->toSql()); + $this->assertSame('select (1 + 1) as "expr", (2 + 2) as "expr2" from "one"', $builder->toSql()); } - public function testSelectWithAliasedExpression() + public function testSelectionAliasesAreSingleIdentifiers(): void { - $builder = $this->getBuilder(); - $builder->from('users')->select(['is_admin' => new Raw('role = 1')]); + foreach (['a.b', 'x as y', 'data->x'] as $alias) { + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('one')->selectSub(function ($query): void { + $query->select('value')->from('two')->where('id', 1); + }, $alias); + + $this->assertSame('select (select "value" from "prefix_two" where "id" = ?) as "' . $alias . '" from "prefix_one"', $builder->toSql()); + $this->assertSame([1], $builder->getBindings()); + + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('one')->selectExpression(new Raw('1 + 1'), $alias); - $this->assertSame('select (role = 1) as "is_admin" from "users"', $builder->toSql()); + $this->assertSame('select (1 + 1) as "' . $alias . '" from "prefix_one"', $builder->toSql()); + $this->assertSame([], $builder->getBindings()); + } } - public function testAddSelectWithAliasedExpression() + public function testSelectPreservesKeyedRawExpressions(): void { $builder = $this->getBuilder(); - $builder->from('users')->select('*')->addSelect(['is_admin' => new Raw('role = 1')]); + $builder->from('users')->select(['is_admin' => new Raw('role = 1 as is_admin')]); - $this->assertSame('select *, (role = 1) as "is_admin" from "users"', $builder->toSql()); + $this->assertSame('select role = 1 as is_admin from "users"', $builder->toSql()); } - public function testAddSelectWithAliasedExpressionPreservesDefaultColumns() + public function testAddSelectPreservesKeyedRawExpressions(): void { $builder = $this->getBuilder(); - $builder->from('users')->addSelect(['is_admin' => new Raw('role = 1')]); + $builder->from('users')->addSelect(['is_admin' => new Raw('role = 1 as is_admin')]); - $this->assertSame('select "users".*, (role = 1) as "is_admin" from "users"', $builder->toSql()); + $this->assertSame('select role = 1 as is_admin from "users"', $builder->toSql()); } - public function testSelect() + public function testSelect(): void { $builder = $this->getBuilder(); $builder->from('one')->select([ @@ -6219,7 +6232,7 @@ public function testSelect() 'five' => new Raw('1 + 1'), ]); - $this->assertSame('select "two", "threee" as "threeee", (select "col" from "tbl") as "four", (1 + 1) as "five" from "one"', $builder->toSql()); + $this->assertSame('select "two", "threee" as "threeee", (select "col" from "tbl") as "four", 1 + 1 from "one"', $builder->toSql()); } public function testUppercaseLeadingBooleansAreRemoved() @@ -7752,11 +7765,15 @@ public function testWhereColumnQuestionMarkOperatorOnPostgres(): void $this->assertSame('select * from "users" where "foo" ??& "_foo"', $builder->toSql()); } - public function testUseIndexMySql() + public function testUseIndexMySql(): void { $builder = $this->getMySqlBuilder(); $builder->select('foo')->from('users')->useIndex('test_index'); $this->assertSame('select `foo` from `users` use index (test_index)', $builder->toSql()); + + $builder = $this->getMySqlBuilder(); + $builder->select('foo')->from('users')->useIndex('test_index, second_index'); + $this->assertSame('select `foo` from `users` use index (test_index, second_index)', $builder->toSql()); } public function testForceIndexMySql() @@ -7828,6 +7845,129 @@ public function testCloneWithoutBindings() $this->assertEquals([], $clone->getBindings()); } + public function testWhereVectorSimilarToOnPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3], minSimilarity: 0.4)->limit(10); + + $this->assertSame( + 'select * from "documents" where ("embedding" <=> ?) <= ? order by ("embedding" <=> ?) asc limit 10', + $builder->toSql() + ); + $this->assertSame(['[1,2,3]', 0.6, '[1,2,3]'], $builder->getBindings()); + } + + public function testWhereVectorSimilarToOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3], minSimilarity: 0.4)->limit(10); + + $this->assertSame( + 'select * from `documents` where vec_distance_cosine(`embedding`, vec_fromtext(?)) <= ? order by vec_distance_cosine(`embedding`, vec_fromtext(?)) asc limit 10', + $builder->toSql() + ); + $this->assertSame(['[1,2,3]', 0.6, '[1,2,3]'], $builder->getBindings()); + } + + public function testWhereVectorSimilarToThrowsOnUnsupportedGrammar(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Vector distance queries are only supported by Postgres and MariaDB.'); + + $builder = $this->getMySqlBuilder(); + $builder->select('*')->from('documents')->whereVectorSimilarTo('embedding', [1, 2, 3]); + } + + public function testWhereVectorSimilarToRejectsUnsupportedGrammarBeforeGeneratingEmbeddings(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Vector distance queries are only supported by Postgres and MariaDB.'); + + $builder = $this->getMySqlBuilder(); + $builder->from('documents')->whereVectorSimilarTo('embedding', 'best wineries in Napa Valley'); + } + + public function testWhereVectorDistanceLessThanOnPostgres(): void + { + $builder = $this->getPostgresBuilder(); + $builder->select('*')->from('documents')->whereVectorDistanceLessThan('embedding', [1, 2, 3], 0.5); + + $this->assertSame('select * from "documents" where ("embedding" <=> ?) <= ?', $builder->toSql()); + $this->assertSame(['[1,2,3]', 0.5], $builder->getBindings()); + } + + public function testWhereVectorDistanceLessThanOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->whereVectorDistanceLessThan('embedding', [1, 2, 3], 0.5); + + $this->assertSame('select * from `documents` where vec_distance_cosine(`embedding`, vec_fromtext(?)) <= ?', $builder->toSql()); + $this->assertSame(['[1,2,3]', 0.5], $builder->getBindings()); + } + + public function testOrderByVectorDistanceOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->select('*')->from('documents')->orderByVectorDistance('embedding', [1, 2, 3]); + + $this->assertSame('select * from `documents` order by vec_distance_cosine(`embedding`, vec_fromtext(?)) asc', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceOnMariaDb(): void + { + $builder = $this->getMariaDbBuilder(); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3]); + + $this->assertSame('select vec_distance_cosine(`embedding`, vec_fromtext(?)) as `embedding_distance` from `documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceWithQualifiedColumnsAndExpressions(): void + { + foreach ([ + ['documents.embedding', '"documents"."embedding"', '`documents`.`embedding`'], + [new Raw('embedding'), 'embedding', 'embedding'], + [new Raw('documents.embedding'), 'documents.embedding', 'documents.embedding'], + ] as [$column, $postgresColumn, $mariaDbColumn]) { + $builder = $this->getPostgresBuilder(); + $builder->from('documents')->selectVectorDistance($column, [1, 2, 3]); + + $this->assertSame('select (' . $postgresColumn . ' <=> ?) as "embedding_distance" from "documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + + $builder = $this->getMariaDbBuilder(); + $builder->from('documents')->selectVectorDistance($column, [1, 2, 3]); + + $this->assertSame('select vec_distance_cosine(' . $mariaDbColumn . ', vec_fromtext(?)) as `embedding_distance` from `documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + } + + public function testSelectVectorDistanceWithCastExpression(): void + { + $builder = $this->getPostgresBuilder(); + $builder->from('documents')->selectVectorDistance(new Raw('CAST(documents.embedding AS vector)'), [1, 2, 3]); + + $this->assertSame('select (CAST(documents.embedding AS vector) <=> ?) as "embedding AS vector)_distance" from "documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + + public function testSelectVectorDistanceWithExplicitAlias(): void + { + $builder = $this->getPostgresBuilder('prefix_'); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3], 'a.b'); + + $this->assertSame('select ("embedding" <=> ?) as "a.b" from "prefix_documents"', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + + $builder = $this->getMariaDbBuilder('prefix_'); + $builder->from('documents')->selectVectorDistance('embedding', [1, 2, 3], 'a.b'); + + $this->assertSame('select vec_distance_cosine(`embedding`, vec_fromtext(?)) as `a.b` from `prefix_documents`', $builder->toSql()); + $this->assertSame(['[1,2,3]'], $builder->getBindings()); + } + public function testToRawSql() { $connection = $this->getConnection(); diff --git a/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php index 3ef5b59275..238c6149be 100644 --- a/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php +++ b/tests/Database/migrations/connection_configured/2022_02_21_000000_create_failed_jobs_table.php @@ -15,22 +15,24 @@ /** * Run the migrations. */ - public function up() + public function up(): void { Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); - $table->text('connection'); - $table->text('queue'); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } /** * Reverse the migrations. */ - public function down() + public function down(): void { Schema::dropIfExists('failed_jobs'); } diff --git a/tests/Events/QueuedEventsTest.php b/tests/Events/QueuedEventsTest.php index 9efcb75df2..2dae81df89 100644 --- a/tests/Events/QueuedEventsTest.php +++ b/tests/Events/QueuedEventsTest.php @@ -716,30 +716,25 @@ public function testUniqueLockIsReleasedOnProcessingWithListenerClassName() $handler->call($job, ['command' => serialize($listener)]); } - public function testUniqueUntilProcessingLockIsReleasedBeforeHandling() + public function testUniqueUntilProcessingLockIsReleasedBeforeHandling(): void { $container = new Container; - $cache = m::mock(Cache::class); - $lock = m::mock(Lock::class); + $cache = new Repository(new ArrayStore); + $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; $container->instance(Cache::class, $cache); $container->instance(BusDispatcher::class, new BusDispatcher($container)); TestDispatcherShouldBeUniqueUntilProcessing::$lockReleasedBeforeHandling = null; TestDispatcherShouldBeUniqueUntilProcessing::$cache = $cache; - TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; + TestDispatcherShouldBeUniqueUntilProcessing::$expectedLockKey = $expectedKey; $listener = new CallQueuedListener(TestDispatcherShouldBeUniqueUntilProcessing::class, 'handle', ['foo', 'bar']); $listener->shouldBeUnique = true; $listener->shouldBeUniqueUntilProcessing = true; $listener->uniqueId = 'until-processing-id'; - $expectedKey = 'laravel_unique_job:' . hash('xxh128', TestDispatcherShouldBeUniqueUntilProcessing::class) . ':until-processing-id'; - - $cache->shouldReceive('lock') - ->with($expectedKey) - ->andReturn($lock); - $lock->shouldReceive('forceRelease')->once(); + $this->assertTrue($cache->lock($expectedKey, 10)->get()); $job = m::mock(Job::class); $job->shouldReceive('hasFailed')->andReturn(false); @@ -753,6 +748,9 @@ public function testUniqueUntilProcessingLockIsReleasedBeforeHandling() $handler->call($job, ['command' => serialize($listener)]); $this->assertTrue(TestDispatcherShouldBeUniqueUntilProcessing::$lockReleasedBeforeHandling); + + // A replacement dispatch's lock must survive the first listener's cleanup. + $this->assertFalse($cache->lock($expectedKey)->get()); } public function testQueuePropagatesDebounceOptions(): void @@ -1274,14 +1272,11 @@ class TestDispatcherShouldBeUniqueUntilProcessing implements ShouldQueue, Should public static string $expectedLockKey = ''; - public function handle() + /** + * Attempt to acquire the unique lock during handling. + */ + public function handle(): void { - $lock = m::mock(Lock::class); - $lock->shouldReceive('get')->andReturn(true); - static::$cache->shouldReceive('lock') - ->with(static::$expectedLockKey, 10) - ->andReturn($lock); - static::$lockReleasedBeforeHandling = static::$cache->lock(static::$expectedLockKey, 10)->get(); } } diff --git a/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php b/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php new file mode 100644 index 0000000000..6e94bc1390 --- /dev/null +++ b/tests/Http/Fixtures/PreventRequestForgeryExceptStub.php @@ -0,0 +1,29 @@ +inExceptArray($request); + } + + /** + * Set the locally excluded paths. + */ + public function setExcept(array $except): static + { + $this->except = $except; + + return $this; + } +} diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 5b81fb82af..113b497920 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -3585,6 +3585,54 @@ public function testAsyncRequestRetriesWithIntegerTries(): void $this->factory->assertSentCount(2); } + #[DataProvider('redirectRetryModes')] + public function testRetryPreservesRedirectResponses(bool $async, bool $throw): void + { + $exceptions = []; + $delays = 0; + + $this->factory->fake([ + '*' => $this->factory->response('Redirect body', 302), + ]); + + $response = $this->factory->async($async) + ->withoutRedirecting() + ->retry(3, function () use (&$delays): int { + ++$delays; + + return 0; + }, function (?Throwable $exception) use (&$exceptions): bool { + $exceptions[] = $exception; + + return true; + }, $throw) + ->get('http://foo.com/get'); + + if ($async) { + $response = $response->wait(); + } + + $this->assertInstanceOf(Response::class, $response); + $this->assertSame(302, $response->status()); + $this->assertSame('Redirect body', $response->body()); + $this->factory->assertSentCount(1); + $this->assertSame([null], $exceptions); + $this->assertSame(0, $delays); + } + + /** + * Provide request execution and retry exception modes. + */ + public static function redirectRetryModes(): array + { + return [ + 'sync, throw' => [false, true], + 'sync, no throw' => [false, false], + 'async, throw' => [true, true], + 'async, no throw' => [true, false], + ]; + } + #[DataProvider('requestRewritingModes')] public function testAsyncRetryCallbackReceivesHttpMethod(bool $rewriteMethod): void { diff --git a/tests/Http/Middleware/PreventRequestForgeryExceptTest.php b/tests/Http/Middleware/PreventRequestForgeryExceptTest.php new file mode 100644 index 0000000000..9cfeedc070 --- /dev/null +++ b/tests/Http/Middleware/PreventRequestForgeryExceptTest.php @@ -0,0 +1,82 @@ +stub = new PreventRequestForgeryExceptStub(app(), new Encrypter(Encrypter::generateKey('AES-128-CBC'))); + $this->request = Request::create('http://example.com/foo/bar', 'POST'); + } + + public function testItCanExceptPaths(): void + { + $this->assertMatchingExcept(['/foo/bar']); + $this->assertMatchingExcept(['foo/bar']); + $this->assertNonMatchingExcept(['/bar/foo']); + } + + public function testPathsCanBeGloballyIgnored(): void + { + $this->request = Request::create('http://example.com/globally/ignored', 'POST'); + $this->assertMatchingExcept([]); + } + + public function testItCanExceptWildcardPaths(): void + { + $this->assertMatchingExcept(['/foo/*']); + $this->assertNonMatchingExcept(['/bar*']); + } + + public function testItCanExceptFullUrlPaths(): void + { + $this->assertMatchingExcept(['http://example.com/foo/bar']); + $this->assertMatchingExcept(['http://example.com/foo/bar/']); + + $this->assertNonMatchingExcept(['https://example.com/foo/bar/']); + $this->assertNonMatchingExcept(['http://foobar.com/']); + } + + public function testItCanExceptFullUrlWildcardPaths(): void + { + $this->assertMatchingExcept(['http://example.com/*']); + $this->assertMatchingExcept(['*example.com*']); + + $this->request = Request::create('https://example.com', 'POST'); + $this->assertMatchingExcept(['*example.com']); + } + + /** + * Assert whether the request matches the given exclusions. + */ + private function assertMatchingExcept(array $except, bool $bool = true): void + { + $this->assertSame($bool, $this->stub->setExcept($except)->checkInExceptArray($this->request)); + } + + /** + * Assert that the request does not match the given exclusions. + */ + private function assertNonMatchingExcept(array $except): void + { + $this->assertMatchingExcept($except, false); + } +} diff --git a/tests/Http/Middleware/PreventRequestForgeryTest.php b/tests/Http/Middleware/PreventRequestForgeryTest.php new file mode 100644 index 0000000000..c272049d52 --- /dev/null +++ b/tests/Http/Middleware/PreventRequestForgeryTest.php @@ -0,0 +1,175 @@ +createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-origin']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testSameSiteHeaderRejectedByDefault(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-site']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testSameSiteHeaderPassesWhenAllowed(): void + { + PreventRequestForgery::allowSameSite(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-site']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testCrossSiteWithValidTokenPasses(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site'], 'test-token'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + public function testCrossSiteWithoutTokenFails(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testMissingHeaderWithoutTokenFails(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testArrayTokenIsRejected(): void + { + $middleware = $this->createMiddleware(); + $request = $this->createRequest(); + // Malformed input must reach token validation, not fail at the getter's return type. + $request->request->set('_token', ['test-token']); + + $this->expectException(TokenMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModeRejectsCrossSite(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + // Even with a valid token, origin-only mode rejects cross-site + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'cross-site'], 'test-token'); + + $this->expectException(OriginMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModeRejectsMissingHeader(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest([], 'test-token'); + + $this->expectException(OriginMismatchException::class); + + $middleware->handle($request, fn () => new Response('OK')); + } + + public function testOriginOnlyModePassesSameOrigin(): void + { + PreventRequestForgery::useOriginOnly(); + + $middleware = $this->createMiddleware(); + $request = $this->createRequest(['HTTP_SEC_FETCH_SITE' => 'same-origin']); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + $this->assertSame('OK', $response->getContent()); + } + + /** + * Create a request with the given headers and token. + */ + protected function createRequest(array $server = [], ?string $token = null): Request + { + $request = Request::create( + 'http://example.com/test', + 'POST', + $token ? ['_token' => $token] : [], + [], + [], + $server + ); + + $session = m::mock(Session::class); + $session->shouldReceive('token')->andReturn('test-token'); + $request->setHypervelSession($session); + + return $request; + } + + /** + * Create the middleware for origin and token verification. + */ + protected function createMiddleware(): PreventRequestForgeryTestStub + { + return new PreventRequestForgeryTestStub( + m::mock(Application::class), + m::mock(Encrypter::class) + ); + } +} + +class PreventRequestForgeryTestStub extends PreventRequestForgery +{ + protected bool $addHttpCookie = false; + + /** + * Determine if the application is running unit tests. + */ + protected function runningUnitTests(): bool + { + return false; + } +} diff --git a/tests/Integration/Database/AfterQueryTest.php b/tests/Integration/Database/AfterQueryTest.php index 37e6f367ce..8c8bd7a69c 100644 --- a/tests/Integration/Database/AfterQueryTest.php +++ b/tests/Integration/Database/AfterQueryTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Schema; @@ -36,7 +39,7 @@ protected function afterRefreshingDatabase(): void }); } - public function testAfterQueryOnEloquentBuilder() + public function testAfterQueryOnEloquentBuilder(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -57,7 +60,7 @@ public function testAfterQueryOnEloquentBuilder() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnBaseBuilder() + public function testAfterQueryOnBaseBuilder(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -79,7 +82,7 @@ public function testAfterQueryOnBaseBuilder() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnEloquentCursor() + public function testAfterQueryOnEloquentCursor(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -100,7 +103,7 @@ public function testAfterQueryOnEloquentCursor() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $users->pluck('id')->toArray()); } - public function testAfterQueryOnBaseBuilderCursor() + public function testAfterQueryOnBaseBuilderCursor(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -148,7 +151,7 @@ public function testAfterQueryOnBaseBuilderCursorDistinguishesNullFromAnEmptyRes ); } - public function testAfterQueryOnEloquentPluck() + public function testAfterQueryOnEloquentPluck(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -169,7 +172,7 @@ public function testAfterQueryOnEloquentPluck() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); } - public function testAfterQueryOnBaseBuilderPluck() + public function testAfterQueryOnBaseBuilderPluck(): void { AfterQueryUser::create(); AfterQueryUser::create(); @@ -191,7 +194,7 @@ public function testAfterQueryOnBaseBuilderPluck() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $userIds->toArray()); } - public function testAfterQueryHookOnBelongsToManyRelationship() + public function testAfterQueryHookOnBelongsToManyRelationship(): void { $user = AfterQueryUser::create(); $firstPost = AfterQueryPost::create(); @@ -216,7 +219,21 @@ public function testAfterQueryHookOnBelongsToManyRelationship() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $posts->pluck('id')->toArray()); } - public function testAfterQueryHookOnHasManyThroughRelationship() + public function testAfterQueryKeyByOnEagerBelongsToManyRelationship(): void + { + $user = AfterQueryUser::create(); + $firstPost = AfterQueryPost::create(); + $secondPost = AfterQueryPost::create(); + + $user->posts()->attach($firstPost); + $user->posts()->attach($secondPost); + + $posts = AfterQueryUser::with('posts')->first()->posts; + + $this->assertEqualsCanonicalizing($posts->pluck('id')->toArray(), $posts->keys()->toArray()); + } + + public function testAfterQueryHookOnHasManyThroughRelationship(): void { $user = AfterQueryUser::create(); $team = AfterQueryTeam::create(['owner_id' => $user->id]); @@ -240,7 +257,7 @@ public function testAfterQueryHookOnHasManyThroughRelationship() $this->assertEqualsCanonicalizing($afterQueryIds->toArray(), $teamMates->pluck('id')->toArray()); } - public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() + public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult(): void { $firstUser = AfterQueryUser::create(); $secondUser = AfterQueryUser::create(); @@ -306,7 +323,7 @@ public function testAfterQueryOnEloquentBuilderCanAlterReturnedResult() $this->assertEquals(collect(['foo', 'bar']), $teamMates); } - public function testAfterQueryOnBaseBuilderCanAlterReturnedResult() + public function testAfterQueryOnBaseBuilderCanAlterReturnedResult(): void { $firstUser = AfterQueryUser::create(); $secondUser = AfterQueryUser::create(); @@ -387,14 +404,22 @@ class AfterQueryUser extends Model public bool $timestamps = false; - public function teamMates() + /** + * Get the user's team members. + */ + public function teamMates(): HasManyThrough { return $this->hasManyThrough(self::class, AfterQueryTeam::class, 'owner_id', 'team_id'); } - public function posts() + /** + * Get the user's posts keyed by their IDs. + */ + public function posts(): BelongsToMany { - return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id')->withTimestamps(); + return $this->belongsToMany(AfterQueryPost::class, 'users_posts', 'user_id', 'post_id') + ->afterQuery(fn (Collection $posts): Collection => $posts->keyBy(fn (AfterQueryPost $post): int => $post->id)) + ->withTimestamps(); } } @@ -406,7 +431,10 @@ class AfterQueryTeam extends Model public bool $timestamps = false; - public function members() + /** + * Get the team's members. + */ + public function members(): HasMany { return $this->hasMany(AfterQueryUser::class, 'team_id'); } diff --git a/tests/Integration/Database/AuthQueryAwarePolicyTest.php b/tests/Integration/Database/AuthQueryAwarePolicyTest.php index 39037bfbff..9d0758ed0a 100644 --- a/tests/Integration/Database/AuthQueryAwarePolicyTest.php +++ b/tests/Integration/Database/AuthQueryAwarePolicyTest.php @@ -147,6 +147,7 @@ public function testPolicyBeforeResultsHydrateAsTrueAndFalseLiterals(): void ->withCan(['policy-before-allowed', 'policy-before-denied'], $this->user()) ->findOrFail('owned'); + $this->assertSame('owned', $post->id); $this->assertTrue($post->can_policy_before_allowed); $this->assertFalse($post->can_policy_before_denied); } diff --git a/tests/Integration/Database/DatabaseLockTest.php b/tests/Integration/Database/DatabaseLockTest.php index c124fbd6a3..a1a7bf28ab 100644 --- a/tests/Integration/Database/DatabaseLockTest.php +++ b/tests/Integration/Database/DatabaseLockTest.php @@ -104,6 +104,20 @@ public function testOtherOwnerDoesNotOwnLockAfterRestore(): void $secondLock = Cache::store('database')->restoreLock('foo', 'other_owner'); $this->assertTrue($secondLock->isOwnedBy($firstLock->owner())); $this->assertFalse($secondLock->isOwnedByCurrentProcess()); + $this->assertFalse($secondLock->release()); + $this->assertTrue($firstLock->isOwnedByCurrentProcess()); + } + + public function testExpiredLockCanBeReleasedByItsOwner(): void + { + $lock = Cache::store('database')->lock('foo', 10); + $this->assertTrue($lock->get()); + + DB::table('cache_locks')->update(['expiration' => CarbonImmutable::now()->subDay()->getTimestamp()]); + + $this->assertTrue($lock->release()); + $this->assertSame(0, DB::table('cache_locks')->count()); + $this->assertFalse($lock->release()); } public function testLockCanBeRefreshed(): void @@ -180,15 +194,10 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod { $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(Connection::class); - $ownerBuilder = m::mock(Builder::class); $deleteBuilder = m::mock(Builder::class); $owner = 'owner-123'; - $ownerBuilder->shouldReceive('where')->with('key', 'foo')->once()->andReturnSelf(); - $ownerBuilder->shouldReceive('where')->with('expiration', '>', m::type('int'))->once()->andReturnSelf(); - $ownerBuilder->shouldReceive('first')->once()->andReturn((object) ['owner' => $owner]); - $deleteBuilder->shouldReceive('where')->with('key', 'foo')->once()->andReturnSelf(); $deleteBuilder->shouldReceive('where')->with('owner', $owner)->once()->andReturnSelf(); $deleteBuilder->shouldReceive('delete')->once()->andThrow( @@ -200,7 +209,7 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod ) ); - $connection->shouldReceive('table')->with('cache_locks')->andReturn($ownerBuilder, $deleteBuilder); + $connection->shouldReceive('table')->with('cache_locks')->once()->andReturn($deleteBuilder); $resolver->shouldReceive('connection')->with(null)->andReturn($connection); $lock = new DatabaseLock($resolver, null, 'foo', 'cache_locks', 10, $owner); diff --git a/tests/Integration/Database/MariaDb/EloquentVectorTest.php b/tests/Integration/Database/MariaDb/EloquentVectorTest.php new file mode 100644 index 0000000000..0b294a0d43 --- /dev/null +++ b/tests/Integration/Database/MariaDb/EloquentVectorTest.php @@ -0,0 +1,82 @@ +=11.7.0')] +class EloquentVectorTest extends MariaDbTestCase +{ + /** + * Create the vector storage schema. + */ + protected function afterRefreshingDatabase(): void + { + Schema::create('documents', function (Blueprint $table): void { + $table->increments('id'); + $table->vector('embedding', 3); + $table->vectorIndex('embedding'); + }); + } + + /** + * Remove the vector storage schema. + */ + protected function destroyDatabaseMigrations(): void + { + Schema::dropIfExists('documents'); + } + + public function testVectorsCanBeStoredAndRetrieved(): void + { + $document = VectorDocument::create(['embedding' => [0.5, -1.25, 3]]); + + $this->assertSame([0.5, -1.25, 3.0], $document->embedding); + $this->assertSame([0.5, -1.25, 3.0], $document->fresh()->embedding); + } + + public function testVectorsCanBeUpdated(): void + { + $document = VectorDocument::create(['embedding' => [0.5, -1.25, 3]]); + + $document->update(['embedding' => [1, 2, 3]]); + + $this->assertSame([1.0, 2.0, 3.0], $document->fresh()->embedding); + } + + public function testVectorsCanBeQueriedByDistance(): void + { + $exact = VectorDocument::create(['embedding' => [1, 0, 0]]); + $close = VectorDocument::create(['embedding' => [0.9, 0.1, 0]]); + VectorDocument::create(['embedding' => [0, 1, 0]]); + + $results = VectorDocument::query() + ->select('id') + ->selectVectorDistance('embedding', [1, 0, 0]) + ->whereVectorSimilarTo('embedding', [1, 0, 0], minSimilarity: 0.5) + ->get(); + + $this->assertSame([$exact->id, $close->id], $results->pluck('id')->all()); + $this->assertEqualsWithDelta(0.0, (float) $results[0]->embedding_distance, 0.0001); + $this->assertGreaterThan(0.0, (float) $results[1]->embedding_distance); + } +} + +class VectorDocument extends Model +{ + protected ?string $table = 'documents'; + + public bool $timestamps = false; + + protected array $guarded = []; + + protected array $casts = [ + 'embedding' => AsVector::class, + ]; +} diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index d050ae222f..64c20a4c82 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -273,7 +273,7 @@ public function testAliasedSourcesPreserveTheirColumnsWhenAddingSelections(): vo $expected = DB::table('posts')->orderBy('id')->get()->all(); foreach ([DB::table('posts', 'source'), DB::table('posts AS source'), DB::table('posts', '0')] as $query) { - $rows = $query->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get(); + $rows = $query->addSelect(['bonus' => DB::query()->selectRaw('42')])->orderBy('id')->get(); $this->assertCount(2, $rows); foreach ($rows as $index => $row) { @@ -286,7 +286,7 @@ public function testAliasedSourcesPreserveTheirColumnsWhenAddingSelections(): vo $this->assertEquals([ (object) ['id' => 1, 'bonus' => 42], (object) ['id' => 2, 'bonus' => 42], - ], (clone $query)->addSelect(['bonus' => new Expression(42)])->orderBy('id')->get()->all()); + ], (clone $query)->addSelect(['bonus' => DB::query()->selectRaw('42')])->orderBy('id')->get()->all()); $this->assertEquals([ (object) ['id' => 1, 'bonus' => 7], diff --git a/tests/Integration/Database/Queue/QueuePayloadStorageTest.php b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php new file mode 100644 index 0000000000..4ae29a1b09 --- /dev/null +++ b/tests/Integration/Database/Queue/QueuePayloadStorageTest.php @@ -0,0 +1,58 @@ +artisan('make:queue-table')->assertExitCode(0); + $this->artisan('make:queue-failed-table')->assertExitCode(0); + $this->artisan('migrate')->assertExitCode(0); + + $queue = Queue::connection('database'); + $provider = new DatabaseUuidFailedJobProvider($this->app->make('db'), null, 'failed_jobs'); + + foreach ([ + [null, '{invalid'], + // A native UUID column would reject this supported identifier on PostgreSQL. + ['uuid-1', '{ "uuid": "uuid-1", "job": "ExampleJob", "data": {"b":2,"a":1} }'], + ] as [$identifier, $payload]) { + $queue->pushRaw($payload); + + $job = $queue->pop(); + + $this->assertNotNull($job); + $this->assertSame($payload, $job->getRawBody()); + + $failedId = $provider->log('database', $job->getQueue(), $job->getRawBody(), new RuntimeException); + + if ($identifier === null) { + $this->assertTrue(Str::isUuid($failedId)); + } else { + $this->assertSame($identifier, $failedId); + } + + $failedJob = $provider->find($failedId); + + $this->assertNotNull($failedJob); + $this->assertSame($payload, $failedJob->payload); + + $job->delete(); + } + } +} diff --git a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php index c0bb6cbf3a..a782e6c05c 100644 --- a/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php +++ b/tests/Integration/Database/Sqlite/DatabaseSchemaBuilderTest.php @@ -171,7 +171,7 @@ public function testSchemaQualifiedPrefixedTablesPreserveQueryIdentifiers(): voi $this->assertSame([ ['id' => 1, 'bonus' => 42], ['id' => 2, 'bonus' => 42], - ], $connection->table('main.items', 'source')->addSelect(['bonus' => new Expression(42)]) + ], $connection->table('main.items', 'source')->addSelect(['bonus' => $connection->query()->selectRaw('42')]) ->orderBy('id')->get()->map(static fn (object $row): array => (array) $row)->all()); $query = $connection->table('main.items', 'source') diff --git a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php index bc8020690f..3bb7752919 100644 --- a/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php +++ b/tests/Integration/Foundation/Support/Providers/RouteServiceProviderHealthTest.php @@ -8,6 +8,7 @@ use Hypervel\Foundation\Application; use Hypervel\Foundation\Events\DiagnosingHealth; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Event; use Hypervel\Support\Str; use Hypervel\Testbench\Attributes\WithConfig; @@ -45,6 +46,16 @@ public function testItCanLoadHealthPage(): void ->assertSee('Application up'); } + #[WithConfig('view.cache', false)] + public function testItPreservesTheCssThemeWhenACustomDirectiveIsRegistered(): void + { + Blade::directive('theme', static fn (): string => '/* Application theme directive */'); + + $this->get('/up') + ->assertOk() + ->assertSee('@theme {', false); + } + public function testItRendersTheCurrentRequestDuration(): void { CarbonImmutable::setTestNow('2026-08-06 12:00:00 UTC'); diff --git a/tests/Integration/Generators/QueueFailedTableCommandTest.php b/tests/Integration/Generators/QueueFailedTableCommandTest.php new file mode 100644 index 0000000000..21184cb4e4 --- /dev/null +++ b/tests/Integration/Generators/QueueFailedTableCommandTest.php @@ -0,0 +1,27 @@ +artisan(FailedTableCommand::class)->assertExitCode(0); + + $this->assertMigrationFileContains([ + 'use Hypervel\Database\Migrations\Migration;', + 'return new class extends Migration', + "Schema::create('failed_jobs', function (Blueprint \$table) {", + "\$table->string('uuid')->unique();", + "\$table->string('connection');", + "\$table->string('queue');", + "\$table->longText('payload');", + "\$table->index(['connection', 'queue', 'failed_at']);", + "Schema::dropIfExists('failed_jobs');", + ], 'create_failed_jobs_table.php'); + } +} diff --git a/tests/Integration/Queue/ModelSerializationTest.php b/tests/Integration/Queue/ModelSerializationTest.php index 4c7d950fe2..783bccbcf6 100644 --- a/tests/Integration/Queue/ModelSerializationTest.php +++ b/tests/Integration/Queue/ModelSerializationTest.php @@ -24,12 +24,14 @@ use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; use LogicException; -use Override; class ModelSerializationTest extends TestCase { use RefreshDatabase; + /** + * Define the additional database connection. + */ protected function defineEnvironment(ApplicationContract $app): void { $app->make('config')->set('database.connections.custom', [ @@ -39,56 +41,50 @@ protected function defineEnvironment(ApplicationContract $app): void ]); } + /** + * Set up the model serialization tables. + */ protected function setUp(): void { parent::setUp(); Model::preventLazyLoading(false); - Schema::create('users', function (Blueprint $table) { + Schema::create('users', function (Blueprint $table): void { $table->increments('id'); $table->string('email'); }); - Schema::connection('custom')->create('users', function (Blueprint $table) { + Schema::connection('custom')->create('users', function (Blueprint $table): void { $table->increments('id'); $table->string('email'); }); - Schema::create('orders', function (Blueprint $table) { + Schema::create('orders', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('lines', function (Blueprint $table) { + Schema::create('lines', function (Blueprint $table): void { $table->increments('id'); $table->unsignedInteger('order_id'); $table->unsignedInteger('product_id'); }); - Schema::create('products', function (Blueprint $table) { + Schema::create('products', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('roles', function (Blueprint $table) { + Schema::create('roles', function (Blueprint $table): void { $table->increments('id'); }); - Schema::create('role_user', function (Blueprint $table) { + Schema::create('role_user', function (Blueprint $table): void { $table->unsignedInteger('user_id'); $table->unsignedInteger('role_id'); }); } - #[Override] - protected function tearDown(): void - { - Relation::morphMap([], false); - ModelIdentifier::useMorphMap(false); - - parent::tearDown(); - } - - public function testItSerializeUserOnDefaultConnection() + public function testItSerializeUserOnDefaultConnection(): void { $defaultConnection = config('database.default'); @@ -117,7 +113,7 @@ public function testItSerializeUserOnDefaultConnection() $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } - public function testItSerializeUserOnDifferentConnection() + public function testItSerializeUserOnDifferentConnection(): void { $user = ModelSerializationTestUser::on('custom')->create([ 'email' => 'mohamed@laravel.com', @@ -144,7 +140,7 @@ public function testItSerializeUserOnDifferentConnection() $this->assertSame('taylor@laravel.com', $unSerialized->users[1]->email); } - public function testItFailsIfModelsOnMultiConnections() + public function testItFailsIfModelsOnMultiConnections(): void { $this->expectException(LogicException::class); $this->expectExceptionMessage('Queueing collections with multiple model connections is not supported.'); @@ -164,9 +160,9 @@ public function testItFailsIfModelsOnMultiConnections() unserialize($serialized); } - public function testItReloadsRelationships() + public function testItReloadsRelationships(): void { - $order = tap(Order::create(), function (Order $order) { + $order = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -184,9 +180,9 @@ public function testItReloadsRelationships() $this->assertEquals($unSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsRelationshipsOnlyOnce() + public function testItReloadsRelationshipsOnlyOnce(): void { - $order = tap(ModelSerializationTestCustomOrder::create(), function (ModelSerializationTestCustomOrder $order) { + $order = tap(ModelSerializationTestCustomOrder::create(), function (ModelSerializationTestCustomOrder $order): void { $order->wasRecentlyCreated = false; }); @@ -206,9 +202,9 @@ public function testItReloadsRelationshipsOnlyOnce() $this->assertEquals($unSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsNestedRelationships() + public function testItReloadsNestedRelationships(): void { - $order = tap(Order::create(), function (Order $order) { + $order = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -226,13 +222,13 @@ public function testItReloadsNestedRelationships() $this->assertEquals($nestedUnSerialized->order->getRelations(), $order->getRelations()); } - public function testItReloadsRelationshipsForCollections() + public function testItReloadsRelationshipsForCollections(): void { - $order1 = tap(Order::create(), function (Order $order) { + $order1 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); - $order2 = tap(Order::create(), function (Order $order) { + $order2 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -256,13 +252,13 @@ public function testItReloadsRelationshipsForCollections() $this->assertTrue($unSerialized->orders[1]->relationLoaded('products')); } - public function testItReloadsNestedRelationshipsForCollections() + public function testItReloadsNestedRelationshipsForCollections(): void { - $order1 = tap(Order::create(), function (Order $order) { + $order1 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); - $order2 = tap(Order::create(), function (Order $order) { + $order2 = tap(Order::create(), function (Order $order): void { $order->wasRecentlyCreated = false; }); @@ -288,7 +284,7 @@ public function testItReloadsNestedRelationshipsForCollections() $this->assertTrue($unSerialized->orders[1]->lines->first()->relationLoaded('product')); } - public function testItCanRunModelBootsAndTraitInitializations() + public function testItCanRunModelBootsAndTraitInitializations(): void { $model = new ModelBootTestWithTraitInitialization; @@ -318,11 +314,11 @@ public function testItCanRunModelBootsAndTraitInitializations() /** * Regression test for https://github.com/laravel/framework/issues/23068. */ - public function testItCanUnserializeNestedRelationshipsWithoutPivot() + public function testItCanUnserializeNestedRelationshipsWithoutPivot(): void { $user = tap(User::create([ 'email' => 'taylor@laravel.com', - ]), function (User $user) { + ]), function (User $user): void { $user->wasRecentlyCreated = false; }); @@ -332,7 +328,7 @@ public function testItCanUnserializeNestedRelationshipsWithoutPivot() RoleUser::create(['user_id' => $user->id, 'role_id' => $role1->id]); RoleUser::create(['user_id' => $user->id, 'role_id' => $role2->id]); - $user->roles->each(function ($role) { + $user->roles->each(function (Role $role): void { $role->pivot->load('user', 'role'); }); @@ -340,7 +336,7 @@ public function testItCanUnserializeNestedRelationshipsWithoutPivot() unserialize($serialized); } - public function testItSerializesAnEmptyCollection() + public function testItSerializesAnEmptyCollection(): void { $serialized = serialize(new CollectionSerializationTestClass( new Collection([]) @@ -349,7 +345,7 @@ public function testItSerializesAnEmptyCollection() unserialize($serialized); } - public function testItSerializesACollectionInCorrectOrder() + public function testItSerializesACollectionInCorrectOrder(): void { ModelSerializationTestUser::create(['email' => 'mohamed@laravel.com']); ModelSerializationTestUser::create(['email' => 'taylor@laravel.com']); @@ -364,7 +360,7 @@ public function testItSerializesACollectionInCorrectOrder() $this->assertSame('mohamed@laravel.com', $unserialized->users->last()->email); } - public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedModels() + public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedModels(): void { ModelSerializationTestUser::create(['email' => '2@laravel.com']); ModelSerializationTestUser::create(['email' => '3@laravel.com']); @@ -384,7 +380,7 @@ public function testItCanUnserializeACollectionInCorrectOrderAndHandleDeletedMod $this->assertSame('1@laravel.com', $unserialized->users->last()->email); } - public function testItCanUnserializeCustomCollection() + public function testItCanUnserializeCustomCollection(): void { ModelSerializationTestCustomUser::create(['email' => 'mohamed@laravel.com']); ModelSerializationTestCustomUser::create(['email' => 'taylor@laravel.com']); @@ -398,7 +394,7 @@ public function testItCanUnserializeCustomCollection() $this->assertInstanceOf(ModelSerializationTestCustomUserCollection::class, $unserialized->users); } - public function testItSerializesTypedProperties() + public function testItSerializesTypedProperties(): void { require_once __DIR__ . '/typed-properties.php'; @@ -432,7 +428,7 @@ public function testItSerializesTypedProperties() } #[WithConfig('database.default', 'testing')] - public function testModelSerializationStructure() + public function testModelSerializationStructure(): void { $user = ModelSerializationTestUser::create([ 'email' => 'taylor@laravel.com', @@ -444,7 +440,7 @@ public function testModelSerializationStructure() } #[WithConfig('database.default', 'testing')] - public function testItRespectsWithoutRelationsAttribute() + public function testItRespectsWithoutRelationsAttribute(): void { $user = User::create([ 'email' => 'taylor@laravel.com', @@ -456,7 +452,7 @@ public function testItRespectsWithoutRelationsAttribute() } #[WithConfig('database.default', 'testing')] - public function testItRespectsWithoutRelationsAttributeAppliedToClass() + public function testItRespectsWithoutRelationsAttributeAppliedToClass(): void { $user = User::create([ 'email' => 'taylor@laravel.com', @@ -470,10 +466,26 @@ public function testItRespectsWithoutRelationsAttributeAppliedToClass() $unserialized = unserialize($serialized); $this->assertFalse($unserialized->user->relationLoaded('roles')); - $this->assertEquals('hello', $unserialized->value->value); + $this->assertSame('hello', $unserialized->value->value); } - public function testSerializationTypesEmptyCustomEloquentCollection() + #[WithConfig('database.default', 'testing')] + public function testItRespectsWithoutRelationsAttributeAppliedToParentClass(): void + { + $user = User::create([ + 'email' => 'taylor@laravel.com', + ])->load(['roles']); + + $serialized = serialize(new ModelSerializationAttributeTargetsParentClassTestClass($user, new DataValueObject('hello'))); + + /** @var ModelSerializationAttributeTargetsParentClassTestClass $unserialized */ + $unserialized = unserialize($serialized); + + $this->assertFalse($unserialized->user->relationLoaded('roles')); + $this->assertSame('hello', $unserialized->value->value); + } + + public function testSerializationTypesEmptyCustomEloquentCollection(): void { $class = new ModelSerializationTypedCustomCollectionTestClass( new ModelSerializationTestCustomUserCollection @@ -512,7 +524,42 @@ public function testItUsesMorphMapForSerialization(): void } #[WithConfig('database.default', 'testing')] - public function testItUsesMorphMapForCollectionSerialization(): void + public function testItUsesMorphMapForSerializationOfCollection(): void + { + Relation::morphMap([ + 'user' => User::class, + ]); + + ModelIdentifier::useMorphMap(); + + $user = User::create([ + 'email' => 'taylor@laravel.com', + ]); + + $serialized = serialize(new CollectionSerializationTestClass( + new Collection([$user]), + )); + + $this->assertSame( + sprintf( + 'O:%d:"%s":1:{s:5:"users";O:%d:"%s":5:{s:5:"class";s:4:"user";s:2:"id";a:1:{i:0;i:1;}s:9:"relations";a:0:{}s:10:"connection";s:7:"testing";s:15:"collectionClass";N;}}', + strlen(CollectionSerializationTestClass::class), + CollectionSerializationTestClass::class, + strlen(ModelIdentifier::class), + ModelIdentifier::class, + ), + $serialized + ); + + /** @var CollectionSerializationTestClass $unserialized */ + $unserialized = unserialize($serialized); + + $this->assertInstanceOf(Collection::class, $unserialized->users); + $this->assertTrue($unserialized->users->sole()->is($user)); + } + + #[WithConfig('database.default', 'testing')] + public function testItRestoresMorphMappedCollectionsInOrder(): void { Relation::morphMap([ 'user' => User::class, @@ -540,6 +587,9 @@ public function testItUsesMorphMapForCollectionSerialization(): void $this->assertSame('mohamed@laravel.com', $unserialized->users[1]->email); } + /** + * Get the expected serialization of accessible parent properties. + */ private function expectedParentAccessibleSerialization(): string { $class = ModelSerializationParentAccessibleTestClass::class; @@ -562,6 +612,9 @@ private function expectedParentAccessibleSerialization(): string ); } + /** + * Get the expected serialization without relationships. + */ private function expectedWithoutRelationsSerialization(): string { $class = ModelSerializationWithoutRelations::class; @@ -579,6 +632,9 @@ private function expectedWithoutRelationsSerialization(): string ); } + /** + * Get the expected serialization for class-level relation exclusion. + */ private function expectedAttributeTargetsClassSerialization(string $userClass = User::class): string { $class = ModelSerializationAttributeTargetsClassTestClass::class; @@ -605,24 +661,36 @@ trait TraitBootsAndInitializersTest public bool $fooBar = false; + /** + * Toggle the trait initialization state. + */ public function initializeTraitBootsAndInitializersTest(): void { $this->fooBar = ! $this->fooBar; } + /** + * Register the trait's global scope. + */ public static function bootTraitBootsAndInitializersTest(): void { - static::addGlobalScope('foo_bar', function () { + static::addGlobalScope('foo_bar', function (): void { }); } + /** + * Register the attributed trait's global scope. + */ #[Boot] public static function nonConventionalBootFunctionInTrait(): void { - static::addGlobalScope('booted_attr_in_trait', function () { + static::addGlobalScope('booted_attr_in_trait', function (): void { }); } + /** + * Toggle the attributed trait initialization state. + */ #[Initialize] public function nonConventionalInitFunctionInTrait(): void { @@ -638,13 +706,19 @@ class ModelBootTestWithTraitInitialization extends Model public bool $initializedViaAttributeInClass = false; + /** + * Register the attributed model's global scope. + */ #[Boot] public static function nonConventionalBootFunctionInClass(): void { - static::addGlobalScope('booted_attr_in_class', function () { + static::addGlobalScope('booted_attr_in_class', function (): void { }); } + /** + * Toggle the attributed model initialization state. + */ #[Initialize] public function nonConventionalInitFunctionInClass(): void { @@ -671,6 +745,9 @@ class ModelSerializationTypedCustomCollectionTestClass public ModelSerializationTestCustomUserCollection $collection; + /** + * Create a fixture containing a custom collection. + */ public function __construct(ModelSerializationTestCustomUserCollection $collection) { $this->collection = $collection; @@ -685,6 +762,9 @@ class ModelSerializationTestCustomUser extends Model public bool $timestamps = false; + /** + * Create the model's custom collection. + */ public function newCollection(array $models = []): ModelSerializationTestCustomUserCollection { return new ModelSerializationTestCustomUserCollection($models); @@ -701,16 +781,25 @@ class ModelSerializationTestCustomOrder extends Model protected array $with = ['line', 'lines', 'products']; + /** + * Get the order's first line. + */ public function line(): HasOne { return $this->hasOne(Line::class, 'order_id'); } + /** + * Get the order's lines. + */ public function lines(): HasMany { return $this->hasMany(Line::class, 'order_id'); } + /** + * Get the order's products. + */ public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'lines', 'order_id'); @@ -723,16 +812,25 @@ class Order extends Model public bool $timestamps = false; + /** + * Get the order's first line. + */ public function line(): HasOne { return $this->hasOne(Line::class); } + /** + * Get the order's lines. + */ public function lines(): HasMany { return $this->hasMany(Line::class); } + /** + * Get the order's products. + */ public function products(): BelongsToMany { return $this->belongsToMany(Product::class, 'lines'); @@ -745,6 +843,9 @@ class Line extends Model public bool $timestamps = false; + /** + * Get the line's product. + */ public function product(): BelongsTo { return $this->belongsTo(Product::class); @@ -764,6 +865,9 @@ class User extends Model public bool $timestamps = false; + /** + * Get the user's roles. + */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class) @@ -777,6 +881,9 @@ class Role extends Model public bool $timestamps = false; + /** + * Get the role's users. + */ public function users(): BelongsToMany { return $this->belongsToMany(User::class) @@ -790,11 +897,17 @@ class RoleUser extends Pivot public bool $timestamps = false; + /** + * Get the pivot's user. + */ public function user(): BelongsTo { return $this->belongsTo(User::class); } + /** + * Get the pivot's role. + */ public function role(): BelongsTo { return $this->belongsTo(Role::class); @@ -807,6 +920,9 @@ class ModelSerializationTestClass public ModelSerializationTestUser|User $user; + /** + * Create a fixture containing a user. + */ public function __construct(ModelSerializationTestUser|User $user) { $this->user = $user; @@ -823,6 +939,9 @@ class ModelSerializationAccessibleTestClass private ModelSerializationTestUser $user3; + /** + * Create a fixture with public, protected and private model properties. + */ public function __construct(ModelSerializationTestUser $user, ModelSerializationTestUser $user2, ModelSerializationTestUser $user3) { $this->user = $user; @@ -842,6 +961,9 @@ class ModelSerializationWithoutRelations #[WithoutRelations] public User $user; + /** + * Create a fixture whose model excludes relationships. + */ public function __construct(User $user) { $this->user = $user; @@ -853,17 +975,27 @@ class ModelSerializationAttributeTargetsClassTestClass { use SerializesModels; + /** + * Create a fixture with class-level relation exclusion. + */ public function __construct(public User $user, public DataValueObject $value) { } } +class ModelSerializationAttributeTargetsParentClassTestClass extends ModelSerializationAttributeTargetsClassTestClass +{ +} + class ModelRelationSerializationTestClass { use SerializesModels; public Order|ModelSerializationTestCustomOrder $order; + /** + * Create a fixture containing an order. + */ public function __construct(Order|ModelSerializationTestCustomOrder $order) { $this->order = $order; @@ -876,6 +1008,9 @@ class CollectionSerializationTestClass public Collection $users; + /** + * Create a fixture containing users. + */ public function __construct(Collection $users) { $this->users = $users; @@ -888,6 +1023,9 @@ class CollectionRelationSerializationTestClass public Collection $orders; + /** + * Create a fixture containing orders. + */ public function __construct(Collection $orders) { $this->orders = $orders; @@ -896,6 +1034,9 @@ public function __construct(Collection $orders) class DataValueObject { + /** + * Create a value object for serialization. + */ public function __construct(public string|int $value = 1) { } diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index 47506a8402..bf8bcb6215 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -107,6 +107,25 @@ public function testNotificationCanBeDispatchedToDriver(): void $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); } + public function testChannelCanBeResolvedUsingBackedEnum(): void + { + $container = $this->getContainer(); + + $manager = new ChannelManager($container); + $manager->extend('test', fn () => new NotificationChannelManagerTestCustomChannel); + + $this->assertInstanceOf(NotificationChannelManagerTestCustomChannel::class, $manager->channel(NotificationChannelManagerTestChannelEnum::Test)); + } + + public function testDriverCanBeResolvedUsingBackedEnum(): void + { + $container = $this->getContainer(); + + $manager = new ChannelManager($container); + + $this->assertInstanceOf(NotificationChannelManagerTestCustomChannel::class, $manager->driver(NotificationChannelManagerTestChannelEnum::Custom)); + } + public function testNotificationNotSentOnHalt(): void { $container = $this->getContainer(); @@ -170,6 +189,46 @@ public function testNotificationNotSentWhenFailed(): void $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); } + public function testNotificationFailedDispatchedOnlyOnceWhenMultipleFailed(): void + { + $container = $this->getContainer(); + $events = $container->make(Dispatcher::class); + $manager = new ChannelManager($container); + $manager->extend('test', function () { + return new class { + private int $count = 0; + + /** + * Fail after two successful sends. + */ + public function send(mixed $notifiable, Notification $notification): void + { + if ($this->count > 1) { + throw new Exception('Channel failed.'); + } + + ++$this->count; + } + }; + }); + + // The provider owns the listener; sending must not register additional listeners. + // NotificationFailedEventTest covers channel-owned failure deduplication through the real provider. + $events->shouldNotReceive('listen'); + $events->shouldReceive('until')->times(3)->with(m::type(NotificationSending::class))->andReturn(true); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationFailed::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(NotificationDelivered::class)); + $events->shouldReceive('dispatch')->twice()->with(m::type(NotificationSent::class)); + + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Channel failed.'); + + $manager->send(new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerTestNotification); + } + public function testNotificationCanBeQueued(): void { $container = $this->getContainer(); @@ -664,3 +723,13 @@ public function afterSending($notifiable, $channel, $response) static::$afterSendingResponse = $response; } } + +enum NotificationChannelManagerTestChannelEnum: string +{ + case Test = 'test'; + case Custom = NotificationChannelManagerTestCustomChannel::class; +} + +class NotificationChannelManagerTestCustomChannel +{ +} diff --git a/tests/Integration/Notifications/NotificationFailedEventTest.php b/tests/Notifications/NotificationFailedEventTest.php similarity index 72% rename from tests/Integration/Notifications/NotificationFailedEventTest.php rename to tests/Notifications/NotificationFailedEventTest.php index 974b708693..2d15374d74 100644 --- a/tests/Integration/Notifications/NotificationFailedEventTest.php +++ b/tests/Notifications/NotificationFailedEventTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Notifications; +namespace Hypervel\Tests\Notifications; use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Notifications\ChannelManager; use Hypervel\Notifications\Events\NotificationFailed; +use Hypervel\Notifications\Events\NotificationSent; use Hypervel\Notifications\Notification; use Hypervel\Notifications\NotificationSender; use Hypervel\Testbench\TestCase; @@ -18,6 +19,30 @@ class NotificationFailedEventTest extends TestCase { + public function testNotificationFailedDispatchedOnlyOnceWhenFailed(): void + { + $events = $this->app->make(Dispatcher::class); + $manager = $this->app->make(ChannelManager::class); + $dispatched = $this->recordFailures($events); + $sent = 0; + + $events->listen(NotificationSent::class, function () use (&$sent): void { + ++$sent; + }); + $manager->extend('test', fn () => new NotificationFailedEventDispatchingChannel($events, 'test')); + + // Use the real provider listener to suppress the sender's duplicate failure event. + $exception = $this->captureRuntimeException(fn () => $manager->sendNow( + new NotificationFailedEventNotifiable, + new NotificationFailedEventNotification, + ['test'], + )); + + $this->assertSame('test failed.', $exception->getMessage()); + $this->assertSame(['test'], $dispatched()); + $this->assertSame(0, $sent); + } + public function testChannelOwnedFailureSurvivesNestedSuccess(): void { $events = $this->app->make(Dispatcher::class); @@ -86,6 +111,29 @@ 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); @@ -175,6 +223,9 @@ protected function captureRuntimeException(Closure $callback): RuntimeException class NotificationFailedEventDispatchingChannel { + /** + * Create a channel that reports and throws a failure. + */ public function __construct( private readonly Dispatcher $events, private readonly string $channel, @@ -182,6 +233,9 @@ public function __construct( ) { } + /** + * Report the failure before invoking the nested send and throwing. + */ public function send(mixed $notifiable, Notification $notification): never { $this->events->dispatch(new NotificationFailed($notifiable, $notification, $this->channel)); @@ -194,8 +248,29 @@ 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 { + /** + * Complete the delivery without an exception. + */ public function send(mixed $notifiable, Notification $notification): void { } @@ -203,6 +278,9 @@ public function send(mixed $notifiable, Notification $notification): void class NotificationFailedEventThrowingChannel { + /** + * Throw a delivery failure for the sender to report. + */ public function send(mixed $notifiable, Notification $notification): never { throw new RuntimeException('Channel failed.'); diff --git a/tests/Queue/SerializesModelsTest.php b/tests/Queue/SerializesModelsTest.php index c7c5bcb056..64282711e4 100644 --- a/tests/Queue/SerializesModelsTest.php +++ b/tests/Queue/SerializesModelsTest.php @@ -25,14 +25,14 @@ public function testConcreteClassWithoutRelationsAttributeStripsRelations(): voi $this->assertSame([], $payload['entity']->relations); } - public function testInheritedClassWithoutRelationsAttributeIsNotAppliedToChild(): void + public function testInheritedClassWithoutRelationsAttributeIsAppliedToChild(): void { $payload = (new ChildClassInheritingWithoutRelationsSerializationFixture( new QueueableEntitySerializationFixture ))->__serialize(); $this->assertInstanceOf(ModelIdentifier::class, $payload['entity']); - $this->assertSame(['roles'], $payload['entity']->relations); + $this->assertSame([], $payload['entity']->relations); } public function testPropertyWithoutRelationsAttributeStripsRelations(): void @@ -82,6 +82,9 @@ class EloquentModelSerializationFixture { use SerializesModels; + /** + * Create a fixture containing an Eloquent model. + */ public function __construct(public Model $model) { } @@ -96,6 +99,9 @@ class ClassWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a fixture with class-level relation exclusion. + */ public function __construct( public QueueableEntitySerializationFixture $entity, ) { @@ -107,6 +113,9 @@ class ParentClassWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a parent fixture with relation exclusion. + */ public function __construct( public QueueableEntitySerializationFixture $entity, ) { @@ -121,6 +130,9 @@ class PropertyWithoutRelationsSerializationFixture { use SerializesModels; + /** + * Create a fixture with property-level relation exclusion. + */ public function __construct( #[WithoutRelations] public QueueableEntitySerializationFixture $entity, @@ -131,16 +143,25 @@ public function __construct( class QueueableEntitySerializationFixture extends Model { + /** + * Get the identifier for the fixture. + */ public function getQueueableId(): int { return 1; } + /** + * Get the fixture's queueable relationships. + */ public function getQueueableRelations(): array { return ['roles']; } + /** + * Get the fixture's queueable connection. + */ public function getQueueableConnection(): ?string { return 'testing'; @@ -151,6 +172,9 @@ class NonEloquentQueueablesSerializationFixture { use SerializesModels; + /** + * Create a fixture containing non-Eloquent queueable objects. + */ public function __construct( public NonEloquentQueueableEntitySerializationFixture $entity, public NonEloquentQueueableCollectionSerializationFixture $collection, @@ -160,21 +184,33 @@ public function __construct( class NonEloquentQueueableEntitySerializationFixture implements QueueableEntity { + /** + * Create a queueable entity with the given value. + */ public function __construct( public string $value, ) { } + /** + * Get the queueable identifier. + */ public function getQueueableId(): string { return $this->value; } + /** + * Get the queueable relationships. + */ public function getQueueableRelations(): array { return []; } + /** + * Get the queueable connection. + */ public function getQueueableConnection(): ?string { return null; @@ -183,26 +219,41 @@ public function getQueueableConnection(): ?string class NonEloquentQueueableCollectionSerializationFixture implements QueueableCollection { + /** + * Create a queueable collection with the given items. + */ public function __construct( public array $items, ) { } + /** + * Get the class of the queueable entities. + */ public function getQueueableClass(): ?string { return NonEloquentQueueableEntitySerializationFixture::class; } + /** + * Get the queueable identifiers. + */ public function getQueueableIds(): array { return array_keys($this->items); } + /** + * Get the queueable relationships. + */ public function getQueueableRelations(): array { return []; } + /** + * Get the queueable connection. + */ public function getQueueableConnection(): ?string { return null; diff --git a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php index 941e3f6376..9ba40a7994 100644 --- a/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php +++ b/tests/Queue/migrations/2024_11_20_000000_create_failed_jobs_table.php @@ -14,12 +14,14 @@ public function up(): void { Schema::create('failed_jobs', function (Blueprint $table) { $table->id(); - $table->uuid('uuid')->nullable(); - $table->text('connection'); - $table->text('queue'); + $table->string('uuid')->nullable(); + $table->string('connection'); + $table->string('queue'); $table->longText('payload'); $table->longText('exception'); $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); }); } diff --git a/tests/Support/ClassMetadataCacheTest.php b/tests/Support/ClassMetadataCacheTest.php index 9c359455a6..af1766f34f 100644 --- a/tests/Support/ClassMetadataCacheTest.php +++ b/tests/Support/ClassMetadataCacheTest.php @@ -130,7 +130,20 @@ public function testConcreteClassAttributePresenceDoesNotWalkParentsOrTraits(): $classAttributePresence = $this->staticProperty('classAttributePresence'); $this->assertArrayHasKey(ClassMetadataCacheAttribute::class, $classAttributePresence[ClassMetadataCacheChildFixture::class]); - $this->assertFalse($classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class]); + $this->assertFalse($classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class][0]); + } + + public function testInheritedClassAttributePresenceIsCachedSeparately(): void + { + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class)); + $this->assertTrue(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class, ascend: true)); + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class)); + $this->assertFalse(ClassMetadataCache::hasClassAttribute(ClassMetadataCacheTraitFixture::class, ClassMetadataCacheAttribute::class, ascend: true)); + + $classAttributePresence = $this->staticProperty('classAttributePresence'); + + $this->assertSame([false, true], $classAttributePresence[ClassMetadataCacheChildFixture::class][ClassMetadataCacheAttribute::class]); + $this->assertFalse($classAttributePresence[ClassMetadataCacheTraitFixture::class][ClassMetadataCacheAttribute::class][1]); } public function testPropertyAttributePresenceIsCached(): void @@ -169,6 +182,7 @@ public function testFlushStateClearsCachedMetadata(): void ClassMetadataCache::getAttribute(ClassMetadataCacheAttributedFixture::class, ClassMetadataCacheAttribute::class); ClassMetadataCache::hasClassAttribute(ClassMetadataCacheParentFixture::class, ClassMetadataCacheAttribute::class); + ClassMetadataCache::hasClassAttribute(ClassMetadataCacheChildFixture::class, ClassMetadataCacheAttribute::class, ascend: true); ClassMetadataCache::flushState(); $this->assertSame([], $this->staticProperty('methods')); @@ -213,6 +227,9 @@ class ClassMetadataCacheFixture { public string $name = 'hypervel'; + /** + * Return a greeting. + */ public function greet(): string { return 'hello'; @@ -268,6 +285,9 @@ class ClassMetadataCacheErrorFixture #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheAttribute { + /** + * Create an attribute with the given value. + */ public function __construct( public string $value, ) { @@ -282,6 +302,9 @@ public function __construct( #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheExceptionAttribute { + /** + * Throw an exception while constructing the attribute. + */ public function __construct() { throw new RuntimeException('Cached as null.'); @@ -291,6 +314,9 @@ public function __construct() #[Attribute(Attribute::TARGET_CLASS)] readonly class ClassMetadataCacheErrorAttribute { + /** + * Throw an error while constructing the attribute. + */ public function __construct() { throw new Error('Uncached attribute error.'); diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index f9eda84440..bffab922fc 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -6,22 +6,23 @@ use Hypervel\Support\Number; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\RequiresPhpExtension; class SupportNumberTest extends TestCase { - public function testDefaultLocale() + public function testDefaultLocale(): void { $this->assertSame('en', Number::defaultLocale()); } - public function testDefaultCurrency() + public function testDefaultCurrency(): void { $this->assertSame('USD', Number::defaultCurrency()); } #[RequiresPhpExtension('intl')] - public function testFormat() + public function testFormat(): void { $this->assertSame('0', Number::format(0)); $this->assertSame('0', Number::format(0.0)); @@ -53,7 +54,7 @@ public function testFormat() } #[RequiresPhpExtension('intl')] - public function testFormatWithDifferentLocale() + public function testFormatWithDifferentLocale(): void { $this->assertSame('123,456,789', Number::format(123456789, locale: 'en')); $this->assertSame('123.456.789', Number::format(123456789, locale: 'de')); @@ -63,7 +64,7 @@ public function testFormatWithDifferentLocale() } #[RequiresPhpExtension('intl')] - public function testFormatWithAppLocale() + public function testFormatWithAppLocale(): void { $this->assertSame('123,456,789', Number::format(123456789)); @@ -75,20 +76,20 @@ public function testFormatWithAppLocale() } #[RequiresPhpExtension('intl')] - public function testSpellout() + public function testSpellout(): void { $this->assertSame('ten', Number::spell(10)); $this->assertSame('one point two', Number::spell(1.2)); } #[RequiresPhpExtension('intl')] - public function testSpelloutWithLocale() + public function testSpelloutWithLocale(): void { $this->assertSame('trois', Number::spell(3, 'fr')); } #[RequiresPhpExtension('intl')] - public function testSpelloutWithThreshold() + public function testSpelloutWithThreshold(): void { $this->assertSame('9', Number::spell(9, after: 10)); $this->assertSame('10', Number::spell(10, after: 10)); @@ -103,7 +104,7 @@ public function testSpelloutWithThreshold() } #[RequiresPhpExtension('intl')] - public function testOrdinal() + public function testOrdinal(): void { $this->assertSame('1st', Number::ordinal(1)); $this->assertSame('2nd', Number::ordinal(2)); @@ -111,7 +112,7 @@ public function testOrdinal() } #[RequiresPhpExtension('intl')] - public function testSpellOrdinal() + public function testSpellOrdinal(): void { $this->assertSame('first', Number::spellOrdinal(1)); $this->assertSame('second', Number::spellOrdinal(2)); @@ -119,7 +120,7 @@ public function testSpellOrdinal() } #[RequiresPhpExtension('intl')] - public function testToPercent() + public function testToPercent(): void { $this->assertSame('0%', Number::percentage(0, precision: 0)); $this->assertSame('0%', Number::percentage(0)); @@ -142,7 +143,7 @@ public function testToPercent() } #[RequiresPhpExtension('intl')] - public function testToCurrency() + public function testToCurrency(): void { $this->assertSame('$0.00', Number::currency(0)); $this->assertSame('$1.00', Number::currency(1)); @@ -162,7 +163,7 @@ public function testToCurrency() } #[RequiresPhpExtension('intl')] - public function testToCurrencyWithDifferentLocale() + public function testToCurrencyWithDifferentLocale(): void { $this->assertSame('1,00 €', Number::currency(1, 'EUR', 'de')); $this->assertSame('1,00 $', Number::currency(1, 'USD', 'de')); @@ -174,7 +175,7 @@ public function testToCurrencyWithDifferentLocale() } #[RequiresPhpExtension('intl')] - public function testBytesToHuman() + public function testBytesToHuman(): void { $this->assertSame('0 B', Number::fileSize(0)); $this->assertSame('0.00 B', Number::fileSize(0, precision: 2)); @@ -191,9 +192,19 @@ public function testBytesToHuman() $this->assertSame('1 ZB', Number::fileSize(1024 ** 7)); $this->assertSame('1 YB', Number::fileSize(1024 ** 8)); $this->assertSame('1,024 YB', Number::fileSize(1024 ** 9)); + + $this->assertSame('-1 B', Number::fileSize(-1)); + $this->assertSame('-2 KB', Number::fileSize(-2048)); + $this->assertSame('-2.00 KB', Number::fileSize(-2048, precision: 2)); + $this->assertSame('-1.23 KB', Number::fileSize(-1264, precision: 2)); + $this->assertSame('-5 GB', Number::fileSize(-1024 * 1024 * 1024 * 5)); + + $this->assertSame('∞ B', Number::fileSize(INF)); + $this->assertSame('-∞ B', Number::fileSize(-INF)); + $this->assertSame('NaN B', Number::fileSize(NAN)); } - public function testClamp() + public function testClamp(): void { $this->assertSame(2, Number::clamp(1, 2, 3)); $this->assertSame(3, Number::clamp(5, 2, 3)); @@ -203,7 +214,7 @@ public function testClamp() } #[RequiresPhpExtension('intl')] - public function testToHuman() + public function testToHuman(): void { $this->assertSame('1', Number::forHumans(1)); $this->assertSame('1.00', Number::forHumans(1, precision: 2)); @@ -257,10 +268,39 @@ public function testToHuman() $this->assertSame('-1.1 trillion', Number::forHumans(-1100000000000, maxPrecision: 1)); $this->assertSame('-1 quadrillion', Number::forHumans(-1000000000000000)); $this->assertSame('-1 thousand quadrillion', Number::forHumans(-1000000000000000000)); + + // A negative magnitude that rounds down to zero must not keep the sign. + $this->assertSame('0', Number::forHumans(-0.4)); + $this->assertSame('0', Number::forHumans(-0.05)); + $this->assertSame('0', Number::forHumans(-0.4999)); + $this->assertSame('-0.40', Number::forHumans(-0.4, precision: 2)); + + // Fractions with magnitude below 0.01 must not be scaled up by a negative display exponent. + $this->assertSame('0', Number::forHumans(0.005)); + $this->assertSame('0', Number::forHumans(0.001)); + $this->assertSame('0', Number::forHumans(0.009)); + $this->assertSame('0', Number::forHumans(-0.005)); + $this->assertSame('0.005', Number::forHumans(0.005, precision: 3)); + $this->assertSame('-0.005', Number::forHumans(-0.005, precision: 3)); + + // Request Arabic-Indic digits explicitly because ICU versions use different defaults for ar. + Number::withLocale('ar@numbers=arab', function () { + $this->assertSame('٠', Number::forHumans(0)); + $this->assertSame('٠', Number::forHumans(-0.004)); + $this->assertSame('٠', Number::forHumans(-0.004, maxPrecision: 2)); + }); + + $this->assertSame('999 thousand', Number::forHumans(999499)); + $this->assertSame('1 million', Number::forHumans(999500)); + $this->assertSame('1 million', Number::forHumans(999999)); + + $this->assertSame('∞', Number::forHumans(INF)); + $this->assertSame('-∞', Number::forHumans(-INF)); + $this->assertSame('NaN', Number::forHumans(NAN)); } #[RequiresPhpExtension('intl')] - public function testSummarize() + public function testSummarize(): void { $this->assertSame('1', Number::abbreviate(1)); $this->assertSame('1.00', Number::abbreviate(1, precision: 2)); @@ -314,9 +354,39 @@ public function testSummarize() $this->assertSame('-1.1T', Number::abbreviate(-1100000000000, maxPrecision: 1)); $this->assertSame('-1Q', Number::abbreviate(-1000000000000000)); $this->assertSame('-1KQ', Number::abbreviate(-1000000000000000000)); + + // A negative magnitude that rounds down to zero must not keep the sign. + $this->assertSame('0', Number::abbreviate(-0.4)); + $this->assertSame('0', Number::abbreviate(-0.05)); + + // Fractions with magnitude below 0.01 must not be scaled up by a negative display exponent. + $this->assertSame('0', Number::abbreviate(0.005)); + $this->assertSame('0', Number::abbreviate(0.001)); + $this->assertSame('0', Number::abbreviate(-0.005)); + $this->assertSame('0.005', Number::abbreviate(0.005, precision: 3)); + + // Request Arabic-Indic digits explicitly because ICU versions use different defaults for ar. + Number::withLocale('ar@numbers=arab', function () { + $this->assertSame('٠', Number::abbreviate(0)); + $this->assertSame('٠', Number::abbreviate(-0.004)); + $this->assertSame('٠', Number::abbreviate(-0.004, maxPrecision: 2)); + }); + + $this->assertSame('999K', Number::abbreviate(999499)); + $this->assertSame('1M', Number::abbreviate(999500)); + $this->assertSame('1M', Number::abbreviate(999999)); + $this->assertSame('1B', Number::abbreviate(999500000)); + $this->assertSame('1B', Number::abbreviate(999999999)); + + Number::withLocale('de', fn () => $this->assertSame('1M', Number::abbreviate(999500))); + Number::withLocale('fr', fn () => $this->assertSame('1M', Number::abbreviate(999500))); + + $this->assertSame('∞', Number::abbreviate(INF)); + $this->assertSame('-∞', Number::abbreviate(-INF)); + $this->assertSame('NaN', Number::abbreviate(NAN)); } - public function testPairs() + public function testPairs(): void { $this->assertSame([[0, 10], [10, 20], [20, 25]], Number::pairs(25, 10, 0, 0)); $this->assertSame([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10, 0, 1)); @@ -332,7 +402,19 @@ public function testPairs() $this->assertSame([[0.5, 2.5], [3.0, 5.0], [5.5, 7.5], [8.0, 10.0]], Number::pairs(10, 2.5, 0.5, 0.5)); } - public function testTrim() + public function testPairsThrowsWhenByIsZero(): void + { + $this->expectException(InvalidArgumentException::class); + + Number::pairs(100, 0); + } + + public function testPairsWithNegativeByWorksLikePositive(): void + { + $this->assertSame(Number::pairs(100, 10), Number::pairs(100, -10)); + } + + public function testTrim(): void { $this->assertSame(12, Number::trim(12)); $this->assertSame(120, Number::trim(120)); @@ -341,10 +423,13 @@ public function testTrim() $this->assertSame(12.3, Number::trim(12.30)); $this->assertSame(12.3456789, Number::trim(12.3456789)); $this->assertSame(12.3456789, Number::trim(12.34567890000)); + $this->assertSame(INF, Number::trim(INF)); + $this->assertSame(-INF, Number::trim(-INF)); + $this->assertNan(Number::trim(NAN)); } #[RequiresPhpExtension('intl')] - public function testParse() + public function testParse(): void { $this->assertSame(1234.0, Number::parse('1,234')); $this->assertSame(1234.5, Number::parse('1,234.5')); @@ -356,7 +441,7 @@ public function testParse() } #[RequiresPhpExtension('intl')] - public function testParseInt() + public function testParseInt(): void { $this->assertSame(1234, Number::parseInt('1,234')); $this->assertSame(1234, Number::parseInt('1,234.5')); @@ -367,7 +452,7 @@ public function testParseInt() } #[RequiresPhpExtension('intl')] - public function testParseFloat() + public function testParseFloat(): void { $this->assertSame(1234.0, Number::parseFloat('1,234')); $this->assertSame(1234.5, Number::parseFloat('1,234.5')); @@ -378,7 +463,7 @@ public function testParseFloat() $this->assertSame(1234.56, Number::parseFloat('1 234,56', locale: 'fr')); } - public function testFlushStateClearsMacros() + public function testFlushStateClearsMacros(): void { Number::macro('foo', fn () => 'bar'); $this->assertTrue(Number::hasMacro('foo')); diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index dcf720b815..4c23a347d7 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -472,6 +472,40 @@ public function testStrBeforeLast(): void $this->assertSame('yvette', Str::beforeLast("yvette\tyv0et0te", "\t")); } + public function testStringBoundariesRespectInternalEncoding(): void + { + $encoding = mb_internal_encoding(); + + try { + mb_internal_encoding('SJIS'); + + $subject = mb_convert_encoding('日本語と日本語', 'SJIS', 'UTF-8'); + $search = mb_convert_encoding('日本', 'SJIS', 'UTF-8'); + $wrapper = mb_convert_encoding('語', 'SJIS', 'UTF-8'); + $wrapped = mb_convert_encoding('語日本語', 'SJIS', 'UTF-8'); + + $this->assertSame(mb_convert_encoding('日本語と', 'SJIS', 'UTF-8'), Str::beforeLast($subject, $search)); + $this->assertSame($wrapper, Str::afterLast($subject, $search)); + $this->assertSame($search, Str::unwrap($wrapped, $wrapper)); + + // 表 ends with byte 0x5c in SJIS, but contains no backslash character. + $leadingSubject = mb_convert_encoding('表計算', 'SJIS', 'UTF-8'); + $trailingSubject = mb_convert_encoding('計算表', 'SJIS', 'UTF-8'); + + $this->assertSame($leadingSubject, Str::afterLast($leadingSubject, '\\')); + $this->assertSame($trailingSubject, Str::chopEnd($trailingSubject, '\\')); + $this->assertSame($trailingSubject, Str::unwrap($trailingSubject, '\\')); + $this->assertSame($trailingSubject, Str::chopEnd($trailingSubject . '\\', '\\')); + $this->assertSame($trailingSubject, Str::unwrap($trailingSubject . '\\', '\\')); + $this->assertSame( + mb_convert_encoding('計算', 'SJIS', 'UTF-8'), + Str::chopEnd($trailingSubject, ['\\', mb_convert_encoding('表', 'SJIS', 'UTF-8')]), + ); + } finally { + mb_internal_encoding($encoding); + } + } + public function testStrBetween(): void { $this->assertSame('abc', Str::between('abc', '', 'c')); @@ -1505,6 +1539,12 @@ public function testMask(): void $this->assertSame('maria@email.co*', Str::mask('maria@email.com', '*', -1)); $this->assertSame('***************', Str::mask('maria@email.com', '*', -15)); $this->assertSame('***************', Str::mask('maria@email.com', '*', 0)); + + // the trailing portion of the string must respect a non-default encoding + $latin1 = mb_convert_encoding('José Pérez García', 'ISO-8859-1', 'UTF-8'); + $expected = mb_convert_encoding('José ***** García', 'ISO-8859-1', 'UTF-8'); + $this->assertSame($expected, Str::mask($latin1, '*', 5, 5, 'ISO-8859-1')); + $this->assertSame($expected, Str::mask($latin1, '*', -12, 5, 'ISO-8859-1')); } public function testMatch(): void diff --git a/tests/Testing/Concerns/TestViewsTest.php b/tests/Testing/Concerns/TestViewsTest.php index 28b7e72c7c..7ee71316d2 100644 --- a/tests/Testing/Concerns/TestViewsTest.php +++ b/tests/Testing/Concerns/TestViewsTest.php @@ -8,7 +8,6 @@ use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Facades\Facade; -use Hypervel\Support\Facades\ParallelTesting as ParallelTestingFacade; use Hypervel\Testing\Concerns\TestViews; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; @@ -21,12 +20,24 @@ class TestViewsTest extends TestCase { private mixed $originalParallelTesting; + private string $tempDir; + + private Filesystem $filesystem; + + /** + * Create the isolated compiled-view directory and container bindings. + */ protected function setUp(): void { $this->originalParallelTesting = $_SERVER['HYPERVEL_PARALLEL_TESTING'] ?? null; parent::setUp(); + $this->filesystem = new Filesystem; + $this->tempDir = ParallelTesting::tempDir('TestViewsTest'); + $this->filesystem->deleteDirectory($this->tempDir); + $this->filesystem->ensureDirectoryExists($this->tempDir); + Container::setInstance($container = new Container); Facade::setFacadeApplication($container); @@ -38,14 +49,18 @@ protected function setUp(): void ])); $container->singleton(ParallelTesting::class, fn ($app) => new ParallelTesting($app)); + $container->instance('files', $this->filesystem); $_SERVER['HYPERVEL_PARALLEL_TESTING'] = 1; } + /** + * Remove the isolated compiled-view directory and restore the environment. + */ protected function tearDown(): void { - Container::setInstance(null); - ParallelTestingFacade::clearResolvedInstance(); + $this->filesystem->deleteDirectory($this->tempDir); + Facade::setFacadeApplication(null); if ($this->originalParallelTesting === null) { @@ -133,6 +148,9 @@ public function testSwitchToCompiledViewPathUpdatesCompilerCachePath(): void public function testTearDownProcessDeletesCompiledViewDirectory(): void { Container::getInstance()->make(ParallelTesting::class)->resolveTokenUsing(fn () => '7'); + Container::getInstance()->make('config')->set('view.compiled', $this->tempDir); + + $this->filesystem->put($this->tempDir . '/shared.php', 'shared view'); $instance = $this->makeTestViewsInstance(); @@ -143,8 +161,24 @@ public function testTearDownProcessDeletesCompiledViewDirectory(): void $tearDownCallbacks = (new ReflectionProperty($parallelTesting, 'tearDownProcessCallbacks'))->getValue($parallelTesting); $this->assertCount(1, $tearDownCallbacks); + + $parallelTesting->callSetUpProcessCallbacks(); + + $this->assertDirectoryExists($this->tempDir . '/test_7'); + + $this->filesystem->put($this->tempDir . '/test_7/compiled.php', 'compiled view'); + + $parallelTesting->callSetUpTestCaseCallbacks($this); + $parallelTesting->callTearDownProcessCallbacks(); + + $this->assertDirectoryDoesNotExist($this->tempDir . '/test_7'); + $this->assertDirectoryExists($this->tempDir); + $this->assertFileExists($this->tempDir . '/shared.php'); } + /** + * Get the compiled view path for the current process. + */ protected function getCompiledViewPath(): ?string { $instance = $this->makeTestViewsInstance(); @@ -154,6 +188,9 @@ protected function getCompiledViewPath(): ?string return $method->invoke($instance); } + /** + * Switch to the given compiled view path. + */ protected function switchToCompiledViewPath(string $path): void { $instance = $this->makeTestViewsInstance(); @@ -162,6 +199,9 @@ protected function switchToCompiledViewPath(string $path): void $method->invoke($instance, $path); } + /** + * Create a test views instance using the current container. + */ protected function makeTestViewsInstance(): object { return new class { @@ -169,6 +209,9 @@ protected function makeTestViewsInstance(): object public Container $app; + /** + * Create a new test views instance. + */ public function __construct() { $this->app = Container::getInstance(); diff --git a/tests/Translation/TranslationMessageSelectorTest.php b/tests/Translation/TranslationMessageSelectorTest.php index 750b4ee9d7..4466fccd73 100644 --- a/tests/Translation/TranslationMessageSelectorTest.php +++ b/tests/Translation/TranslationMessageSelectorTest.php @@ -20,6 +20,8 @@ public function testChoose(string $expected, string $id, float|int $number): voi } /** + * Provide translation choices. + * * @return array */ public static function chooseTestData(): array @@ -39,6 +41,7 @@ public static function chooseTestData(): array ['first', '{9}first|{10}second', 1], ['', '{0}|{1}second', 0], ['', '{0}first|{1}', 1], + ['second', '{1.3}first|{2.3}second', .3], ['first', '{1.3}first|{2.3}second', 1.3], ['second', '{1.3}first|{2.3}second', 2.3], ['first', '{1.}first|{2.}second', 1], @@ -113,6 +116,7 @@ public function testChooseWithFloatDoesNotTriggerDeprecation(): void }, E_DEPRECATED); try { + $this->assertSame('many', $selector->choose('{0} zero|{1} one|[2,*] many', 2.75, 'pl')); $this->assertSame('few', $selector->choose('one|few|many', 2.75, 'pl')); } finally { restore_error_handler(); @@ -123,6 +127,7 @@ public function testChoosePluralizesFloats(): void { $selector = new MessageSelector; + $this->assertSame('plural', $selector->choose('singular|plural', 0.5, 'en')); $this->assertSame('plural', $selector->choose('singular|plural', 1.5, 'en')); } } diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 3b7ed47f9d..0d6fa29d81 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -57,6 +57,11 @@ assertType('Hypervel\Support\LazyCollection', $lazy->random(2, true)); assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource)); +assertType('Hypervel\Support\Collection', $collection::make([1])->merge(['string'])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->merge([1])); +assertType('Hypervel\Support\LazyCollection', $lazy::make([1])->merge(['string'])); +assertType('Hypervel\Support\LazyCollection', $lazy::make(['string'])->merge([1])); + /** * Check shared enumerable return and callback types. * diff --git a/types/Http/Client/Response.php b/types/Http/Client/Response.php new file mode 100644 index 0000000000..c78890d576 --- /dev/null +++ b/types/Http/Client/Response.php @@ -0,0 +1,12 @@ + 'test', +]); + +assertType('TestEnum|null', $request->enum('key', TestEnum::class)); +assertType('TestEnum|TestEnum::Foo', $request->enum('key', TestEnum::class, TestEnum::Foo)); + +assertType('Hypervel\Routing\Route|null', $request->route()); +assertType('object|string|null', $request->route('key')); + +assertType('Symfony\Component\HttpFoundation\InputBag', $request->json()); +assertType('mixed', $request->json('key'));