diff --git a/src/bus/src/Events/BatchCanceled.php b/src/bus/src/Events/BatchCanceled.php index afe566f539..584088782a 100644 --- a/src/bus/src/Events/BatchCanceled.php +++ b/src/bus/src/Events/BatchCanceled.php @@ -11,6 +11,8 @@ class BatchCanceled { /** * Create a new event instance. + * + * @param null|Throwable $exception the exception that caused the cancellation */ public function __construct( public Batch $batch, diff --git a/src/cache/src/Console/CacheTableCommand.php b/src/cache/src/Console/CacheTableCommand.php index e836587ead..e53cba2c07 100644 --- a/src/cache/src/Console/CacheTableCommand.php +++ b/src/cache/src/Console/CacheTableCommand.php @@ -46,7 +46,7 @@ public function handle(): int $this->components->info('Migrations created successfully.'); - return 0; + return self::SUCCESS; } /** diff --git a/src/cache/src/Console/PruneDbExpiredCommand.php b/src/cache/src/Console/PruneDbExpiredCommand.php index 7f3c0da4df..ffe4ebb9f1 100644 --- a/src/cache/src/Console/PruneDbExpiredCommand.php +++ b/src/cache/src/Console/PruneDbExpiredCommand.php @@ -25,7 +25,7 @@ class PruneDbExpiredCommand extends Command /** * Execute the console command. */ - public function handle(): ?int + public function handle(): int { $store = $this->argument('store'); $cache = $this->hypervel->make('cache')->store($store); @@ -42,14 +42,14 @@ public function handle(): ?int $this->error("The cache store [{$store}] is not using the database driver."); } - return 1; + return self::FAILURE; } $deleted = $cache->getStore()->pruneExpired(); $this->info("Successfully pruned {$deleted} expired cache entries."); - return 0; + return self::SUCCESS; } /** diff --git a/src/cache/src/Console/PruneStaleTagsCommand.php b/src/cache/src/Console/PruneStaleTagsCommand.php index 8361af1792..b0002e1a0c 100644 --- a/src/cache/src/Console/PruneStaleTagsCommand.php +++ b/src/cache/src/Console/PruneStaleTagsCommand.php @@ -35,7 +35,7 @@ public function handle(): int if (! method_exists($store, 'flushStaleTags')) { $this->components->info('The selected cache store does not support pruning stale tags.'); - return 0; + return self::SUCCESS; } $stats = $store->flushStaleTags(); @@ -54,7 +54,7 @@ public function handle(): int $this->components->info('Stale cache tags pruned successfully.'); - return 0; + return self::SUCCESS; } /** diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index 2bff323b8b..90175fc493 100644 --- a/src/collections/src/Arr.php +++ b/src/collections/src/Arr.php @@ -111,7 +111,7 @@ public static function collapse(iterable $array): array $results = []; foreach ($array as $values) { - if ($values instanceof Collection) { + if ($values instanceof Enumerable) { $results[] = $values->all(); } elseif (is_array($values)) { $results[] = $values; @@ -364,7 +364,7 @@ public static function flatten(iterable $array, float $depth = INF): array $result = []; foreach ($array as $item) { - $item = $item instanceof Collection ? $item->all() : $item; + $item = $item instanceof Enumerable ? $item->all() : $item; if (! is_array($item)) { $result[] = $item; @@ -687,6 +687,11 @@ public static function keyBy(iterable $array, callable|array|string $keyBy): arr /** * Prepend the key names of an associative array. + * + * @template TValue + * + * @param array $array + * @return array */ public static function prependKeysWith(array $array, string $prependWith): array { @@ -834,11 +839,9 @@ public static function mapWithKeys(array $array, callable $callback): array * Run a map over each nested chunk of items. * * @template TKey - * @template TValue * * @param array $array - * @param callable(mixed...): TValue $callback - * @return array + * @return array */ public static function mapSpread(array $array, callable $callback): array { @@ -1024,7 +1027,7 @@ public static function sole(array $array, ?callable $callback = null): mixed * @template TValue * * @param iterable $array - * @param null|array|callable|int|string $callback + * @param null|array|(callable(TValue, TKey): mixed)|int|string $callback * @return array */ public static function sort(iterable $array, callable|array|int|string|null $callback = null): array @@ -1045,7 +1048,7 @@ public static function sort(iterable $array, callable|array|int|string|null $cal * @template TValue * * @param iterable $array - * @param null|array|callable|int|string $callback + * @param null|array|(callable(TValue, TKey): mixed)|int|string $callback * @return array */ public static function sortDesc(iterable $array, callable|array|int|string|null $callback = null): array @@ -1128,8 +1131,7 @@ public static function string(ArrayAccess|array $array, string|int|null $key, ?s /** * Conditionally compile classes from an array into a CSS class list. * - * @param array|array|string $array - * @return ($array is array ? '' : ($array is '' ? '' : ($array is array{} ? '' : non-empty-string))) + * @param array|string $array */ public static function toCssClasses(array|string $array): string { @@ -1151,8 +1153,7 @@ public static function toCssClasses(array|string $array): string /** * Conditionally compile styles from an array into a style list. * - * @param array|array|string $array - * @return ($array is array ? '' : ($array is '' ? '' : ($array is array{} ? '' : non-empty-string))) + * @param array|string $array */ public static function toCssStyles(array|string $array): string { diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index d5ef7b19b1..765077b99a 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -179,7 +179,7 @@ public function collapseWithKeys(): static $results = []; foreach ($this->items as $key => $values) { - if ($values instanceof Collection) { + if ($values instanceof Enumerable) { $values = $values->all(); } elseif (! is_array($values)) { continue; @@ -511,15 +511,17 @@ public function getOrPut(mixed $key, mixed $value): mixed * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy - * @return static< - * ($groupBy is (array|string) - * ? array-key - * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), - * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> - * > + * @return ($groupBy is array + * ? Collection>|static> + * : static< + * ($groupBy is string + * ? array-key + * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), + * static<($preserveKeys is true ? TKey : int), TValue> + * >) */ #[Override] - public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static + public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): Collection|static { if (! $this->useAsCallable($groupBy) && is_array($groupBy)) { $nextGroups = $groupBy; @@ -558,7 +560,6 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal $result = $this->newInstance($results); if (! empty($nextGroups)) { - // @phpstan-ignore return.type (recursive groupBy returns Enumerable, PHPStan can't verify it matches static) return $result->map->groupBy($nextGroups, $preserveKeys); } @@ -1244,11 +1245,11 @@ public function shuffle(): static * * @param positive-int $size * @param positive-int $step - * @return static + * @return Collection|static * * @throws InvalidArgumentException */ - public function sliding(int $size = 2, int $step = 1): static + public function sliding(int $size = 2, int $step = 1): Collection|static { if ($size < 1) { throw new InvalidArgumentException('Size value must be at least 1.'); @@ -1508,7 +1509,7 @@ public function sortDesc(int $options = SORT_REGULAR): static /** * Sort the collection using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|int|string $callback + * @param array|(callable(TValue, TKey): mixed)|int|string $callback */ public function sortBy(callable|array|int|string $callback, int $options = SORT_REGULAR, SortDirection|bool $descending = false): static { @@ -1545,7 +1546,7 @@ public function sortBy(callable|array|int|string $callback, int $options = SORT_ /** * Sort the collection using multiple comparisons. * - * @param array $comparisons + * @param array $comparisons */ protected function sortByMany(array $comparisons = [], int $options = SORT_REGULAR): static { @@ -1603,7 +1604,7 @@ protected function sortByMany(array $comparisons = [], int $options = SORT_REGUL /** * Sort the collection in descending order using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|int|string $callback + * @param array|(callable(TValue, TKey): mixed)|int|string $callback */ public function sortByDesc(callable|array|int|string $callback, int $options = SORT_REGULAR): static { diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index 3fa328bc9e..5717ba4dde 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -50,9 +50,9 @@ public static function make(Arrayable|iterable|null $items = []): static; * @template TTimesValue * * @param null|(callable(int): TTimesValue) $callback - * @return ($callback is null ? static : static) + * @return ($callback is null ? static : Collection|static) */ - public static function times(int $number, ?callable $callback = null): static; + public static function times(int $number, ?callable $callback = null): Collection|static; /** * Create a collection with the given range. @@ -440,14 +440,16 @@ public function get(mixed $key, mixed $default = null): mixed; * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy - * @return static< - * ($groupBy is (array|string) - * ? array-key - * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), - * Collection<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> - * > + * @return ($groupBy is array + * ? Collection>|static> + * : static< + * ($groupBy is string + * ? array-key + * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), + * Collection<($preserveKeys is true ? TKey : int), TValue> + * >) */ - public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static; + public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): Collection|static; /** * Key an associative array by a field or using a callback. @@ -576,8 +578,10 @@ public function map(callable $callback): Collection|static; /** * Run a map over each nested chunk of items. + * + * @return Collection|static */ - public function mapSpread(callable $callback): static; + public function mapSpread(callable $callback): Collection|static; /** * Run a dictionary map over the items. @@ -601,9 +605,9 @@ public function mapToDictionary(callable $callback): static; * @template TMapToGroupsValue * * @param callable(TValue, TKey): array $callback - * @return static> + * @return Collection>|static> */ - public function mapToGroups(callable $callback): static; + public function mapToGroups(callable $callback): Collection|static; /** * Run an associative map over each of the items. @@ -624,7 +628,7 @@ public function mapWithKeys(callable $callback): Collection|static; * @template TFlatMapKey of array-key * @template TFlatMapValue * - * @param callable(TValue, TKey): (array|Collection) $callback + * @param callable(TValue, TKey): (array|Enumerable) $callback * @return static */ public function flatMap(callable $callback): Collection|static; @@ -823,9 +827,9 @@ public function shuffle(): static; /** * Create chunks representing a "sliding window" view of the items in the collection. * - * @return static + * @return Collection|static */ - public function sliding(int $size = 2, int $step = 1): static; + public function sliding(int $size = 2, int $step = 1): Collection|static; /** * Skip the first {$count} items. @@ -894,6 +898,14 @@ public function chunk(int $size): static; */ public function chunkWhile(callable $callback): static; + /** + * Chunk the collection into chunks by comparing adjacent values using the given key or callback. + * + * @param (callable(TValue, TKey): mixed)|string $key + * @return static> + */ + public function chunkBy(callable|string $key): static; + /** * Split a collection into a certain number of groups, and fill the first groups completely. * @@ -918,7 +930,7 @@ public function sortDesc(int $options = SORT_REGULAR): static; /** * Sort the collection using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|int|string $callback + * @param array|(callable(TValue, TKey): mixed)|int|string $callback * @param int-mask-of $options */ public function sortBy(array|callable|int|string $callback, int $options = SORT_REGULAR, SortDirection|bool $descending = false): static; @@ -926,7 +938,7 @@ public function sortBy(array|callable|int|string $callback, int $options = SORT_ /** * Sort the collection in descending order using the given callback. * - * @param array|(callable(TValue, TKey): mixed)|int|string $callback + * @param array|(callable(TValue, TKey): mixed)|int|string $callback * @param int-mask-of $options */ public function sortByDesc(array|callable|int|string $callback, int $options = SORT_REGULAR): static; @@ -984,7 +996,7 @@ public function takeWhile(mixed $value): static; /** * Pass the collection to the given callback and then return it. * - * @param callable(TValue): mixed $callback + * @param callable($this): mixed $callback */ public function tap(callable $callback): static; diff --git a/src/collections/src/HigherOrderCollectionProxy.php b/src/collections/src/HigherOrderCollectionProxy.php index eb40911011..80338f4790 100644 --- a/src/collections/src/HigherOrderCollectionProxy.php +++ b/src/collections/src/HigherOrderCollectionProxy.php @@ -5,11 +5,10 @@ namespace Hypervel\Support; /** - * @template TKey of array-key + * @template TMethod of string + * @template TValue + * @template TCollection of Enumerable * - * @template-covariant TValue - * - * @mixin \Hypervel\Support\Enumerable * @mixin TValue */ class HigherOrderCollectionProxy @@ -17,7 +16,8 @@ class HigherOrderCollectionProxy /** * Create a new proxy instance. * - * @param \Hypervel\Support\Enumerable $collection + * @param TCollection $collection + * @param TMethod $method */ public function __construct( protected Enumerable $collection, diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 07186de265..acc9a9ebc1 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -5,6 +5,7 @@ namespace Hypervel\Support; use ArrayIterator; +use BackedEnum; use Closure; use DateInterval; use DateTimeImmutable; @@ -22,6 +23,7 @@ use Override; use SortDirection; use stdClass; +use Stringable as BaseStringable; use Traversable; use UnitEnum; @@ -495,7 +497,7 @@ public function flatten(int|float $depth = INF): static foreach ($this as $item) { if (! is_array($item) && ! $item instanceof Enumerable) { yield $item; - } elseif ($depth === 1) { + } elseif ((float) $depth === 1.0) { yield from $item; } else { yield from $this->newInstance($item)->flatten($depth - 1); @@ -547,6 +549,18 @@ public function get(mixed $key, mixed $default = null): mixed /** * Group an associative array by a field or using a callback. + * + * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy + * @return ($groupBy is array + * ? static> + * : static< + * ($groupBy is string + * ? array-key + * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), + * Collection<($preserveKeys is true ? TKey : int), TValue> + * >) */ #[Override] public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static @@ -760,6 +774,18 @@ public function map(callable $callback): static }); } + /** + * Run a map over each nested chunk of items. + * + * @return static + */ + public function mapSpread(callable $callback): static + { + return $this->map(function ($chunk, $key) use ($callback) { + return $callback(...[...$chunk, $key]); + }); + } + #[Override] public function mapToDictionary(callable $callback): static { @@ -767,6 +793,24 @@ public function mapToDictionary(callable $callback): static return $this->passthru(__FUNCTION__, func_get_args()); } + /** + * Run a grouping map over the items. + * + * The callback should return an associative array with a single key/value pair. + * + * @template TMapToGroupsKey of array-key + * @template TMapToGroupsValue + * + * @param callable(TValue, TKey): array $callback + * @return static> + */ + public function mapToGroups(callable $callback): static + { + $groups = $this->mapToDictionary($callback); + + return $groups->map($this->make(...)); + } + /** * Run an associative map over each of the items. * @@ -787,6 +831,37 @@ public function mapWithKeys(callable $callback): static }); } + /** + * Map a collection and flatten the result by a single level. + * + * @template TFlatMapKey of array-key + * @template TFlatMapValue + * + * @param callable(TValue, TKey): (array|Enumerable) $callback + * @return static + */ + public function flatMap(callable $callback): static + { + return $this->map($callback)->collapse(); + } + + /** + * Map the values into a new class. + * + * @template TMapIntoValue + * + * @param class-string $class + * @return static + */ + public function mapInto(string $class): static + { + if (is_subclass_of($class, BackedEnum::class)) { + return $this->map(fn ($value, $key) => enum_from($class, $value)); + } + + return $this->map(fn ($value, $key) => new $class($value, $key)); + } + #[Override] public function merge(mixed $items): static { @@ -951,6 +1026,24 @@ public function select(mixed $keys): static }); } + /** + * Partition the collection into two arrays using the given callback or key. + * + * @param (callable(TValue, TKey): bool)|string|TValue $key + * @return static, static> + */ + public function partition(mixed $key, mixed $operator = null, mixed $value = null): static + { + $callback = func_num_args() === 1 + ? $this->valueRetriever($key) + : $this->operatorForWhere(...func_get_args()); + + [$passed, $failed] = Arr::partition($this->getIterator(), $callback); + + // @phpstan-ignore return.type (returns exactly 2 elements with keys 0,1 but PHPStan infers int) + return $this->newInstance([$this->newInstance($passed), $this->newInstance($failed)]); + } + /** * Push all of the given items onto the collection. * diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 6ce157a19a..4eb6edf992 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -28,38 +28,38 @@ * * @template-covariant TValue * - * @property-read HigherOrderCollectionProxy $average - * @property-read HigherOrderCollectionProxy $avg - * @property-read HigherOrderCollectionProxy $contains - * @property-read HigherOrderCollectionProxy $doesntContain - * @property-read HigherOrderCollectionProxy $each - * @property-read HigherOrderCollectionProxy $every - * @property-read HigherOrderCollectionProxy $filter - * @property-read HigherOrderCollectionProxy $first - * @property-read HigherOrderCollectionProxy $flatMap - * @property-read HigherOrderCollectionProxy $groupBy - * @property-read HigherOrderCollectionProxy $hasMany - * @property-read HigherOrderCollectionProxy $hasSole - * @property-read HigherOrderCollectionProxy $keyBy - * @property-read HigherOrderCollectionProxy $last - * @property-read HigherOrderCollectionProxy $map - * @property-read HigherOrderCollectionProxy $max - * @property-read HigherOrderCollectionProxy $min - * @property-read HigherOrderCollectionProxy $partition - * @property-read HigherOrderCollectionProxy $percentage - * @property-read HigherOrderCollectionProxy $reject - * @property-read HigherOrderCollectionProxy $skipUntil - * @property-read HigherOrderCollectionProxy $skipWhile - * @property-read HigherOrderCollectionProxy $some - * @property-read HigherOrderCollectionProxy $sortBy - * @property-read HigherOrderCollectionProxy $sortByDesc - * @property-read HigherOrderCollectionProxy $sum - * @property-read HigherOrderCollectionProxy $takeUntil - * @property-read HigherOrderCollectionProxy $takeWhile - * @property-read HigherOrderCollectionProxy $unique - * @property-read HigherOrderCollectionProxy $unless - * @property-read HigherOrderCollectionProxy $until - * @property-read HigherOrderCollectionProxy $when + * @property-read HigherOrderCollectionProxy<'average', TValue, static> $average + * @property-read HigherOrderCollectionProxy<'avg', TValue, static> $avg + * @property-read HigherOrderCollectionProxy<'contains', TValue, static> $contains + * @property-read HigherOrderCollectionProxy<'doesntContain', TValue, static> $doesntContain + * @property-read HigherOrderCollectionProxy<'each', TValue, static> $each + * @property-read HigherOrderCollectionProxy<'every', TValue, static> $every + * @property-read HigherOrderCollectionProxy<'filter', TValue, static> $filter + * @property-read HigherOrderCollectionProxy<'first', TValue, static> $first + * @property-read HigherOrderCollectionProxy<'flatMap', TValue, static> $flatMap + * @property-read HigherOrderCollectionProxy<'groupBy', TValue, static> $groupBy + * @property-read HigherOrderCollectionProxy<'hasMany', TValue, static> $hasMany + * @property-read HigherOrderCollectionProxy<'hasSole', TValue, static> $hasSole + * @property-read HigherOrderCollectionProxy<'keyBy', TValue, static> $keyBy + * @property-read HigherOrderCollectionProxy<'last', TValue, static> $last + * @property-read HigherOrderCollectionProxy<'map', TValue, static> $map + * @property-read HigherOrderCollectionProxy<'max', TValue, static> $max + * @property-read HigherOrderCollectionProxy<'min', TValue, static> $min + * @property-read HigherOrderCollectionProxy<'partition', TValue, static> $partition + * @property-read HigherOrderCollectionProxy<'percentage', TValue, static> $percentage + * @property-read HigherOrderCollectionProxy<'reject', TValue, static> $reject + * @property-read HigherOrderCollectionProxy<'skipUntil', TValue, static> $skipUntil + * @property-read HigherOrderCollectionProxy<'skipWhile', TValue, static> $skipWhile + * @property-read HigherOrderCollectionProxy<'some', TValue, static> $some + * @property-read HigherOrderCollectionProxy<'sortBy', TValue, static> $sortBy + * @property-read HigherOrderCollectionProxy<'sortByDesc', TValue, static> $sortByDesc + * @property-read HigherOrderCollectionProxy<'sum', TValue, static> $sum + * @property-read HigherOrderCollectionProxy<'takeUntil', TValue, static> $takeUntil + * @property-read HigherOrderCollectionProxy<'takeWhile', TValue, static> $takeWhile + * @property-read HigherOrderCollectionProxy<'unique', TValue, static> $unique + * @property-read HigherOrderCollectionProxy<'unless', TValue, static> $unless + * @property-read HigherOrderCollectionProxy<'until', TValue, static> $until + * @property-read HigherOrderCollectionProxy<'when', TValue, static> $when */ trait EnumeratesValues { @@ -147,11 +147,12 @@ public static function wrap(mixed $value, mixed ...$args): static /** * Get the underlying items from the given collection if applicable. * - * @template TUnwrapKey of array-key - * @template TUnwrapValue + * @template TUnwrapKey of array-key = array-key + * @template TUnwrapValue = mixed + * @template TUnwrapInput = mixed * - * @param array|static|TUnwrapValue $value - * @return (array|TUnwrapValue) + * @param array|Enumerable|TUnwrapInput $value + * @return ($value is array|Enumerable ? array : TUnwrapInput) */ public static function unwrap(mixed $value): mixed { @@ -172,9 +173,9 @@ public static function empty(mixed ...$args): static * @template TTimesValue * * @param null|(callable(int): TTimesValue) $callback - * @return ($callback is null ? static : static) + * @return ($callback is null ? static : Collection|static) */ - public static function times(int $number, ?callable $callback = null, mixed ...$args): static + public static function times(int $number, ?callable $callback = null, mixed ...$args): Collection|static { if ($number < 1) { return new static([], ...$args); @@ -273,15 +274,11 @@ public function each(callable $callback): static /** * Execute a callback over each nested chunk of items. - * - * @param callable(mixed...): mixed $callback */ public function eachSpread(callable $callback): static { return $this->each(function ($chunk, $key) use ($callback) { - $chunk[] = $key; - - return $callback(...$chunk); + return $callback(...[...$chunk, $key]); }); } @@ -399,17 +396,12 @@ public function isNotEmpty(): bool /** * Run a map over each nested chunk of items. * - * @template TMapSpreadValue - * - * @param callable(mixed...): TMapSpreadValue $callback - * @return static + * @return Collection|static */ - public function mapSpread(callable $callback): static + public function mapSpread(callable $callback): Collection|static { return $this->map(function ($chunk, $key) use ($callback) { - $chunk[] = $key; - - return $callback(...$chunk); + return $callback(...[...$chunk, $key]); }); } @@ -422,9 +414,9 @@ public function mapSpread(callable $callback): static * @template TMapToGroupsValue * * @param callable(TValue, TKey): array $callback - * @return static> + * @return Collection>|static> */ - public function mapToGroups(callable $callback): static + public function mapToGroups(callable $callback): Collection|static { $groups = $this->mapToDictionary($callback); @@ -437,7 +429,7 @@ public function mapToGroups(callable $callback): static * @template TFlatMapKey of array-key * @template TFlatMapValue * - * @param callable(TValue, TKey): (array|Collection) $callback + * @param callable(TValue, TKey): (array|Enumerable) $callback * @return Collection|static */ public function flatMap(callable $callback): Collection|static @@ -671,6 +663,8 @@ public function whereInStrict(string $key, Arrayable|iterable $values): static */ public function whereBetween(string $key, Arrayable|iterable $values): static { + $values = $this->getArrayableItems($values); + return $this->where($key, '>=', reset($values))->where($key, '<=', end($values)); } @@ -679,9 +673,15 @@ public function whereBetween(string $key, Arrayable|iterable $values): static */ public function whereNotBetween(string $key, Arrayable|iterable $values): static { - return $this->filter( - fn ($item) => data_get($item, $key) < reset($values) || data_get($item, $key) > end($values) - ); + $values = $this->getArrayableItems($values); + $minimum = reset($values); + $maximum = end($values); + + return $this->filter(function ($item) use ($key, $minimum, $maximum) { + $retrieved = data_get($item, $key); + + return $retrieved < $minimum || $retrieved > $maximum; + }); } /** @@ -861,6 +861,22 @@ public function reject(mixed $callback = true): static }); } + /** + * Chunk the collection into chunks by comparing adjacent values using the given key or callback. + * + * @param (callable(TValue, TKey): mixed)|string $key + * @return static> + */ + public function chunkBy(callable|string $key): static + { + $callback = $this->valueRetriever($key); + + // Read the last key without rebuilding the growing chunk's entire key list. + return $this->chunkWhile( + fn ($value, $key, $chunk) => $callback($value, $key) == $callback($chunk->last(), array_key_last($chunk->all())) + ); + } + /** * Pass the collection to the given callback and then return it. * diff --git a/src/console/src/Application.php b/src/console/src/Application.php index aac0b2d2ad..32d59f7f52 100644 --- a/src/console/src/Application.php +++ b/src/console/src/Application.php @@ -52,12 +52,14 @@ class Application extends SymfonyApplication implements ConsoleApplicationContra /** * The console application bootstrappers. * - * @var array + * @var array */ protected static array $bootstrappers = []; /** * A map of command names to classes. + * + * @var array> */ protected array $commandMap = []; @@ -172,7 +174,7 @@ public static function formatCommandString(string $string): string * Boot-only. The bootstrapper persists in a static property for the worker * lifetime and runs for every subsequent console application instance. * - * @param Closure(static): void $callback + * @param Closure(static): mixed $callback */ public static function starting(Closure $callback): void { @@ -287,6 +289,8 @@ protected function configureProgrammaticIO(InputInterface $input, OutputInterfac /** * Parse the incoming Artisan command and its input. + * + * @return array{?string, ArrayInput|StringInput} */ protected function parseCommand(string|SymfonyCommand $command, array $parameters): array { diff --git a/src/console/src/Concerns/DisableEventDispatcher.php b/src/console/src/Concerns/DisableEventDispatcher.php index 9f88c62ba8..90a4863e62 100644 --- a/src/console/src/Concerns/DisableEventDispatcher.php +++ b/src/console/src/Concerns/DisableEventDispatcher.php @@ -16,7 +16,7 @@ trait DisableEventDispatcher */ public function addDisableDispatcherOption(): void { - $this->addOption('disable-event-dispatcher', null, InputOption::VALUE_NONE, 'Whether disable event dispatcher.'); + $this->addOption('disable-event-dispatcher', null, InputOption::VALUE_NONE, 'Disable the event dispatcher'); } /** diff --git a/src/console/src/Concerns/HasParameters.php b/src/console/src/Concerns/HasParameters.php index f80c561830..748561282b 100644 --- a/src/console/src/Concerns/HasParameters.php +++ b/src/console/src/Concerns/HasParameters.php @@ -4,6 +4,9 @@ namespace Hypervel\Console\Concerns; +use Closure; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\Suggestion; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -36,6 +39,14 @@ protected function specifyParameters(): void /** * Get the console command arguments. + * + * @return (array{ + * 0: non-empty-string, + * 1?: null|int-mask-of, + * 2?: string, + * 3?: mixed, + * 4?: Closure(CompletionInput): list|list + * }|InputArgument)[] */ protected function getArguments(): array { @@ -44,6 +55,15 @@ protected function getArguments(): array /** * Get the console command options. + * + * @return (array{ + * 0: non-empty-string, + * 1?: null|non-empty-array|string, + * 2?: null|int-mask-of, + * 3?: string, + * 4?: mixed, + * 5?: Closure(CompletionInput): list|list + * }|InputOption)[] */ protected function getOptions(): array { diff --git a/src/console/src/Concerns/InteractsWithIO.php b/src/console/src/Concerns/InteractsWithIO.php index d293efbcb5..3a57f3a295 100644 --- a/src/console/src/Concerns/InteractsWithIO.php +++ b/src/console/src/Concerns/InteractsWithIO.php @@ -10,7 +10,9 @@ use Hypervel\Console\View\Components\Factory; use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Str; +use Stringable; use Symfony\Component\Console\Formatter\OutputFormatterStyle; +use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableStyle; use Symfony\Component\Console\Input\InputInterface; @@ -25,8 +27,14 @@ trait InteractsWithIO */ protected ?Factory $components = null; + /** + * The input interface implementation. + */ protected ?InputInterface $input = null; + /** + * The output interface implementation. + */ protected ?OutputStyle $output = null; /** @@ -36,6 +44,8 @@ trait InteractsWithIO /** * The mapping between human-readable verbosity levels and Symfony's OutputInterface. + * + * @var array */ protected array $verbosityMap = [ 'v' => OutputInterface::VERBOSITY_VERBOSE, @@ -58,7 +68,7 @@ public function input(?string $key = null, mixed $default = null): mixed } /** - * Determine if the given argument is present. + * Determine whether the argument is defined in the command signature. */ public function hasArgument(int|string $name): bool { @@ -67,6 +77,8 @@ public function hasArgument(int|string $name): bool /** * Get the value of a command argument. + * + * @return ($key is null ? array : mixed) */ public function argument(?string $key = null): mixed { @@ -86,7 +98,7 @@ public function arguments(): array } /** - * Determine if the given option is present. + * Determine whether the option is defined in the command signature. */ public function hasOption(string $name): bool { @@ -95,6 +107,8 @@ public function hasOption(string $name): bool /** * Get the value of a command option. + * + * @return ($key is null ? array : mixed) */ public function option(?string $key = null): mixed { @@ -131,6 +145,8 @@ public function ask(string $question, ?string $default = null): mixed /** * Prompt the user for input with auto completion. + * + * @param (callable(string): string[])|iterable $choices */ public function anticipate(string $question, iterable|callable $choices, ?string $default = null): mixed { @@ -139,6 +155,8 @@ public function anticipate(string $question, iterable|callable $choices, ?string /** * Prompt the user for input with auto completion. + * + * @param (callable(string): string[])|iterable $choices */ public function askWithCompletion(string $question, iterable|callable $choices, ?string $default = null): mixed { @@ -165,6 +183,9 @@ public function secret(string $question, bool $fallback = true): mixed /** * Give the user a single choice from an array of answers. + * + * @param array $choices + * @param null|positive-int $attempts */ public function choice(string $question, array $choices, int|string|null $default = null, ?int $attempts = null, bool $multiple = false): array|string { @@ -177,6 +198,8 @@ public function choice(string $question, array $choices, int|string|null $defaul /** * Format input to textual table. + * + * @param array $columnStyles */ public function table(array $headers, array|Arrayable $rows, string|TableStyle $tableStyle = 'default', array $columnStyles = []): void { @@ -197,6 +220,14 @@ public function table(array $headers, array|Arrayable $rows, string|TableStyle $ /** * Execute a given callback while advancing a progress bar. + * + * @template TKey of array-key + * @template TValue + * @template TIterable of iterable + * + * @param int|TIterable $totalSteps + * @param Closure(ProgressBar): mixed|Closure(TValue, ProgressBar, TKey): mixed $callback + * @return ($totalSteps is iterable ? TIterable : null) */ public function withProgressBar(iterable|int $totalSteps, Closure $callback): mixed { diff --git a/src/console/src/Concerns/InteractsWithSignals.php b/src/console/src/Concerns/InteractsWithSignals.php index c18ed8541d..258ba20b50 100644 --- a/src/console/src/Concerns/InteractsWithSignals.php +++ b/src/console/src/Concerns/InteractsWithSignals.php @@ -9,13 +9,16 @@ trait InteractsWithSignals { + /** + * The signal registry instance. + */ protected ?SignalRegistry $signalRegistry = null; /** * Define a callback to be run when the given signal(s) occurs. * * @param int|int[] $signo - * @param (callable(int $signo): void) $callback + * @param (callable(int $signo): mixed) $callback */ public function trap(array|int $signo, callable $callback): void { diff --git a/src/console/src/ConfirmableTrait.php b/src/console/src/ConfirmableTrait.php index bbcc3e4020..ae791ac4a7 100644 --- a/src/console/src/ConfirmableTrait.php +++ b/src/console/src/ConfirmableTrait.php @@ -15,6 +15,11 @@ trait ConfirmableTrait * Confirm before proceeding with the action. * * This method only asks for confirmation in production. + * + * @template TReturn of bool = bool + * + * @param null|(Closure(): TReturn)|TReturn $callback + * @return (TReturn is false ? true : bool) */ public function confirmToProceed(string $warning = 'Application In Production', bool|Closure|null $callback = null): bool { @@ -43,6 +48,8 @@ public function confirmToProceed(string $warning = 'Application In Production', /** * Get the default confirmation callback. + * + * @return Closure(): bool */ protected function getDefaultConfirmCallback(): Closure { diff --git a/src/console/src/ContainerCommandLoader.php b/src/console/src/ContainerCommandLoader.php index 8dc9b2bb47..e18e6013da 100644 --- a/src/console/src/ContainerCommandLoader.php +++ b/src/console/src/ContainerCommandLoader.php @@ -13,6 +13,8 @@ class ContainerCommandLoader implements CommandLoaderInterface { /** * Create a new command loader instance. + * + * @param array $commandMap */ public function __construct( protected Container $container, diff --git a/src/console/src/MigrationGeneratorCommand.php b/src/console/src/MigrationGeneratorCommand.php index f3d287ae1d..57b722bf3c 100644 --- a/src/console/src/MigrationGeneratorCommand.php +++ b/src/console/src/MigrationGeneratorCommand.php @@ -40,14 +40,14 @@ public function handle(): int if ($this->migrationExists($table)) { $this->components->error('Migration already exists.'); - return 1; + return self::FAILURE; } $this->createBaseMigration($table); $this->components->info('Migration created successfully.'); - return 0; + return self::SUCCESS; } /** diff --git a/src/console/src/Parser.php b/src/console/src/Parser.php index b93fec1eae..a3385bc3b0 100644 --- a/src/console/src/Parser.php +++ b/src/console/src/Parser.php @@ -13,6 +13,8 @@ class Parser /** * Parse the given console command definition into an array. * + * @return array{string, InputArgument[], InputOption[]} + * * @throws InvalidArgumentException */ public static function parse(string $expression): array @@ -42,6 +44,9 @@ protected static function name(string $expression): string /** * Extract all the parameters from the tokens. + * + * @param string[] $tokens + * @return array{InputArgument[], InputOption[]} */ protected static function parameters(array $tokens): array { @@ -104,6 +109,8 @@ protected static function parseOption(string $token): InputOption /** * Parse the token into its token and description segments. + * + * @return array{string, string} */ protected static function extractDescription(string $token): array { diff --git a/src/console/src/SignalRegistry.php b/src/console/src/SignalRegistry.php index f92d176d42..a3557b1a76 100644 --- a/src/console/src/SignalRegistry.php +++ b/src/console/src/SignalRegistry.php @@ -15,15 +15,22 @@ class SignalRegistry { /** - * @var array + * The callbacks registered for each signal. + * + * @var array> */ protected array $signalHandlers = []; /** - * @var int[] + * The waiting coroutine ID for each signal. + * + * @var array */ protected array $handling = []; + /** + * Create a new signal registry. + */ public function __construct( protected int $timeout = 1, protected int $concurrentLimit = 0, @@ -34,7 +41,7 @@ public function __construct( * Register a signal handler for one or more signals. * * @param int|int[] $signo - * @param (callable(int $signo): void) $signalHandler + * @param (callable(int $signo): mixed) $signalHandler */ public function register(int|array $signo, callable $signalHandler): void { @@ -101,7 +108,7 @@ public function unregister(int|array|null $signo = null): void /** * Add a signal handler to the stack for the given signal. * - * @param (callable(int $signo): void) $signalHandler + * @param (callable(int $signo): mixed) $signalHandler */ protected function pushSignalHandler(int $signo, callable $signalHandler): void { diff --git a/src/contracts/src/Foundation/MaintenanceMode.php b/src/contracts/src/Foundation/MaintenanceMode.php index 11ccfef906..7dabf6eaab 100644 --- a/src/contracts/src/Foundation/MaintenanceMode.php +++ b/src/contracts/src/Foundation/MaintenanceMode.php @@ -18,6 +18,8 @@ public function deactivate(): void; /** * Determine if the application is currently down for maintenance. + * + * @phpstan-impure Maintenance state may change between calls. */ public function active(): bool; diff --git a/src/database/src/Console/DbCommand.php b/src/database/src/Console/DbCommand.php index 61eded2b0f..781d277054 100644 --- a/src/database/src/Console/DbCommand.php +++ b/src/database/src/Console/DbCommand.php @@ -45,7 +45,7 @@ public function handle(): int $this->line(' Use the [--read] and [--write] options to specify a read or write connection.'); $this->newLine(); - return Command::FAILURE; + return self::FAILURE; } $configuration = new DatabaseCliConfiguration( @@ -68,10 +68,10 @@ public function handle(): int $this->error("{$configuration->command} not found in path."); - return Command::FAILURE; + return self::FAILURE; } - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/DumpCommand.php b/src/database/src/Console/DumpCommand.php index 738b3fccbf..b0dcb1a5c5 100644 --- a/src/database/src/Console/DumpCommand.php +++ b/src/database/src/Console/DumpCommand.php @@ -40,7 +40,7 @@ class DumpCommand extends Command public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher): int { if ($this->isProhibited()) { - return Command::FAILURE; + return self::FAILURE; } /** @var Connection $connection */ @@ -72,7 +72,7 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis $this->components->info($info . ' successfully.'); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/Migrations/FreshCommand.php b/src/database/src/Console/Migrations/FreshCommand.php index 401c15aba4..d6bba02f41 100644 --- a/src/database/src/Console/Migrations/FreshCommand.php +++ b/src/database/src/Console/Migrations/FreshCommand.php @@ -4,7 +4,6 @@ namespace Hypervel\Database\Console\Migrations; -use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; use Hypervel\Console\Prohibitable; use Hypervel\Contracts\Events\Dispatcher; @@ -46,7 +45,7 @@ public function __construct(Migrator $migrator) public function handle(): int { if ($this->isProhibited()) { - return Command::FAILURE; + return self::FAILURE; } $database = $this->input->getOption('database'); @@ -66,7 +65,7 @@ public function handle(): int } if (! $this->confirmToProceed()) { - return Command::FAILURE; + return self::FAILURE; } $this->createMissingDatabases($missingDatabases); @@ -78,7 +77,7 @@ public function handle(): int '--drop-views' => $this->option('drop-views'), '--drop-types' => $this->option('drop-types'), '--force' => true, - ])) !== Command::SUCCESS) { + ])) !== self::SUCCESS) { throw new RuntimeException("Database wipe failed for connection [{$connection}]."); } @@ -95,7 +94,7 @@ public function handle(): int '--schema-path' => $this->input->getOption('schema-path'), '--force' => true, '--step' => $this->option('step'), - ])) !== Command::SUCCESS) { + ])) !== self::SUCCESS) { throw new RuntimeException('Migration command failed while refreshing the databases.'); } @@ -111,7 +110,7 @@ public function handle(): int $this->runSeeder($database); } - return Command::SUCCESS; + return self::SUCCESS; } /** @@ -131,7 +130,7 @@ protected function runSeeder(?string $database): void '--database' => $database, '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', '--force' => true, - ])) !== Command::SUCCESS) { + ])) !== self::SUCCESS) { throw new RuntimeException('Database seeding failed after the databases were refreshed.'); } } diff --git a/src/database/src/Console/Migrations/MigrateCommand.php b/src/database/src/Console/Migrations/MigrateCommand.php index 5ef4808ec1..7b2371ca27 100644 --- a/src/database/src/Console/Migrations/MigrateCommand.php +++ b/src/database/src/Console/Migrations/MigrateCommand.php @@ -64,7 +64,7 @@ public function __construct(Migrator $migrator, Dispatcher $dispatcher) public function handle(): int { if (! $this->confirmToProceed()) { - return 1; + return self::FAILURE; } try { @@ -73,17 +73,19 @@ public function handle(): int if ($this->option('graceful')) { $this->components->warn($e->getMessage()); - return 0; + return self::SUCCESS; } throw $e; } - return 0; + return self::SUCCESS; } /** * Run the pending migrations. + * + * @throws RuntimeException */ protected function runMigrations(): void { @@ -124,11 +126,13 @@ protected function runMigrations(): void // Forwards the user-supplied --database so seeders run on the chosen app // connection rather than silently falling back to database.default. if ($this->option('seed') && ! $this->option('pretend')) { - $this->call('db:seed', array_filter([ + if ($this->call('db:seed', array_filter([ '--database' => $this->option('database'), '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', '--force' => true, - ])); + ])) !== self::SUCCESS) { + throw new RuntimeException('Database seeding failed after migrations ran.'); + } } }); } diff --git a/src/database/src/Console/Migrations/RefreshCommand.php b/src/database/src/Console/Migrations/RefreshCommand.php index c7f9fa2216..032543b2ab 100644 --- a/src/database/src/Console/Migrations/RefreshCommand.php +++ b/src/database/src/Console/Migrations/RefreshCommand.php @@ -9,6 +9,7 @@ use Hypervel\Console\Prohibitable; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Events\DatabaseRefreshed; +use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -30,12 +31,13 @@ class RefreshCommand extends Command /** * Execute the console command. + * + * @throws RuntimeException */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { - return Command::FAILURE; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } // Next we'll gather some of the options so that we can have the right options @@ -48,7 +50,7 @@ public function handle(): int // If the "step" option is specified it means we only want to rollback a small // number of migrations before migrating again. For example, the user might // only rollback and remigrate the latest four migrations instead of all. - $step = $this->input->getOption('step') ?: 0; + $step = (int) $this->input->getOption('step'); if ($step > 0) { $this->runRollback($database, $path, $step); @@ -59,12 +61,14 @@ public function handle(): int // The refresh command is essentially just a brief aggregate of a few other of // the migration commands and just provides a convenient wrapper to execute // them in succession. We'll also see if we need to re-seed the database. - $this->call('migrate', array_filter([ + if ($this->call('migrate', array_filter([ '--database' => $database, '--path' => $path, '--realpath' => $this->input->getOption('realpath'), '--force' => true, - ])); + ])) !== self::SUCCESS) { + throw new RuntimeException('Migration command failed while refreshing the database.'); + } if ($this->hypervel->bound(Dispatcher::class)) { $events = $this->hypervel->make(Dispatcher::class); @@ -78,34 +82,42 @@ public function handle(): int $this->runSeeder($database); } - return 0; + return self::SUCCESS; } /** * Run the rollback command. + * + * @throws RuntimeException */ protected function runRollback(?string $database, array|string|null $path, int $step): void { - $this->call('migrate:rollback', array_filter([ + if ($this->call('migrate:rollback', array_filter([ '--database' => $database, '--path' => $path, '--realpath' => $this->input->getOption('realpath'), '--step' => $step, '--force' => true, - ])); + ])) !== self::SUCCESS) { + throw new RuntimeException('Migration rollback failed while refreshing the database.'); + } } /** * Run the reset command. + * + * @throws RuntimeException */ protected function runReset(?string $database, array|string|null $path): void { - $this->call('migrate:reset', array_filter([ + if ($this->call('migrate:reset', array_filter([ '--database' => $database, '--path' => $path, '--realpath' => $this->input->getOption('realpath'), '--force' => true, - ])); + ])) !== self::SUCCESS) { + throw new RuntimeException('Migration reset failed while refreshing the database.'); + } } /** @@ -118,14 +130,18 @@ protected function needsSeeding(): bool /** * Run the database seeder command. + * + * @throws RuntimeException */ protected function runSeeder(?string $database): void { - $this->call('db:seed', array_filter([ + if ($this->call('db:seed', array_filter([ '--database' => $database, '--class' => $this->option('seeder') ?: 'Database\Seeders\DatabaseSeeder', '--force' => true, - ])); + ])) !== self::SUCCESS) { + throw new RuntimeException('Database seeding failed after the database was refreshed.'); + } } /** diff --git a/src/database/src/Console/Migrations/ResetCommand.php b/src/database/src/Console/Migrations/ResetCommand.php index 0b07389955..9df21f07c8 100644 --- a/src/database/src/Console/Migrations/ResetCommand.php +++ b/src/database/src/Console/Migrations/ResetCommand.php @@ -4,7 +4,6 @@ namespace Hypervel\Database\Console\Migrations; -use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; use Hypervel\Console\Prohibitable; use Hypervel\Database\Migrations\Migrator; @@ -42,9 +41,8 @@ public function __construct(Migrator $migrator) */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { - return Command::FAILURE; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } return $this->migrator->usingConnection($this->option('database'), function () { @@ -54,7 +52,7 @@ public function handle(): int if (! $this->migrator->repositoryExists()) { $this->components->warn('Migration table not found.'); - return Command::SUCCESS; + return self::SUCCESS; } $this->migrator->setOutput($this->output)->reset( @@ -62,7 +60,7 @@ public function handle(): int $this->option('pretend') ); - return Command::SUCCESS; + return self::SUCCESS; }); } diff --git a/src/database/src/Console/Migrations/RollbackCommand.php b/src/database/src/Console/Migrations/RollbackCommand.php index 84bb8ccdd7..c71dbd2b83 100644 --- a/src/database/src/Console/Migrations/RollbackCommand.php +++ b/src/database/src/Console/Migrations/RollbackCommand.php @@ -4,7 +4,6 @@ namespace Hypervel\Database\Console\Migrations; -use Hypervel\Console\Command; use Hypervel\Console\ConfirmableTrait; use Hypervel\Console\Prohibitable; use Hypervel\Database\Migrations\Migrator; @@ -42,9 +41,8 @@ public function __construct(Migrator $migrator) */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { - return Command::FAILURE; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } $this->migrator->usingConnection($this->option('database'), function () { @@ -58,7 +56,7 @@ public function handle(): int ); }); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/Migrations/StatusCommand.php b/src/database/src/Console/Migrations/StatusCommand.php index 8b6bfd5c3a..34aac403ce 100644 --- a/src/database/src/Console/Migrations/StatusCommand.php +++ b/src/database/src/Console/Migrations/StatusCommand.php @@ -42,7 +42,7 @@ public function handle(): int if (! $this->migrator->repositoryExists()) { $this->components->error('Migration table not found.'); - return 1; + return self::FAILURE; } $ran = $this->migrator->getRepository()->getRan(); @@ -75,7 +75,7 @@ public function handle(): int return (int) $this->option('pending'); } - return 0; + return self::SUCCESS; }); } diff --git a/src/database/src/Console/Seeds/SeedCommand.php b/src/database/src/Console/Seeds/SeedCommand.php index 899466d020..8178cdf4d9 100644 --- a/src/database/src/Console/Seeds/SeedCommand.php +++ b/src/database/src/Console/Seeds/SeedCommand.php @@ -56,9 +56,8 @@ public function __construct(ConnectionResolverInterface $resolver) */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { - return Command::FAILURE; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } $this->components->info('Seeding database.'); @@ -79,7 +78,7 @@ public function handle(): int } } - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/ShowCommand.php b/src/database/src/Console/ShowCommand.php index 88c7cbd753..75c8701e09 100644 --- a/src/database/src/Console/ShowCommand.php +++ b/src/database/src/Console/ShowCommand.php @@ -59,7 +59,7 @@ public function handle(ConnectionResolverInterface $connections): int $this->display($data); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/ShowModelCommand.php b/src/database/src/Console/ShowModelCommand.php index a501e68a36..6f75ca4fa5 100644 --- a/src/database/src/Console/ShowModelCommand.php +++ b/src/database/src/Console/ShowModelCommand.php @@ -51,12 +51,12 @@ public function handle(ModelInspector $modelInspector): int } catch (BindingResolutionException $e) { $this->components->error($e->getMessage()); - return 1; + return self::FAILURE; } $this->display($info); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/TableCommand.php b/src/database/src/Console/TableCommand.php index fb51f09197..8173ae6963 100644 --- a/src/database/src/Console/TableCommand.php +++ b/src/database/src/Console/TableCommand.php @@ -63,7 +63,7 @@ function (array $table) use ($currentSchemas) { if (! $table) { $this->components->warn("Table [{$tableName}] doesn't exist."); - return 1; + return self::FAILURE; } [$columns, $indexes, $foreignKeys] = $connection->withoutTablePrefix(function ($connection) use ($table) { @@ -95,7 +95,7 @@ function (array $table) use ($currentSchemas) { $this->display($data); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Console/WipeCommand.php b/src/database/src/Console/WipeCommand.php index d022f2a74f..3ce9469838 100644 --- a/src/database/src/Console/WipeCommand.php +++ b/src/database/src/Console/WipeCommand.php @@ -32,9 +32,8 @@ class WipeCommand extends Command */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { - return Command::FAILURE; + if ($this->isProhibited() || ! $this->confirmToProceed()) { + return self::FAILURE; } $database = Migrator::resolveMigrationConnectionName( @@ -59,7 +58,7 @@ public function handle(): int $this->flushDatabaseConnection($database); - return 0; + return self::SUCCESS; } /** diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index a8977b685a..3152f28553 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -626,6 +626,7 @@ public function findOr(mixed $id, Closure|array|string $columns = ['*'], ?Closur /** * Get the first record matching the attributes or instantiate it. * + * @param array|(Closure(): array) $values * @return TModel */ public function firstOrNew(array $attributes = [], Closure|array $values = []): Model @@ -640,6 +641,7 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): /** * Get the first record matching the attributes. If the record is not found, create it. * + * @param array|(Closure(): array) $values * @return TModel */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model @@ -656,7 +658,10 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] /** * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * + * @param array|(Closure(): array) $values * @return TModel + * + * @throws UniqueConstraintViolationException */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { @@ -681,6 +686,7 @@ public function ensureCanCreateOrFirst(): void /** * Create or update a record matching the attributes, and fill it with values. * + * @param array|(Closure(): array) $values * @return TModel */ public function updateOrCreate(array $attributes, Closure|array $values = []): Model diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 6e70b2b06d..7253674f28 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -578,6 +578,7 @@ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection /** * Get the first related model record matching the attributes or instantiate it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function firstOrNew(array $attributes = [], Closure|array $values = []): Model @@ -592,6 +593,7 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): /** * Get the first record matching the attributes. If the record is not found, create it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model @@ -618,7 +620,10 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] /** * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * + * @param array|(Closure(): array) $values * @return TRelatedModel + * + * @throws UniqueConstraintViolationException */ public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], bool $touch = true): Model { @@ -656,6 +661,7 @@ protected function hasAttachedPivot(Model $instance): bool /** * Create or update a related record matching the attributes, and fill it with values. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function updateOrCreate(array $attributes, Closure|array $values = [], array $joining = [], bool $touch = true): Model diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php index b77b4cfbfa..147f5410da 100755 --- a/src/database/src/Eloquent/Relations/HasOneOrMany.php +++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php @@ -220,6 +220,7 @@ public function findOrNew(mixed $id, array $columns = ['*']): EloquentCollection /** * Get the first related model record matching the attributes or instantiate it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function firstOrNew(array $attributes = [], Closure|array $values = []): Model @@ -236,6 +237,7 @@ public function firstOrNew(array $attributes = [], Closure|array $values = []): /** * Get the first record matching the attributes. If the record is not found, create it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model @@ -252,7 +254,10 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] /** * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * + * @param array|(Closure(): array) $values * @return TRelatedModel + * + * @throws UniqueConstraintViolationException */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { @@ -268,6 +273,7 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] /** * Create or update a related record matching the attributes, and fill it with values. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function updateOrCreate(array $attributes, Closure|array $values = []): Model diff --git a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php index 485f87f637..3393252c2a 100644 --- a/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php +++ b/src/database/src/Eloquent/Relations/HasOneOrManyThrough.php @@ -187,20 +187,22 @@ protected function buildDictionary(EloquentCollection $results): array /** * Get the first related model record matching the attributes or instantiate it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ - public function firstOrNew(array $attributes = [], array $values = []): Model + public function firstOrNew(array $attributes = [], Closure|array $values = []): Model { if (! is_null($instance = $this->where($attributes)->first())) { return $instance; } - return $this->related->newInstance(array_merge($attributes, $values)); + return $this->related->newInstance(array_merge($attributes, value($values))); } /** * Get the first record matching the attributes. If the record is not found, create it. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ public function firstOrCreate(array $attributes = [], Closure|array $values = []): Model @@ -211,13 +213,16 @@ public function firstOrCreate(array $attributes = [], Closure|array $values = [] return $instance; } - return $this->createOrFirst(array_merge($attributes, value($values))); + return $this->createOrFirst($attributes, $values); } /** * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * + * @param array|(Closure(): array) $values * @return TRelatedModel + * + * @throws UniqueConstraintViolationException */ public function createOrFirst(array $attributes = [], Closure|array $values = []): Model { @@ -233,13 +238,14 @@ public function createOrFirst(array $attributes = [], Closure|array $values = [] /** * Create or update a related record matching the attributes, and fill it with values. * + * @param array|(Closure(): array) $values * @return TRelatedModel */ - public function updateOrCreate(array $attributes, array $values = []): Model + public function updateOrCreate(array $attributes, Closure|array $values = []): Model { return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { if (! $instance->wasRecentlyCreated) { - $instance->fill($values)->save(); + $instance->fill(value($values))->save(); } }); } diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index ba6a5f183e..22331f2d81 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -1366,15 +1366,7 @@ public function geography(string $column, ?string $subtype = null, int $srid = 4 return $this->addColumn('geography', $column, compact('subtype', 'srid')); } - /** - * Create a new generated, computed column on the table. - * - * @return TColumnDefinition - */ - public function computed(string $column, string $expression): ColumnDefinition - { - return $this->addColumn('computed', $column, compact('expression')); - } + // REMOVED: SQL Server-only computed(); use virtualAs() or storedAs() on a typed column. /** * Create a new vector column on the table. diff --git a/src/database/src/Schema/Grammars/Grammar.php b/src/database/src/Schema/Grammars/Grammar.php index 5ecf2e82d3..99c9efed07 100755 --- a/src/database/src/Schema/Grammars/Grammar.php +++ b/src/database/src/Schema/Grammars/Grammar.php @@ -279,13 +279,7 @@ protected function getType(Fluent $column): string return $this->{'type' . ucfirst($column->type)}($column); } - /** - * Create the column definition for a generated, computed column type. - */ - protected function typeComputed(Fluent $column): void - { - throw new RuntimeException('This database driver does not support the computed type.'); - } + // REMOVED: typeComputed(); the SQL Server-only Blueprint::computed() column type was removed. /** * Create the column definition for a vector type. diff --git a/src/database/src/Schema/Grammars/MySqlGrammar.php b/src/database/src/Schema/Grammars/MySqlGrammar.php index ecb85b0dc8..7cced5b812 100755 --- a/src/database/src/Schema/Grammars/MySqlGrammar.php +++ b/src/database/src/Schema/Grammars/MySqlGrammar.php @@ -11,7 +11,6 @@ use Hypervel\Support\Collection; use Hypervel\Support\Fluent; use Override; -use RuntimeException; /** * @property MySqlConnection $connection @@ -942,15 +941,7 @@ protected function typeGeography(Fluent $column): string return $this->typeGeometry($column); } - /** - * Create the column definition for a generated, computed column type. - * - * @throws RuntimeException - */ - protected function typeComputed(Fluent $column): void - { - throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); - } + // REMOVED: typeComputed(); the SQL Server-only Blueprint::computed() column type was removed. /** * Create the column definition for a vector type. diff --git a/src/database/src/Schema/Grammars/SQLiteGrammar.php b/src/database/src/Schema/Grammars/SQLiteGrammar.php index 87c32e19b5..1e4fcb0451 100644 --- a/src/database/src/Schema/Grammars/SQLiteGrammar.php +++ b/src/database/src/Schema/Grammars/SQLiteGrammar.php @@ -1110,15 +1110,7 @@ protected function typeGeography(Fluent $column): string return $this->typeGeometry($column); } - /** - * Create the column definition for a generated, computed column type. - * - * @throws RuntimeException - */ - protected function typeComputed(Fluent $column): void - { - throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); - } + // REMOVED: typeComputed(); the SQL Server-only Blueprint::computed() column type was removed. /** * Get the SQL for a generated virtual column modifier. diff --git a/src/docs/collections.md b/src/docs/collections.md index 2cceb0473d..8040c8e5e7 100644 --- a/src/docs/collections.md +++ b/src/docs/collections.md @@ -115,6 +115,7 @@ For the majority of the remaining collection documentation, we'll discuss each m [avg](#method-avg) [before](#method-before) [chunk](#method-chunk) +[chunkBy](#method-chunkby) [chunkWhile](#method-chunkwhile) [collapse](#method-collapse) [collapseWithKeys](#method-collapsewithkeys) @@ -429,6 +430,27 @@ This method is especially useful in [views](/docs/{{version}}/views) when workin @endforeach ``` + +#### `chunkBy()` {.collection-method} + +The `chunkBy` method breaks the collection into multiple, smaller collections by grouping adjacent items that have the same value for a given key or callback. For example, you may group adjacent products that share the same parent: + +```php +$chunks = $products->chunkBy('parent'); +``` + +Unlike the `groupBy` method, items with the same value that are not adjacent are placed in separate chunks: + +```php +$collection = collect([1, 1, 2, 2, 1]); + +$chunks = $collection->chunkBy(fn (int $value) => $value); + +$chunks->all(); + +// [[1, 1], [2, 2], [1]] +``` + #### `chunkWhile()` {.collection-method} @@ -4404,6 +4426,7 @@ Almost all methods available on the `Collection` class are also available on the [avg](#method-avg) [before](#method-before) [chunk](#method-chunk) +[chunkBy](#method-chunkby) [chunkWhile](#method-chunkwhile) [collapse](#method-collapse) [collapseWithKeys](#method-collapsewithkeys) diff --git a/src/docs/configuration.md b/src/docs/configuration.md index 36caef15df..ac8a0f6b94 100644 --- a/src/docs/configuration.md +++ b/src/docs/configuration.md @@ -414,6 +414,23 @@ The `retry` option may be specified as a number of seconds or as a date / time s php artisan down --retry="tomorrow 14:00" ``` +You may run the `down` command again to change the maintenance mode options without bringing the application back online. The new options replace the previous ones, so include any options you want to keep, such as `secret` or `redirect`. + + +#### Excluding URLs From Maintenance Mode + +You may allow specific URLs during maintenance by configuring the `preventRequestsDuringMaintenance` method in your application's `bootstrap/app.php` file: + +```php +use Hypervel\Foundation\Configuration\Middleware; + +->withMiddleware(function (Middleware $middleware): void { + $middleware->preventRequestsDuringMaintenance(except: [ + 'webhooks/*', + ]); +}) +``` + #### Bypassing Maintenance Mode diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md index 0faf395223..7acad177dd 100644 --- a/src/docs/eloquent.md +++ b/src/docs/eloquent.md @@ -732,7 +732,7 @@ Route::get('/api/flights/{id}', function (string $id) { ### Retrieving or Creating Models -The `firstOrCreate` method will attempt to locate a database record using the given column / value pairs. If the model cannot be found in the database, a record will be inserted with the attributes resulting from merging the first array argument with the optional second array argument. +The `firstOrCreate` method will attempt to locate a database record using the given column / value pairs. If the model cannot be found in the database, a record will be inserted with the attributes resulting from merging the first array argument with the optional second argument. The `firstOrNew` method, like `firstOrCreate`, will attempt to locate a record in the database matching the given attributes. However, if a model is not found, a new model instance will be returned. Note that the model returned by `firstOrNew` has not yet been persisted to the database. You will need to manually call the `save` method to persist it: @@ -762,8 +762,21 @@ $flight = Flight::firstOrNew( ); ``` +You may pass a closure as the second argument to `firstOrCreate` or `firstOrNew`. The closure should return an array of attributes and will only be invoked when no matching model is found: + +```php +use Hypervel\Support\Str; + +$flight = Flight::firstOrCreate( + ['name' => 'London to Paris'], + fn () => ['reference' => Str::uuid()->toString()] +); +``` + +The `createOrFirst` method attempts to create the model first, then retrieves a matching record if the insert violates a unique constraint. Ensure the attributes in the first argument are protected by a unique index; otherwise, repeated calls can insert duplicates. Its second argument may also be a closure. + > [!NOTE] -> If `firstOrCreate` or `updateOrCreate` encounters a concurrent insert, it attempts to retrieve the winning row from the write connection. Inside a repeatable-read transaction (the default on MySQL and MariaDB), a row committed after the transaction's snapshot may remain invisible, in which case the original unique constraint violation is rethrown. For idempotent collision handling in this situation, retry the complete transaction from outside it. +> If `firstOrCreate`, `createOrFirst`, or `updateOrCreate` encounters a concurrent insert, it attempts to retrieve the winning row from the write connection. Inside a repeatable-read transaction (the default on MySQL and MariaDB), a row committed after the transaction's snapshot may remain invisible, in which case the original unique constraint violation is rethrown. For idempotent collision handling in this situation, retry the complete transaction from outside it. ### Retrieving Aggregates @@ -891,6 +904,8 @@ $flight = Flight::updateOrCreate( ); ``` +The second argument may also be a closure returning the attributes to create or update. + When using methods such as `firstOrCreate` or `updateOrCreate`, you may not know whether a new model has been created or an existing one has been updated. The `wasRecentlyCreated` property indicates if the model was created during its current lifecycle: ```php diff --git a/src/docs/requests.md b/src/docs/requests.md index 85281043d4..07580f5e6c 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -424,7 +424,7 @@ $perPage = $request->integer('per_page'); #### Retrieving Clamped Input Values -To retrieve a numeric input value constrained between a minimum and maximum value, you may use the `clamp` method. If the input is not present, the default value you specify will be clamped instead: +To retrieve a numeric input value constrained between a minimum and maximum value, you may use the `clamp` method. If the input is not present or is not numeric, the default value you specify will be clamped instead: ```php $perPage = $request->clamp('per_page', min: 1, max: 100, default: 15); diff --git a/src/docs/strings.md b/src/docs/strings.md index 70dabfcf2b..4adb456d49 100644 --- a/src/docs/strings.md +++ b/src/docs/strings.md @@ -650,6 +650,16 @@ $result = Str::deduplicate('The---Hypervel---Framework', '-'); // The-Hypervel-Framework ``` +You may also pass an array of characters to deduplicate each of them: + +```php +use Hypervel\Support\Str; + +$result = Str::deduplicate('The---Hypervel Framework', ['-', ' ']); + +// The-Hypervel Framework +``` + #### `Str::doesntEndWith()` {.collection-method} @@ -2533,7 +2543,7 @@ $result = Str::of('The Hypervel Framework')->deduplicate(); // The Hypervel Framework ``` -You may specify a different character to deduplicate by passing it in as the second argument to the method: +You may specify a different character to deduplicate by passing it to the method: ```php use Hypervel\Support\Str; @@ -2543,6 +2553,16 @@ $result = Str::of('The---Hypervel---Framework')->deduplicate('-'); // The-Hypervel-Framework ``` +You may also pass an array of characters to deduplicate each of them: + +```php +use Hypervel\Support\Str; + +$result = Str::of('The---Hypervel Framework')->deduplicate(['-', ' ']); + +// The-Hypervel Framework +``` + #### `dirname` {.collection-method} diff --git a/src/foundation/src/Console/AboutCommand.php b/src/foundation/src/Console/AboutCommand.php index 1adaf3afe9..a728c548f8 100644 --- a/src/foundation/src/Console/AboutCommand.php +++ b/src/foundation/src/Console/AboutCommand.php @@ -15,9 +15,15 @@ #[AsCommand(name: 'about')] class AboutCommand extends Command { + /** + * The console command signature. + */ protected ?string $signature = 'about {--only= : The section to display} {--json : Output the information as JSON}'; + /** + * The console command description. + */ protected string $description = 'Display basic information about your application'; /** @@ -76,7 +82,7 @@ public function handle(): int $this->newLine(); - return 0; + return self::SUCCESS; } /** diff --git a/src/foundation/src/Console/ConfigShowCommand.php b/src/foundation/src/Console/ConfigShowCommand.php index daf4242433..ec33c6de3d 100644 --- a/src/foundation/src/Console/ConfigShowCommand.php +++ b/src/foundation/src/Console/ConfigShowCommand.php @@ -12,17 +12,29 @@ #[AsCommand(name: 'config:show')] class ConfigShowCommand extends Command { + /** + * The console command signature. + */ protected ?string $signature = 'config:show {config : The configuration file or key to show}'; + /** + * The console command description. + */ protected string $description = 'Display all of the values for a given configuration file or key'; + /** + * Create a new command instance. + */ public function __construct( protected Repository $config ) { parent::__construct(); } - public function handle() + /** + * Execute the console command. + */ + public function handle(): int { $config = $this->argument('config'); @@ -34,7 +46,7 @@ public function handle() $this->render($config); $this->newLine(); - return Command::SUCCESS; + return self::SUCCESS; } /** @@ -78,7 +90,7 @@ protected function formatKey(string $key): string { return preg_replace_callback( '/(.*)\.(.*)$/', - fn ($matches) => sprintf( + fn (array $matches): string => sprintf( '%s ⇁ %s', str_replace('.', ' ⇁ ', $matches[1]), $matches[2] diff --git a/src/foundation/src/Console/DownCommand.php b/src/foundation/src/Console/DownCommand.php index 9e906d45f1..7de099a682 100644 --- a/src/foundation/src/Console/DownCommand.php +++ b/src/foundation/src/Console/DownCommand.php @@ -97,10 +97,10 @@ public function handle(): int $e->getMessage(), )); - return 1; + return self::FAILURE; } - return 0; + return self::SUCCESS; } /** diff --git a/src/foundation/src/Console/UpCommand.php b/src/foundation/src/Console/UpCommand.php index 116d0ee37c..290c1776e2 100644 --- a/src/foundation/src/Console/UpCommand.php +++ b/src/foundation/src/Console/UpCommand.php @@ -37,7 +37,7 @@ public function handle(): int if (! $this->hypervel->maintenanceMode()->active()) { $this->components->info('Application is already up.'); - return 0; + return self::SUCCESS; } $this->hypervel->maintenanceMode()->deactivate(); @@ -80,9 +80,9 @@ public function handle(): int $e->getMessage(), )); - return 1; + return self::FAILURE; } - return 0; + return self::SUCCESS; } } diff --git a/src/foundation/src/FileBasedMaintenanceMode.php b/src/foundation/src/FileBasedMaintenanceMode.php index 347569d5a7..3b0f59dfd9 100644 --- a/src/foundation/src/FileBasedMaintenanceMode.php +++ b/src/foundation/src/FileBasedMaintenanceMode.php @@ -4,6 +4,7 @@ namespace Hypervel\Foundation; +use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Json; @@ -50,10 +51,23 @@ public function active(): bool /** * Get the data array which was provided when the application was placed into maintenance. + * + * @throws FileNotFoundException */ public function data(): array { - $data = Json::decode($this->files->get($this->path())); + try { + $contents = $this->files->get($this->path()); + } catch (FileNotFoundException $exception) { + if ($this->active()) { + throw $exception; + } + + // The cached snapshot and middleware recheck activity for an empty payload. + return []; + } + + $data = Json::decode($contents); if (! is_array($data)) { throw new RuntimeException('The maintenance mode file does not contain a valid payload.'); diff --git a/src/foundation/src/Http/MaintenanceModeBypassCookie.php b/src/foundation/src/Http/MaintenanceModeBypassCookie.php index 183b461ac7..11c9a3a84d 100644 --- a/src/foundation/src/Http/MaintenanceModeBypassCookie.php +++ b/src/foundation/src/Http/MaintenanceModeBypassCookie.php @@ -31,7 +31,7 @@ public static function isValid(string $cookie, string $key): bool return is_array($payload) && is_numeric($payload['expires_at'] ?? null) - && isset($payload['mac']) + && is_string($payload['mac'] ?? null) && hash_equals(hash_hmac('sha256', (string) $payload['expires_at'], $key), $payload['mac']) && (int) $payload['expires_at'] >= CarbonImmutable::now()->getTimestamp(); } diff --git a/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php b/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php index a14b2c4f9f..ec012ad70d 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php +++ b/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -5,7 +5,6 @@ namespace Hypervel\Foundation\Http\Middleware; use Closure; -use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Contracts\Foundation\Application; use Hypervel\Foundation\Http\MaintenanceModeBypassCookie; use Hypervel\Foundation\Http\Middleware\Concerns\ExcludesPaths; @@ -50,13 +49,14 @@ public function handle(Request $request, Closure $next): mixed return $next($request); } - try { - if (! $this->app->maintenanceMode()->active()) { - return $next($request); - } + if (! $this->app->maintenanceMode()->active()) { + return $next($request); + } + + $data = $this->app->maintenanceMode()->data(); - $data = $this->app->maintenanceMode()->data(); - } catch (FileNotFoundException) { + // Maintenance may end between reads; an empty payload alone does not mean it ended. + if ($data === [] && ! $this->app->maintenanceMode()->active()) { return $next($request); } diff --git a/src/foundation/src/WorkerCachedMaintenanceMode.php b/src/foundation/src/WorkerCachedMaintenanceMode.php index 6908218c4b..593a3f7f2d 100644 --- a/src/foundation/src/WorkerCachedMaintenanceMode.php +++ b/src/foundation/src/WorkerCachedMaintenanceMode.php @@ -96,10 +96,16 @@ protected function loadSnapshot(): array { if ($this->shouldRefreshSnapshot()) { $active = $this->driver->active(); + $data = $active ? $this->driver->data() : []; + + // Maintenance may end between reads, but an active empty payload is valid. + if ($active && $data === []) { + $active = $this->driver->active(); + } static::$snapshot = [ 'active' => $active, - 'data' => $active ? $this->driver->data() : [], + 'data' => $data, ]; // Set after successful reads so failed refreshes retry on the next request. diff --git a/src/inertia/src/Commands/CreateMiddleware.php b/src/inertia/src/Commands/CreateMiddleware.php index b604c1953d..a6f39d9b49 100644 --- a/src/inertia/src/Commands/CreateMiddleware.php +++ b/src/inertia/src/Commands/CreateMiddleware.php @@ -6,6 +6,7 @@ use Hypervel\Console\GeneratorCommand; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'inertia:middleware')] @@ -44,20 +45,16 @@ protected function getDefaultNamespace(string $rootNamespace): string /** * Get the console command arguments. - * - * @return array> */ protected function getArguments(): array { return [ - ['name', InputOption::VALUE_REQUIRED, 'Name of the Middleware that should be created', 'HandleInertiaRequests'], + ['name', InputArgument::OPTIONAL, 'Name of the Middleware that should be created', 'HandleInertiaRequests'], ]; } /** * Get the console command options. - * - * @return array> */ protected function getOptions(): array { diff --git a/src/queue/src/Console/ForgetFailedCommand.php b/src/queue/src/Console/ForgetFailedCommand.php index 18b27d50d5..8507bdb717 100644 --- a/src/queue/src/Console/ForgetFailedCommand.php +++ b/src/queue/src/Console/ForgetFailedCommand.php @@ -31,7 +31,7 @@ public function handle(): ?int } else { $this->error('No failed job matches the given ID.'); - return 1; + return self::FAILURE; } return null; diff --git a/src/queue/src/Console/PruneFailedJobsCommand.php b/src/queue/src/Console/PruneFailedJobsCommand.php index ff2c922bc8..5d1480ca88 100644 --- a/src/queue/src/Console/PruneFailedJobsCommand.php +++ b/src/queue/src/Console/PruneFailedJobsCommand.php @@ -36,7 +36,7 @@ public function handle(): ?int } else { $this->error('The [' . class_basename($failer) . '] failed job storage driver does not support pruning.'); - return 1; + return self::FAILURE; } $this->info("{$count} entries deleted."); diff --git a/src/support/src/Facades/Route.php b/src/support/src/Facades/Route.php index 9b4165be4d..f7e82c4ee3 100644 --- a/src/support/src/Facades/Route.php +++ b/src/support/src/Facades/Route.php @@ -83,7 +83,7 @@ * @method static \Hypervel\Routing\Route substituteBindings(\Hypervel\Routing\Route $route) * @method static mixed substituteImplicitBindings(\Hypervel\Routing\Route $route) * @method static \Hypervel\Routing\Router substituteImplicitBindingsUsing(callable $callback) - * @method static ($callback is null ? \Hypervel\Support\HigherOrderTapProxy : \Hypervel\Routing\Router) tap(null|callable $callback = null) + * @method static ($callback is null ? \Hypervel\Support\HigherOrderTapProxy<\Hypervel\Routing\Router> : \Hypervel\Routing\Router) tap(null|callable $callback = null) * @method static \Symfony\Component\HttpFoundation\Response toResponse(\Hypervel\Http\Request $request, mixed $response) * @method static array uniqueMiddleware(array $middleware) * @method static bool uses(array|string ...$patterns) diff --git a/src/support/src/HigherOrderTapProxy.php b/src/support/src/HigherOrderTapProxy.php index d637562bf2..eb61b34c27 100644 --- a/src/support/src/HigherOrderTapProxy.php +++ b/src/support/src/HigherOrderTapProxy.php @@ -4,10 +4,15 @@ namespace Hypervel\Support; +/** + * @template TTarget + */ class HigherOrderTapProxy { /** * Create a new tap proxy instance. + * + * @param TTarget $target */ public function __construct( public mixed $target, @@ -16,6 +21,8 @@ public function __construct( /** * Dynamically pass method calls to the target. + * + * @return TTarget */ public function __call(string $method, array $parameters): mixed { diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php index fb27665078..a720b1d12c 100644 --- a/src/support/src/Sleep.php +++ b/src/support/src/Sleep.php @@ -365,7 +365,6 @@ public static function assertSequence(array $sequence): void (new Collection($sequence)) ->zip(static::$sequence) - /* @phpstan-ignore argument.type (eachSpread signature can't express fixed-param callbacks) */ ->eachSpread(function (?Sleep $expected, CarbonInterval $actual) { if ($expected === null) { return; diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php index 74e4e0ef49..a542da3465 100644 --- a/src/support/src/Stringable.php +++ b/src/support/src/Stringable.php @@ -207,10 +207,12 @@ public function counted(int|array|Countable $count): static /** * Replace consecutive instances of a given character with a single character. + * + * @param array|string $characters */ - public function deduplicate(string $character = ' '): static + public function deduplicate(array|string $characters = ' '): static { - return new static(Str::deduplicate($this->value, $character)); + return new static(Str::deduplicate($this->value, $characters)); } /** diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index 4efc0241b4..bf4f65d754 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -283,14 +283,15 @@ public function float(string $key, float $default = 0.0): float */ public function clamp(string $key, int|float $min, int|float $max, int|float $default = 0): int|float { - $number = $this->data($key, $default); + $value = $this->data($key, $default); - if (is_string($number) && is_numeric($number)) { - $number += 0; + if (! is_numeric($value)) { + $value = $default; + } elseif (is_string($value)) { + $value += 0; } - // Non-numeric input fails naturally in the strictly typed Number::clamp(). - return Number::clamp($number, $min, $max); // @phpstan-ignore argument.type + return Number::clamp($value, $min, $max); } /** diff --git a/src/support/src/Traits/Tappable.php b/src/support/src/Traits/Tappable.php index 89edf81bf4..8948215959 100644 --- a/src/support/src/Traits/Tappable.php +++ b/src/support/src/Traits/Tappable.php @@ -4,13 +4,15 @@ namespace Hypervel\Support\Traits; +use Hypervel\Support\HigherOrderTapProxy; + trait Tappable { /** * Call the given Closure with this instance then return the instance. * * @param null|(callable($this): mixed) $callback - * @return ($callback is null ? \Hypervel\Support\HigherOrderTapProxy : $this) + * @return ($callback is null ? HigherOrderTapProxy<$this> : $this) */ public function tap(?callable $callback = null): mixed { diff --git a/src/support/src/helpers.php b/src/support/src/helpers.php index 75da7489f5..fe32f4380b 100644 --- a/src/support/src/helpers.php +++ b/src/support/src/helpers.php @@ -273,7 +273,7 @@ function once(callable $callback) * * @param TValue $value * @param null|(callable(TValue): TReturn) $callback - * @return ($callback is null ? \Hypervel\Support\Optional : ($value is null ? null : TReturn)) + * @return ($callback is null ? Optional : ($value is null ? null : TReturn)) */ function optional($value = null, ?callable $callback = null) { @@ -355,7 +355,7 @@ function retry($times, callable $callback, $sleepMilliseconds = 0, $when = null) * Get a new stringable object from the given string. * * @param null|string $string - * @return ($string is null ? object : \Hypervel\Support\Stringable) + * @return ($string is null ? object : SupportStringable) */ function str($string = null) { @@ -385,7 +385,7 @@ public function __toString() * * @param TValue $value * @param null|(callable(TValue): mixed) $callback - * @return ($callback is null ? \Hypervel\Support\HigherOrderTapProxy : TValue) + * @return ($callback is null ? HigherOrderTapProxy : TValue) */ function tap($value, $callback = null) { diff --git a/tests/Bus/BusBatchTest.php b/tests/Bus/BusBatchTest.php index 138422cfbb..39ee7aca13 100644 --- a/tests/Bus/BusBatchTest.php +++ b/tests/Bus/BusBatchTest.php @@ -508,7 +508,7 @@ public function testBatchCanBeCancelled() $this->assertTrue($batch->cancelled()); } - public function testBatchCancelledEventIsDispatched() + public function testBatchCancelledEventIsDispatched(): void { $this->app->instance(EventDispatcher::class, $events = m::mock(EventDispatcher::class)); @@ -518,7 +518,7 @@ public function testBatchCancelledEventIsDispatched() $exception = new RuntimeException('Something went wrong.'); $events->shouldReceive('hasListeners')->once()->with(BatchCanceled::class)->andReturnTrue(); - $events->shouldReceive('dispatch')->once()->with(m::on(function ($event) use ($batch, $exception) { + $events->shouldReceive('dispatch')->once()->with(m::on(function (object $event) use ($batch, $exception): bool { return $event instanceof BatchCanceled && $event->batch->id === $batch->id && $event->exception === $exception; diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index c09734a8a6..ac0fdf4ada 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -44,7 +44,7 @@ 'isArray' => false, 'acceptValue' => false, 'default' => false, - 'description' => 'Whether disable event dispatcher.', + 'description' => 'Disable the event dispatcher', ], ], ], @@ -83,7 +83,7 @@ 'isArray' => false, 'acceptValue' => false, 'default' => false, - 'description' => 'Whether disable event dispatcher.', + 'description' => 'Disable the event dispatcher', ], ], ], diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php index b2ec7c7436..d27c140401 100755 --- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -26,7 +26,8 @@ protected function setUp(): void CarbonImmutable::setTestNow('2023-01-01 00:00:00'); } - public function testCreateOrFirstMethodCreatesNewRecord(): void + #[DataProvider('createOrFirstValues')] + public function testCreateOrFirstMethodCreatesNewRecord(Closure|array $values): void { $model = new TestModel; $this->mockConnectionForModel($model, 'SQLite', [123]); @@ -38,7 +39,7 @@ public function testCreateOrFirstMethodCreatesNewRecord(): void ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], )->andReturnTrue(); - $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], $values); $this->assertTrue($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 123, @@ -614,6 +615,9 @@ public function testFirstOrNewMethodAcceptsClosureValuesAndInstantiates(Closure| $this->assertSame('bar', $result->val); } + /** + * Provide array and closure creation values. + */ public static function createOrFirstValues(): array { return [ @@ -652,6 +656,9 @@ public function testFirstOrNewDoesNotInvokeClosureWhenRecordExists(): void $this->assertSame('bar', $result->val); } + /** + * Mock the model's database connection. + */ protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index 745ad12b17..dae96ba089 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -809,7 +809,7 @@ public function testWithoutAppendsRemovesAppendsOnEntireCollection() $this->assertArrayNotHasKey('appended_field', $c->toArray()[0]); } - public function testNonModelRelatedMethods() + public function testNonModelRelatedMethods(): void { $a = new Collection([['foo' => 'bar'], ['foo' => 'baz']]); $b = new Collection(['a', 'b', 'c']); @@ -822,6 +822,29 @@ public function testNonModelRelatedMethods() $this->assertEquals(BaseCollection::class, get_class($b->flip())); $this->assertEquals(BaseCollection::class, get_class($a->partition('foo', '=', 'bar'))); $this->assertEquals(BaseCollection::class, get_class($a->partition('foo', 'bar'))); + + $models = new Collection([ + (new CollectionModel)->forceFill(['team' => 'a', 'name' => 'bar']), + (new CollectionModel)->forceFill(['team' => 'a', 'name' => 'baz']), + ]); + + $groups = $models->mapToGroups(fn (CollectionModel $model) => ['values' => $model->name]); + $this->assertSame(BaseCollection::class, get_class($groups)); + $this->assertSame(['bar', 'baz'], $groups->get('values')->all()); + + $values = $models->chunk(1)->mapSpread(fn (CollectionModel $model) => $model->name); + $this->assertSame(BaseCollection::class, get_class($values)); + $this->assertSame(['bar', 'baz'], $values->all()); + + $windows = $models->sliding(2); + $this->assertSame(BaseCollection::class, get_class($windows)); + $this->assertSame(Collection::class, get_class($windows->first())); + $this->assertSame(['bar', 'baz'], $windows->first()->pluck('name')->all()); + + $groups = $models->groupBy(['team', 'name']); + $this->assertSame(BaseCollection::class, get_class($groups)); + $this->assertSame(Collection::class, get_class($groups->get('a')->get('bar'))); + $this->assertSame(['bar'], $groups->get('a')->get('bar')->pluck('name')->all()); } public function testMakeVisibleRemovesHiddenAndIncludesVisible() diff --git a/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php index f0d8cfe639..5b2dbb03c5 100644 --- a/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php +++ b/tests/Database/DatabaseEloquentCreateOrFirstValidationTest.php @@ -43,7 +43,7 @@ public function testRelatedBuilderValidationRunsBeforeQueriesAndValueCallbacks(s 'belongsToMany' => $parent->belongsToMany($related, 'parent_related', 'parent_id', 'related_id', relation: 'related'), 'morphToMany' => $parent->morphToMany($related, 'parent', 'parent_related', 'parent_id', 'related_id', relation: 'related'), }; - $values = $method === 'updateOrCreate' && in_array($relation, ['hasOneThrough', 'hasManyThrough'], true) ? [] : function (): never { + $values = function (): never { $this->fail('The value callback must not run before validation.'); }; @@ -52,6 +52,9 @@ public function testRelatedBuilderValidationRunsBeforeQueriesAndValueCallbacks(s $query->{$method}(['id' => 2], $values); } + /** + * Provide creation helpers for each relationship type. + */ public static function creationHelpers(): iterable { foreach (['direct', 'hasOne', 'hasMany', 'morphOne', 'morphMany', 'hasOneThrough', 'hasManyThrough', 'belongsToMany', 'morphToMany'] as $relation) { @@ -76,6 +79,9 @@ class CreationValidationModel extends CreationValidationParent class CreationValidationBuilder extends Builder { + /** + * Reject create-or-first operations for this builder. + */ public function ensureCanCreateOrFirst(): never { throw new LogicException('Create-or-first is unavailable for this builder.'); diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php index 8b0dcca7f7..d2e9d33500 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -26,13 +26,6 @@ protected function setUp(): void CarbonImmutable::setTestNow('2023-01-01 00:00:00'); } - protected function tearDown(): void - { - CarbonImmutable::setTestNow(); - - parent::tearDown(); - } - #[DataProvider('createOrFirstValues')] public function testCreateOrFirstMethodCreatesNewRecord(Closure|array $values): void { @@ -57,14 +50,6 @@ public function testCreateOrFirstMethodCreatesNewRecord(Closure|array $values): ], $result->toArray()); } - public static function createOrFirstValues(): array - { - return [ - 'array' => [['val' => 'bar']], - 'closure' => [fn () => ['val' => 'bar']], - ]; - } - public function testCreateOrFirstMethodRetrievesExistingRecord(): void { $parent = new ParentModel; @@ -188,7 +173,8 @@ public function testFirstOrCreateMethodRetrievesExistingRecord(): void ], $result->toArray()); } - public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + #[DataProvider('createOrFirstValues')] + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(Closure|array $values): void { $parent = new ParentModel; $parent->id = 123; @@ -218,8 +204,8 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ? and "val" = ?) limit 1', - [123, 'foo', 'bar'], + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], false, [], ) @@ -228,25 +214,37 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void 'pivot_id' => 456, 'hypervel_through_key' => 123, 'attr' => 'foo', - 'val' => 'bar', + 'val' => 'other', 'created_at' => '2023-01-01T00:00:00.000000Z', 'updated_at' => '2023-01-01T00:00:00.000000Z', ]]); - $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], $values); $this->assertFalse($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 789, 'pivot_id' => 456, 'hypervel_through_key' => 123, 'attr' => 'foo', - 'val' => 'bar', + 'val' => 'other', 'created_at' => '2023-01-01T00:00:00.000000Z', 'updated_at' => '2023-01-01T00:00:00.000000Z', ], $result->toArray()); } - public function testUpdateOrCreateMethodCreatesNewRecord(): void + /** + * Provide array and closure creation values. + */ + public static function createOrFirstValues(): array + { + return [ + 'array' => [['val' => 'bar']], + 'closure' => [fn () => ['val' => 'bar']], + ]; + } + + #[DataProvider('updateOrCreateValues')] + public function testUpdateOrCreateMethodCreatesNewRecord(Closure|array $values): void { $parent = new ParentModel; $parent->id = 123; @@ -273,7 +271,7 @@ public function testUpdateOrCreateMethodCreatesNewRecord(): void ) ->andReturnTrue(); - $result = $parent->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], $values); $this->assertTrue($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 789, @@ -284,7 +282,8 @@ public function testUpdateOrCreateMethodCreatesNewRecord(): void ], $result->toArray()); } - public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + #[DataProvider('updateOrCreateValues')] + public function testUpdateOrCreateMethodUpdatesExistingRecord(Closure|array $values): void { $parent = new ParentModel; $parent->id = 123; @@ -319,7 +318,7 @@ public function testUpdateOrCreateMethodUpdatesExistingRecord(): void ) ->andReturn(1); - $result = $parent->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], $values); $this->assertFalse($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 789, @@ -332,7 +331,8 @@ public function testUpdateOrCreateMethodUpdatesExistingRecord(): void ], $result->toArray()); } - public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + #[DataProvider('updateOrCreateValues')] + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(Closure|array $values): void { $parent = new ParentModel; $parent->id = 123; @@ -352,7 +352,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void ->andReturn([]); $sql = 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; - $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + $bindings = ['foo', 'baz', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; $parent->getConnection() ->expects('insert') @@ -362,8 +362,8 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void $parent->getConnection() ->expects('select') ->with( - 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ? and "val" = ?) limit 1', - [123, 'foo', 'bar'], + 'select "child".*, "pivot"."parent_id" as "hypervel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], false, [], ) @@ -377,19 +377,41 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void 'updated_at' => '2023-01-01T00:00:00.000000Z', ]]); - $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $parent->getConnection() + ->expects('update') + ->with( + 'update "child" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 789], + ) + ->andReturn(1); + + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], $values); $this->assertFalse($result->wasRecentlyCreated); $this->assertEquals([ 'id' => 789, 'pivot_id' => 456, 'hypervel_through_key' => 123, 'attr' => 'foo', - 'val' => 'bar', + 'val' => 'baz', 'created_at' => '2023-01-01T00:00:00.000000Z', 'updated_at' => '2023-01-01T00:00:00.000000Z', ], $result->toArray()); } + /** + * Provide array and closure update values. + */ + public static function updateOrCreateValues(): array + { + return [ + 'array' => [['val' => 'baz']], + 'closure' => [fn () => ['val' => 'baz']], + ]; + } + + /** + * Mock the model's database connection. + */ protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void { $grammarClass = 'Hypervel\Database\Query\Grammars\\' . $database . 'Grammar'; @@ -445,6 +467,9 @@ class ParentModel extends Model protected array $guarded = []; + /** + * Get the parent's children through the pivot model. + */ public function children(): HasManyThrough { return $this->hasManyThrough( diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 36be0914ef..a7ec6c2e2a 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -2731,6 +2731,23 @@ public function testMergeAppendsMergesAppends() $this->assertContains('bar', $model->getAppends()); } + public function testHasAppendedReturnsTrueWhenAttributeIsAppended(): void + { + $model = new AppendsStub; + + $this->assertTrue($model->hasAppended('is_admin')); + $this->assertTrue($model->hasAppended('camelCased')); + $this->assertTrue($model->hasAppended('StudlyCased')); + } + + public function testHasAppendedReturnsFalseWhenAttributeIsNotAppended(): void + { + $model = new AppendsStub; + + $this->assertFalse($model->hasAppended('foo')); + $this->assertFalse($model->hasAppended('bar')); + } + public function testWithoutAppendsRemovesAppends() { $model = new AppendsStub; diff --git a/tests/Database/DatabaseMigrationMigrateCommandTest.php b/tests/Database/DatabaseMigrationMigrateCommandTest.php index 720ec8e555..d4aebd0e5c 100755 --- a/tests/Database/DatabaseMigrationMigrateCommandTest.php +++ b/tests/Database/DatabaseMigrationMigrateCommandTest.php @@ -24,10 +24,12 @@ use Mockery as m; use PDO; use PDOException; +use PHPUnit\Framework\Attributes\TestWith; use RuntimeException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\NullOutput; use Throwable; @@ -197,6 +199,40 @@ public function testSeedOptionRunsSeederAfterMigrations(): void $this->runCommand($command, ['--seed' => true, '--seeder' => 'Database\Seeders\CustomSeeder']); } + #[TestWith([false])] + #[TestWith([true])] + public function testSeedFailureHonorsGracefulOption(bool $graceful): void + { + $app = new ApplicationDatabaseMigrationStub(['path.database' => __DIR__]); + $app->useDatabasePath(__DIR__); + $command = $this->getMockBuilder(MigrateCommand::class) + ->onlyMethods(['call']) + ->setConstructorArgs([$migrator = m::mock(Migrator::class), m::mock(Dispatcher::class)]) + ->getMock(); + $command->setHypervel($app); + $this->expectMigrationPreflight($migrator); + $migrator->shouldReceive('hasRunAnyMigrations')->andReturn(true); + $migrator->shouldReceive('setOutput')->once()->andReturn($migrator); + $migrator->shouldReceive('run')->once(); + $command->expects($this->once())->method('call')->with('db:seed', [ + '--class' => 'Database\Seeders\DatabaseSeeder', + '--force' => true, + ])->willReturn(1); + + if (! $graceful) { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Database seeding failed after migrations ran.'); + } + + $code = $command->run(new ArrayInput([ + '--seed' => true, + '--graceful' => $graceful, + ]), $output = new BufferedOutput); + + $this->assertSame(0, $code); + $this->assertStringContainsString('Database seeding failed after migrations ran.', $output->fetch()); + } + public function testSeedOptionForwardsDatabaseToSeedCommand(): void { // migrate --database=X --seed must forward --database=X to db:seed diff --git a/tests/Database/DatabaseMigrationRefreshCommandTest.php b/tests/Database/DatabaseMigrationRefreshCommandTest.php index b5d5b0fca8..38d3f87ecd 100755 --- a/tests/Database/DatabaseMigrationRefreshCommandTest.php +++ b/tests/Database/DatabaseMigrationRefreshCommandTest.php @@ -14,13 +14,15 @@ use Hypervel\Foundation\Application; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use RuntimeException; use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; class DatabaseMigrationRefreshCommandTest extends TestCase { - public function testRefreshCommandCallsCommandsWithProperArguments() + public function testRefreshCommandCallsCommandsWithProperArguments(): void { $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); $dispatcher = $app->instance(Dispatcher::class, $events = m::mock(Dispatcher::class)->shouldIgnoreMissing()); @@ -49,7 +51,7 @@ public function testRefreshCommandCallsCommandsWithProperArguments() $this->runCommand($command); } - public function testRefreshCommandCallsCommandsWithStep() + public function testRefreshCommandCallsCommandsWithStep(): void { $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); $dispatcher = $app->instance(Dispatcher::class, $events = m::mock(Dispatcher::class)->shouldIgnoreMissing()); @@ -75,10 +77,65 @@ public function testRefreshCommandCallsCommandsWithStep() $migrateCommand->shouldReceive('setHypervel')->once()->with($app); $migrateCommand->shouldReceive('run')->with(new InputMatcher('--force=1 migrate'), m::any()); - $this->runCommand($command, ['--step' => 2]); + $this->runCommand($command, ['--step' => '2']); } - public function testRefreshCommandExitsWhenProhibited() + #[DataProvider('failedCommandProvider')] + public function testChildFailureStopsRefresh(string $failedCommand, array $options, array $expectedOperations, string $message): void + { + $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); + $dispatcher = $app->instance(Dispatcher::class, m::mock(Dispatcher::class)); + $dispatcher->shouldReceive('hasListeners')->byDefault()->andReturnFalse(); + $command = $this->getMockBuilder(RefreshCommand::class)->onlyMethods(['call'])->getMock(); + $command->setHypervel($app); + $operations = []; + $command->expects($this->atLeastOnce())->method('call')->willReturnCallback(function (string $name) use ($failedCommand, &$operations): int { + $operations[] = $name; + + return $name === $failedCommand ? 1 : 0; + }); + $dispatcher->shouldReceive('hasListeners')->with(DatabaseRefreshed::class)->andReturnTrue(); + $dispatcher->shouldReceive('dispatch')->with(m::type(DatabaseRefreshed::class))->andReturnUsing(function () use (&$operations): void { + $operations[] = 'event'; + }); + $caught = null; + + try { + $this->runCommand($command, $options + ['--seed' => true]); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertSame($message, $caught?->getMessage()); + $this->assertSame($expectedOperations, $operations); + } + + /** + * Get the failed refresh command scenarios. + */ + public static function failedCommandProvider(): array + { + return [ + 'reset' => [ + 'migrate:reset', [], ['migrate:reset'], + 'Migration reset failed while refreshing the database.', + ], + 'rollback' => [ + 'migrate:rollback', ['--step' => '2'], ['migrate:rollback'], + 'Migration rollback failed while refreshing the database.', + ], + 'migrate' => [ + 'migrate', [], ['migrate:reset', 'migrate'], + 'Migration command failed while refreshing the database.', + ], + 'seed' => [ + 'db:seed', [], ['migrate:reset', 'migrate', 'event', 'db:seed'], + 'Database seeding failed after the database was refreshed.', + ], + ]; + } + + public function testRefreshCommandExitsWhenProhibited(): void { $app = new ApplicationDatabaseRefreshStub(['path.database' => __DIR__]); $dispatcher = $app->instance(Dispatcher::class, $events = m::mock(Dispatcher::class)->shouldIgnoreMissing()); @@ -98,7 +155,10 @@ public function testRefreshCommandExitsWhenProhibited() $dispatcher->shouldNotReceive('dispatch'); } - protected function runCommand($command, $input = []) + /** + * Run the refresh command. + */ + protected function runCommand(RefreshCommand $command, array $input = []): int { return $command->run(new ArrayInput($input), new NullOutput); } @@ -107,13 +167,18 @@ protected function runCommand($command, $input = []) class InputMatcher extends m\Matcher\MatcherAbstract { /** - * @param \Symfony\Component\Console\Input\ArrayInput $actual + * Match the command input. + * + * @param ArrayInput $actual */ - public function match(&$actual): bool + public function match(mixed &$actual): bool { return (string) $actual === $this->_expected; } + /** + * Get the string representation of the matcher. + */ public function __toString(): string { return ''; @@ -122,6 +187,9 @@ public function __toString(): string class ApplicationDatabaseRefreshStub extends Application { + /** + * Create a new test application instance. + */ public function __construct(array $data = []) { $mutex = m::mock(CommandMutex::class); @@ -137,7 +205,10 @@ public function __construct(array $data = []) static::setInstance($this); } - public function environment(...$environments): bool|string + /** + * Get the application environment. + */ + public function environment(array|string ...$environments): bool|string { return 'development'; } diff --git a/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php b/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php index ef0eee9e8d..c23ad11a5e 100644 --- a/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php +++ b/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php @@ -4,8 +4,10 @@ namespace Hypervel\Tests\Foundation; +use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\FileBasedMaintenanceMode; +use Hypervel\Foundation\WorkerCachedMaintenanceMode; use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; use JsonException; @@ -63,6 +65,42 @@ public function testDataReturnsDecodedPayload(): void $this->assertNull($data['retry']); } + public function testDataReturnsEmptyPayloadWhenFileDoesNotExist(): void + { + $this->assertSame([], (new FileBasedMaintenanceMode)->data()); + } + + public function testCachedActivityReturnsFalseWhenFileDisappearsDuringRead(): void + { + $path = storage_path('framework/down'); + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->with($path)->andReturn(true, false); + $files->shouldReceive('get')->once()->with($path) + ->andThrow(new FileNotFoundException('removed')); + + $mode = new WorkerCachedMaintenanceMode(new FileBasedMaintenanceMode($files)); + + $this->assertFalse($mode->active()); + $this->assertSame([], $mode->data()); + } + + public function testDataRethrowsReadFailureWhenFileStillExists(): void + { + $path = storage_path('framework/down'); + $exception = new FileNotFoundException('unreadable'); + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->once()->with($path)->andReturnTrue(); + $files->shouldReceive('get')->once()->with($path)->andThrow($exception); + + try { + (new FileBasedMaintenanceMode($files))->data(); + + $this->fail('Expected the read failure to be rethrown.'); + } catch (FileNotFoundException $throwable) { + $this->assertSame($exception, $throwable); + } + } + public function testDeactivateDeletesFile(): void { $mode = new FileBasedMaintenanceMode; diff --git a/tests/Foundation/WorkerCachedMaintenanceModeTest.php b/tests/Foundation/WorkerCachedMaintenanceModeTest.php index 1ab1685e96..d356a9a7db 100644 --- a/tests/Foundation/WorkerCachedMaintenanceModeTest.php +++ b/tests/Foundation/WorkerCachedMaintenanceModeTest.php @@ -10,10 +10,11 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; class WorkerCachedMaintenanceModeTest extends TestCase { - public function testActiveCallsDriverOnlyOnceAndCachesResult() + public function testActiveCallsDriverOnlyOnceAndCachesResult(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->once()->andReturn(true); @@ -26,7 +27,7 @@ public function testActiveCallsDriverOnlyOnceAndCachesResult() $this->assertTrue($cached->active()); } - public function testDataReturnsCachedPayloadWithoutRereadingDriver() + public function testDataReturnsCachedPayloadWithoutRereadingDriver(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->once()->andReturn(true); @@ -38,7 +39,7 @@ public function testDataReturnsCachedPayloadWithoutRereadingDriver() $this->assertSame(['status' => 503, 'retry' => 60], $cached->data()); } - public function testActiveAndDataAreLoadedAtomically() + public function testActiveAndDataAreLoadedAtomically(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->once()->andReturn(true); @@ -52,6 +53,22 @@ public function testActiveAndDataAreLoadedAtomically() $this->assertSame(['status' => 503], $cached->data()); } + #[TestWith([false])] + #[TestWith([true])] + public function testEmptyPayloadRechecksActivityBeforeCachingTheSnapshot(bool $remainsActive): void + { + $driver = m::mock(MaintenanceModeContract::class); + $driver->shouldReceive('active')->twice()->andReturn(true, $remainsActive); + $driver->shouldReceive('data')->once()->andReturn([]); + + $cached = new WorkerCachedMaintenanceMode($driver); + + $this->assertSame($remainsActive, $cached->active()); + $this->assertSame([], $cached->data()); + $this->assertSame($remainsActive, $cached->active()); + $this->assertSame([], $cached->data()); + } + public function testSnapshotIsReusedWithinRefreshInterval(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::parse('2026-01-01 00:00:00')); @@ -172,7 +189,7 @@ public function testNegativeRefreshIntervalDisablesTimeRefresh(): void $this->assertFalse($cached->active()); } - public function testFlushCacheResetsSnapshot() + public function testFlushCacheResetsSnapshot(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->twice()->andReturn(true, false); @@ -205,7 +222,7 @@ public function testFlushCacheForcesRefreshWithinInterval(): void $this->assertTrue($cached->active()); } - public function testActivateDelegatesToDriverAndFlushesCache() + public function testActivateDelegatesToDriverAndFlushesCache(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->twice()->andReturn(false, true); @@ -221,7 +238,7 @@ public function testActivateDelegatesToDriverAndFlushesCache() $this->assertTrue($cached->active()); } - public function testDeactivateDelegatesToDriverAndFlushesCache() + public function testDeactivateDelegatesToDriverAndFlushesCache(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->twice()->andReturn(true, false); @@ -237,7 +254,7 @@ public function testDeactivateDelegatesToDriverAndFlushesCache() $this->assertFalse($cached->active()); } - public function testWhenNotActiveDataReturnsEmptyArrayWithoutCallingDriverData() + public function testWhenNotActiveDataReturnsEmptyArrayWithoutCallingDriverData(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->once()->andReturn(false); @@ -248,7 +265,7 @@ public function testWhenNotActiveDataReturnsEmptyArrayWithoutCallingDriverData() $this->assertSame([], $cached->data()); } - public function testAfterFlushAndRereadDecoratorReflectsUpdatedState() + public function testAfterFlushAndRereadDecoratorReflectsUpdatedState(): void { $driver = m::mock(MaintenanceModeContract::class); $driver->shouldReceive('active')->twice()->andReturn(true, false); diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index 128febed12..3f667f23c5 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -2137,12 +2137,22 @@ public function testItDoesNotGenerateJsonErrorsForEmptyContent(): void public function testItClampsValues(): void { - $request = Request::create('/', 'GET', ['per_page' => 100, 'float' => 9.24]); + $request = Request::create('/', 'GET', [ + 'per_page' => 100, + 'float' => 9.24, + 'empty' => '', + 'null' => null, + 'string' => 'string', + ]); + $this->assertSame(100, $request->clamp('per_page', 100, 101)); $this->assertSame(10, $request->clamp('per_page', -10, 10)); $this->assertSame(25, $request->clamp('per_page_2', 25, 100, 1)); $this->assertSame(100, $request->clamp('per_page', 1, 250, 99)); $this->assertSame(22.4, $request->clamp('per_page', 1.11, 22.4, 2)); $this->assertSame(9.24, $request->clamp('float', 1, 10)); + $this->assertSame(15, $request->clamp('empty', 10, 100, 15)); + $this->assertSame(15, $request->clamp('string', 10, 100, 15)); + $this->assertSame(15, $request->clamp('null', 10, 100, 15)); } } diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 3ae0b5e6c6..239280e165 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -221,7 +221,7 @@ public function testCustomPivotClassUsingUpdateExistingPivot() ); } - public function testCustomPivotClassUpdatesTimestamps() + public function testCustomPivotClassUpdatesTimestamps(): void { CarbonImmutable::setTestNow('2017-10-10 10:10:10'); @@ -245,13 +245,8 @@ public function testCustomPivotClassUpdatesTimestamps() foreach ($post->tagsWithCustomExtraPivot as $tag) { $this->assertSame('exclude', $tag->pivot->flag); - if ($this->driver === 'sqlsrv') { - $this->assertSame('2017-10-10 10:10:10.000', $tag->pivot->getAttributes()['created_at']); - $this->assertSame('2017-10-10 10:10:20.000', $tag->pivot->getAttributes()['updated_at']); // +10 seconds - } else { - $this->assertSame('2017-10-10 10:10:10', $tag->pivot->getAttributes()['created_at']); - $this->assertSame('2017-10-10 10:10:20', $tag->pivot->getAttributes()['updated_at']); // +10 seconds - } + $this->assertSame('2017-10-10 10:10:10', $tag->pivot->getAttributes()['created_at']); + $this->assertSame('2017-10-10 10:10:20', $tag->pivot->getAttributes()['updated_at']); // +10 seconds } } diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php index 158a7a514e..05c7f76a3a 100644 --- a/tests/Integration/Database/EloquentHasManyThroughTest.php +++ b/tests/Integration/Database/EloquentHasManyThroughTest.php @@ -5,6 +5,9 @@ namespace Hypervel\Tests\Integration\Database\EloquentHasManyThroughTest; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsTo; +use Hypervel\Database\Eloquent\Relations\HasMany; +use Hypervel\Database\Eloquent\Relations\HasManyThrough; use Hypervel\Database\Eloquent\Relations\HasOneThrough; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; @@ -49,7 +52,7 @@ protected function afterRefreshingDatabase(): void }); } - public function testBasicCreateAndRetrieve() + public function testBasicCreateAndRetrieve(): void { $user = User::create(['name' => Str::random()]); @@ -91,7 +94,7 @@ public function testBasicCreateAndRetrieve() ); } - public function testGlobalScopeColumns() + public function testGlobalScopeColumns(): void { $user = User::create(['name' => Str::random()]); @@ -106,7 +109,7 @@ public function testGlobalScopeColumns() $this->assertEquals(['id' => 2, 'hypervel_through_key' => 1], $teamMates[0]->getAttributes()); } - public function testHasSelf() + public function testHasSelf(): void { $user = User::create(['name' => Str::random()]); @@ -121,7 +124,7 @@ public function testHasSelf() $this->assertCount(1, $users); } - public function testHasSelfCustomOwnerKey() + public function testHasSelfCustomOwnerKey(): void { $user = User::create(['slug' => Str::random(), 'name' => Str::random()]); @@ -136,7 +139,7 @@ public function testHasSelfCustomOwnerKey() $this->assertCount(1, $users); } - public function testHasSameParentAndThroughParentTable() + public function testHasSameParentAndThroughParentTable(): void { Category::create(); Category::create(); @@ -151,7 +154,7 @@ public function testHasSameParentAndThroughParentTable() $this->assertEquals([1], $categories->pluck('id')->all()); } - public function testFirstOrNewOnMissingRecord() + public function testFirstOrNewOnMissingRecord(): void { $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); $team = Team::create(['owner_id' => $taylor->id]); @@ -167,7 +170,26 @@ public function testFirstOrNewOnMissingRecord() $this->assertSame('Tony', $user1->name); } - public function testFirstOrNewWhenRecordExists() + public function testFirstOrNewAcceptsClosureValuesOnMissingRecord(): void + { + $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); + $team = Team::create(['owner_id' => $taylor->id]); + $callCount = 0; + + $user = $taylor->teamMates()->firstOrNew(['slug' => 'tony'], function () use (&$callCount, $team) { + ++$callCount; + + return ['name' => 'Tony', 'team_id' => $team->id]; + }); + + $this->assertSame(1, $callCount); + $this->assertFalse($user->exists); + $this->assertEquals($team->id, $user->team_id); + $this->assertSame('tony', $user->slug); + $this->assertSame('Tony', $user->name); + } + + public function testFirstOrNewWhenRecordExists(): void { $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); $team = Team::create(['owner_id' => $taylor->id]); @@ -188,7 +210,25 @@ public function testFirstOrNewWhenRecordExists() $this->assertSame('Tony Messias', $existingTony->name); } - public function testFirstOrCreateWhenModelDoesntExist() + public function testFirstOrNewDoesNotInvokeClosureValuesWhenRecordExists(): void + { + $taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']); + $team = Team::create(['owner_id' => $taylor->id]); + $existingTony = $team->members()->create(['name' => 'Tony Messias', 'slug' => 'tony']); + $callCount = 0; + + $user = $taylor->teamMates()->firstOrNew(['slug' => 'tony'], function () use (&$callCount) { + ++$callCount; + + return ['name' => 'Tony']; + }); + + $this->assertSame(0, $callCount); + $this->assertTrue($existingTony->is($user)); + $this->assertSame('Tony Messias', $user->name); + } + + public function testFirstOrCreateWhenModelDoesntExist(): void { $owner = User::create(['name' => 'Taylor']); Team::create(['owner_id' => $owner->id]); @@ -201,7 +241,7 @@ public function testFirstOrCreateWhenModelDoesntExist() $this->assertEquals('adam', $mate->slug); } - public function testFirstOrCreateWhenModelExists() + public function testFirstOrCreateWhenModelExists(): void { $owner = User::create(['name' => 'Taylor']); $team = Team::create(['owner_id' => $owner->id]); @@ -217,7 +257,7 @@ public function testFirstOrCreateWhenModelExists() $this->assertEquals('adam', $mate->slug); } - public function testFirstOrCreateRegressionIssue() + public function testFirstOrCreateRegressionIssue(): void { $team1 = Team::create(); $team2 = Team::create(); @@ -244,7 +284,7 @@ public function testFirstOrCreateRegressionIssue() $this->assertSame('Jane', $jane->name); } - public function testCreateOrFirstWhenRecordDoesntExist() + public function testCreateOrFirstWhenRecordDoesntExist(): void { $team = Team::create(); $tony = $team->members()->create(['name' => 'Tony']); @@ -259,7 +299,7 @@ public function testCreateOrFirstWhenRecordDoesntExist() $this->assertTrue($tony->is($article->user)); } - public function testCreateOrFirstWhenRecordExists() + public function testCreateOrFirstWhenRecordExists(): void { $team = Team::create(); $taylor = $team->members()->create(['name' => 'Taylor']); @@ -280,7 +320,7 @@ public function testCreateOrFirstWhenRecordExists() $this->assertTrue($existingArticle->is($newArticle)); } - public function testCreateOrFirstWhenRecordExistsInTransaction() + public function testCreateOrFirstWhenRecordExistsInTransaction(): void { $team = Team::create(); $taylor = $team->members()->create(['name' => 'Taylor']); @@ -301,7 +341,7 @@ public function testCreateOrFirstWhenRecordExistsInTransaction() $this->assertTrue($existingArticle->is($newArticle)); } - public function testCreateOrFirstRegressionIssue() + public function testCreateOrFirstRegressionIssue(): void { $team1 = Team::create(); @@ -326,7 +366,7 @@ public function testCreateOrFirstRegressionIssue() $this->assertTrue($tony->is($existingTonyArticle->user)); } - public function testUpdateOrCreateAffectingWrongModelsRegression() + public function testUpdateOrCreateAffectingWrongModelsRegression(): void { // On Laravel 10.21.0, a bug was introduced that would update the wrong model when using `updateOrCreate()`, // because the UPDATE statement would target a model based on the ID from the parent instead of the actual @@ -355,8 +395,8 @@ public function testUpdateOrCreateAffectingWrongModelsRegression() $this->assertSame('jane-slug', $jane->refresh()->slug); // The `updateOrCreate` method would first try to find a matching attached record with a query like: - // `->where($attributes)->first()`, which should return `John` of ID 1 in our case. However, it'd - // return the incorrect ID of 2, which caused it to update Jane's record instead of John's. + // `->where($attributes)->first()`, which should return `John` of ID 2 in our case. However, it'd + // return the incorrect ID of 1, which caused it to update Jane's record instead of John's. $taylor->teamMates()->updateOrCreate([ 'name' => 'John', @@ -370,7 +410,7 @@ public function testUpdateOrCreateAffectingWrongModelsRegression() $this->assertSame('jane-slug', $jane->fresh()->slug); } - public function testCanReplicateModelLoadedThroughHasManyThrough() + public function testCanReplicateModelLoadedThroughHasManyThrough(): void { $team = Team::create(); $user = User::create(['team_id' => $team->id, 'name' => 'John']); @@ -406,50 +446,77 @@ class User extends Model protected array $guarded = []; - public function teamMates() + /** + * Get the members of the user's teams. + */ + public function teamMates(): HasManyThrough { return $this->hasManyThrough(self::class, Team::class, 'owner_id', 'team_id'); } - public function teamMatesWithPendingRelation() + /** + * Get team members using the fluent through relationship. + */ + public function teamMatesWithPendingRelation(): HasManyThrough { return $this->through($this->ownedTeams()) ->has(fn (Team $team) => $team->members()); } - public function teamMatesBySlug() + /** + * Get team members using the owner's slug. + */ + public function teamMatesBySlug(): HasManyThrough { return $this->hasManyThrough(self::class, Team::class, 'owner_slug', 'team_id', 'slug'); } - public function teamMatesBySlugWithPendingRelationship() + /** + * Get team members through the owner's slug using a fluent relationship. + */ + public function teamMatesBySlugWithPendingRelationship(): HasManyThrough { return $this->through($this->hasMany(Team::class, 'owner_slug', 'slug')) ->has(fn ($team) => $team->hasMany(User::class, 'team_id')); } - public function teamMatesWithGlobalScope() + /** + * Get team members with the global column scope. + */ + public function teamMatesWithGlobalScope(): HasManyThrough { return $this->hasManyThrough(UserWithGlobalScope::class, Team::class, 'owner_id', 'team_id'); } - public function teamMatesWithGlobalScopeWithPendingRelation() + /** + * Get scoped team members using the fluent through relationship. + */ + public function teamMatesWithGlobalScopeWithPendingRelation(): HasManyThrough { return $this->through($this->ownedTeams()) ->has(fn (Team $team) => $team->membersWithGlobalScope()); } - public function ownedTeams() + /** + * Get the teams owned by the user. + */ + public function ownedTeams(): HasMany { return $this->hasMany(Team::class, 'owner_id'); } - public function team() + /** + * Get the user's team. + */ + public function team(): BelongsTo { return $this->belongsTo(Team::class); } - public function articles() + /** + * Get the user's articles. + */ + public function articles(): HasMany { return $this->hasMany(Article::class); } @@ -463,6 +530,9 @@ class UserWithGlobalScope extends Model protected array $guarded = []; + /** + * Boot the model's global column scope. + */ public static function boot(): void { parent::boot(); @@ -481,21 +551,33 @@ class Team extends Model protected array $guarded = []; - public function members() + /** + * Get the team's members. + */ + public function members(): HasMany { return $this->hasMany(User::class, 'team_id'); } - public function membersWithGlobalScope() + /** + * Get team members with the global column scope. + */ + public function membersWithGlobalScope(): HasMany { return $this->hasMany(UserWithGlobalScope::class, 'team_id'); } - public function articles() + /** + * Get articles written by the team's members. + */ + public function articles(): HasManyThrough { return $this->hasManyThrough(Article::class, User::class); } + /** + * Get the team's latest article. + */ public function latestArticle(): HasOneThrough { return $this->articles()->one()->latest(); @@ -510,7 +592,10 @@ class Category extends Model protected array $guarded = []; - public function subProducts() + /** + * Get products in the category's child categories. + */ + public function subProducts(): HasManyThrough { return $this->hasManyThrough(Product::class, self::class, 'parent_id'); } @@ -527,7 +612,10 @@ class Article extends Model { protected array $guarded = []; - public function user() + /** + * Get the article's author. + */ + public function user(): BelongsTo { return $this->belongsTo(User::class); } diff --git a/tests/Integration/Database/EloquentUpdateTest.php b/tests/Integration/Database/EloquentUpdateTest.php index e40471acba..dc1dcaf160 100644 --- a/tests/Integration/Database/EloquentUpdateTest.php +++ b/tests/Integration/Database/EloquentUpdateTest.php @@ -49,12 +49,8 @@ public function testBasicUpdate() $this->assertCount(0, TestUpdateModel1::all()); } - public function testUpdateWithLimitsAndOrders() + public function testUpdateWithLimitsAndOrders(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('The limit keyword is not supported on MSSQL.'); - } - for ($i = 1; $i <= 10; ++$i) { TestUpdateModel1::create(); } diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index 64c20a4c82..87b68f0d1c 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -928,10 +928,13 @@ protected function definePrefixedEnvironment(ApplicationContract $app): void $config->set("database.connections.{$connection}.prefix", 'app_'); } - protected function defineEnvironmentWouldThrowsPDOException($app): void + /** + * Expect invalid date operators to fail on PostgreSQL. + */ + protected function defineEnvironmentWouldThrowsPDOException(ApplicationContract $app): void { $this->afterApplicationCreated(function () { - if (in_array($this->driver, ['pgsql', 'sqlsrv'])) { + if ($this->driver === 'pgsql') { $this->expectException(PDOException::class); } }); diff --git a/tests/Integration/Database/QueryBuilderWhereLikeTest.php b/tests/Integration/Database/QueryBuilderWhereLikeTest.php index 1bb2ad315c..b31bf2275d 100644 --- a/tests/Integration/Database/QueryBuilderWhereLikeTest.php +++ b/tests/Integration/Database/QueryBuilderWhereLikeTest.php @@ -62,12 +62,8 @@ public function testWhereLikeWithUnderscoreWildcard() $this->assertSame('Dale.Doe@example.com', $users[1]->email); } - public function testWhereLikeCaseSensitive() + public function testWhereLikeCaseSensitive(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); - } - $users = DB::table('users')->whereLike('email', 'john.doe@example.com', true)->get(); $this->assertCount(0, $users); @@ -77,12 +73,8 @@ public function testWhereLikeCaseSensitive() $this->assertSame(5, DB::table('users')->whereNotLike('email', 'john.doe@example.com', true)->count()); } - public function testWhereLikeWithPercentWildcardCaseSensitive() + public function testWhereLikeWithPercentWildcardCaseSensitive(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); - } - $this->assertSame(2, DB::table('users')->whereLike('email', '%Doe@example.com', true)->count()); $this->assertSame(4, DB::table('users')->whereNotLike('email', '%smith%', true)->count()); @@ -92,12 +84,8 @@ public function testWhereLikeWithPercentWildcardCaseSensitive() $this->assertSame('Dale.Doe@example.com', $users[1]->email); } - public function testWhereLikeWithUnderscoreWildcardCaseSensitive() + public function testWhereLikeWithUnderscoreWildcardCaseSensitive(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('The case-sensitive whereLike clause is not supported on MSSQL.'); - } - $users = DB::table('users')->whereLike('email', 'j__edoe@example.com', true)->get(); $this->assertCount(1, $users); $this->assertSame('janedoe@example.com', $users[0]->email); diff --git a/tests/Integration/Database/SchemaBuilderTest.php b/tests/Integration/Database/SchemaBuilderTest.php index 25ffae89d4..f984967b5e 100644 --- a/tests/Integration/Database/SchemaBuilderTest.php +++ b/tests/Integration/Database/SchemaBuilderTest.php @@ -215,12 +215,8 @@ public function testCompoundPrimaryWithAutoIncrement() $this->assertTrue(Schema::hasIndex('test', ['id', 'uuid'], 'primary')); } - public function testModifyingAutoIncrementColumn() + public function testModifyingAutoIncrementColumn(): void { - if ($this->driver === 'sqlsrv') { - $this->markTestSkipped('Changing a primary column is not supported on SQL Server.'); - } - Schema::create('test', function (Blueprint $table) { $table->increments('id'); }); @@ -236,10 +232,10 @@ public function testModifyingAutoIncrementColumn() $this->assertTrue(Schema::hasIndex('test', ['id'], 'primary')); } - public function testModifyingColumnToAutoIncrementColumn() + public function testModifyingColumnToAutoIncrementColumn(): void { - if (in_array($this->driver, ['pgsql', 'sqlsrv'])) { - $this->markTestSkipped('Changing a column to auto increment is not supported on PostgreSQL and SQL Server.'); + if ($this->driver === 'pgsql') { + $this->markTestSkipped('Changing a column to auto increment is not supported on PostgreSQL.'); } Schema::create('test', function (Blueprint $table) { @@ -800,19 +796,17 @@ public function testModifyingStoredColumnOnSqlite() )); } - #[RequiresDatabase('pgsql', '>=18')] - public function testGettingGeneratedColumns() + public function testGettingGeneratedColumns(): void { + if ($this->driver === 'pgsql' && version_compare($this->getConnection()->getServerVersion(), '18', '<')) { + $this->markTestSkipped('Test requires a PostgreSQL connection >= 18'); + } + Schema::create('test', function (Blueprint $table) { $table->integer('price'); - if ($this->driver === 'sqlsrv') { - $table->computed('virtual_price', 'price - 5'); - $table->computed('stored_price', 'price - 10')->persisted(); - } else { - $table->integer('virtual_price')->virtualAs('price - 5'); - $table->integer('stored_price')->storedAs('price - 10'); - } + $table->integer('virtual_price')->virtualAs('price - 5'); + $table->integer('stored_price')->storedAs('price - 10'); }); $columns = Schema::getColumns('test'); @@ -826,7 +820,6 @@ public function testGettingGeneratedColumns() && match ($this->driver) { 'mysql' => $column['generation']['expression'] === '(`price` - 5)', 'mariadb' => $column['generation']['expression'] === '`price` - 5', - 'sqlsrv' => $column['generation']['expression'] === '([price]-(5))', 'pgsql' => $column['generation']['expression'] === '(price - 5)', default => $column['generation']['expression'] === 'price - 5', } @@ -837,7 +830,6 @@ public function testGettingGeneratedColumns() && match ($this->driver) { 'mysql' => $column['generation']['expression'] === '(`price` - 10)', 'mariadb' => $column['generation']['expression'] === '`price` - 10', - 'sqlsrv' => $column['generation']['expression'] === '([price]-(10))', 'pgsql' => $column['generation']['expression'] === '(price - 10)', default => $column['generation']['expression'] === 'price - 10', } diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index ab1822c3d2..c216721b0e 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -4,15 +4,20 @@ namespace Hypervel\Tests\Integration\Foundation; +use Hypervel\Contracts\Cache\Factory; +use Hypervel\Contracts\Cache\Repository; use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Debug\ExceptionHandler; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; +use Hypervel\Filesystem\Filesystem; +use Hypervel\Foundation\CacheBasedMaintenanceMode; use Hypervel\Foundation\Console\DownCommand; use Hypervel\Foundation\Console\UpCommand; use Hypervel\Foundation\Events\MaintenanceModeDisabled; use Hypervel\Foundation\Events\MaintenanceModeEnabled; +use Hypervel\Foundation\FileBasedMaintenanceMode; use Hypervel\Foundation\Http\MaintenanceModeBypassCookie; use Hypervel\Foundation\Http\Middleware\PreventRequestsDuringMaintenance; use Hypervel\Support\CarbonImmutable; @@ -27,9 +32,12 @@ class MaintenanceModeTest extends TestCase { + /** + * Set up the test environment. + */ protected function setUp(): void { - $this->beforeApplicationDestroyed(function () { + $this->beforeApplicationDestroyed(function (): void { @unlink(storage_path('framework/down')); @unlink(resource_path('views/errors/503.blade.php')); }); @@ -37,6 +45,9 @@ protected function setUp(): void parent::setUp(); } + /** + * Tear down the test environment. + */ protected function tearDown(): void { FailingReloadDownCommand::$reloadAttempted = false; @@ -47,14 +58,14 @@ protected function tearDown(): void parent::tearDown(); } - public function testBasicMaintenanceModeResponse() + public function testBasicMaintenanceModeResponse(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, 'refresh' => 60, ])); - Route::get('/foo', function () { + Route::get('/foo', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -65,14 +76,54 @@ public function testBasicMaintenanceModeResponse() $response->assertHeader('Refresh', '60'); } + public function testCacheMaintenanceModeAllowsRequestWhenDeactivatedWhileReadingPayload(): void + { + $cache = m::mock(Factory::class, Repository::class); + $cache->shouldReceive('store')->with('maintenance')->andReturnSelf(); + $cache->shouldReceive('has')->with('framework:down')->andReturn(true, false); + $cache->shouldReceive('get')->once()->with('framework:down')->andReturnNull(); + + $this->app->instance(MaintenanceModeContract::class, new CacheBasedMaintenanceMode( + $cache, + 'maintenance', + 'framework:down' + )); + + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + + $this->get('/foo') + ->assertOk() + ->assertSeeText('Hello World'); + } + + public function testActiveCacheMaintenanceModeWithAnEmptyPayloadBlocksRequests(): void + { + $cache = m::mock(Factory::class, Repository::class); + $cache->shouldReceive('store')->with('maintenance')->andReturnSelf(); + $cache->shouldReceive('has')->with('framework:down')->andReturnTrue(); + $cache->shouldReceive('get')->once()->with('framework:down')->andReturn([]); + + $this->app->instance(MaintenanceModeContract::class, new CacheBasedMaintenanceMode( + $cache, + 'maintenance', + 'framework:down' + )); + + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + + $this->get('/foo')->assertServiceUnavailable(); + } + public function testConcurrentMaintenanceFileRemovalAllowsTheRequestToProceed(): void { - $mode = m::mock(MaintenanceModeContract::class); - $mode->shouldReceive('active')->twice()->andReturnTrue(); - $mode->shouldReceive('data')->twice()->andThrow(new FileNotFoundException('removed')); - $this->app->instance(MaintenanceModeContract::class, $mode); + $path = storage_path('framework/down'); + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->with($path)->andReturn(true, false); + $files->shouldReceive('get')->once()->with($path) + ->andThrow(new FileNotFoundException('removed')); + $this->app->instance(MaintenanceModeContract::class, new FileBasedMaintenanceMode($files)); - Route::get('/foo', fn () => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); $response = $this->get('/foo'); @@ -80,14 +131,36 @@ public function testConcurrentMaintenanceFileRemovalAllowsTheRequestToProceed(): $this->assertSame('Hello World', $response->original); } - public function testMaintenanceModeCanHaveCustomStatus() + public function testUnreadableMaintenanceFileDoesNotAllowTheRequestToProceed(): void + { + $this->withoutExceptionHandling(); + + $path = storage_path('framework/down'); + $exception = new FileNotFoundException('unreadable'); + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->with($path)->andReturnTrue(); + $files->shouldReceive('get')->once()->with($path)->andThrow($exception); + $this->app->instance(MaintenanceModeContract::class, new FileBasedMaintenanceMode($files)); + + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + + try { + $this->get('/foo'); + + $this->fail('Expected the read failure to be rethrown.'); + } catch (FileNotFoundException $throwable) { + $this->assertSame($exception, $throwable); + } + } + + public function testMaintenanceModeCanHaveCustomStatus(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, 'status' => 200, ])); - Route::get('/foo', function () { + Route::get('/foo', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -97,14 +170,14 @@ public function testMaintenanceModeCanHaveCustomStatus() $response->assertHeader('Retry-After', '60'); } - public function testMaintenanceModeCanHaveCustomTemplate() + public function testMaintenanceModeCanHaveCustomTemplate(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, 'template' => 'Rendered Content', ])); - Route::get('/foo', function () { + Route::get('/foo', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -123,7 +196,7 @@ public function testMaintenanceModeDoesNotUseCustomTemplateForJsonRequests(): vo 'template' => 'Rendered Content', ])); - Route::get('/foo', fn () => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); $response = $this->getJson('/foo'); @@ -141,7 +214,7 @@ public function testMaintenanceModeDoesNotRedirectJsonRequests(): void 'redirect' => '/maintenance', ])); - Route::get('/foo', fn () => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); $response = $this->getJson('/foo'); @@ -151,7 +224,7 @@ public function testMaintenanceModeDoesNotRedirectJsonRequests(): void $response->assertJson(['message' => 'Service Unavailable']); } - public function testDownCommandPrerendersTemplateIntoMaintenancePayload() + public function testDownCommandPrerendersTemplateIntoMaintenancePayload(): void { file_put_contents(resource_path('views/errors/503.blade.php'), 'Rendered {{ $retryAfter }}'); @@ -175,7 +248,7 @@ public function testDownCommandReportsARelativeBypassPathWithoutACanonicalApplic ->assertExitCode(0); } - public function testMaintenanceModeCanRedirectWithBypassCookie() + public function testMaintenanceModeCanRedirectWithBypassCookie(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, @@ -183,7 +256,7 @@ public function testMaintenanceModeCanRedirectWithBypassCookie() 'template' => 'Rendered Content', ])); - Route::get('/foo', function () { + Route::get('/foo', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -193,7 +266,7 @@ public function testMaintenanceModeCanRedirectWithBypassCookie() $response->assertCookie('hypervel_maintenance'); } - public function testMaintenanceModeCanBeBypassedWithValidCookie() + public function testMaintenanceModeCanBeBypassedWithValidCookie(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, @@ -202,7 +275,7 @@ public function testMaintenanceModeCanBeBypassedWithValidCookie() $cookie = MaintenanceModeBypassCookie::create('foo'); - Route::get('/test', function () { + Route::get('/test', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -214,7 +287,7 @@ public function testMaintenanceModeCanBeBypassedWithValidCookie() $this->assertSame('Hello World', $response->original); } - public function testMaintenanceModeCanBeBypassedOnExcludedUrls() + public function testMaintenanceModeCanBeBypassedOnExcludedUrls(): void { $this->app->instance(PreventRequestsDuringMaintenance::class, new class($this->app) extends PreventRequestsDuringMaintenance { protected array $except = ['/test']; @@ -224,7 +297,7 @@ public function testMaintenanceModeCanBeBypassedOnExcludedUrls() 'retry' => 60, ])); - Route::get('/test', fn () => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + Route::get('/test', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); $response = $this->get('/test'); @@ -232,7 +305,7 @@ public function testMaintenanceModeCanBeBypassedOnExcludedUrls() $this->assertSame('Hello World', $response->original); } - public function testMaintenanceModeCantBeBypassedWithInvalidCookie() + public function testMaintenanceModeCantBeBypassedWithInvalidCookie(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, @@ -241,7 +314,7 @@ public function testMaintenanceModeCantBeBypassedWithInvalidCookie() $cookie = MaintenanceModeBypassCookie::create('test-key'); - Route::get('/test', function () { + Route::get('/test', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -266,7 +339,16 @@ public function testCanCreateBypassCookies(): void $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'test-key')); } - public function testDispatchEventWhenMaintenanceModeIsEnabled() + public function testBypassCookieWithMalformedMacIsInvalid(): void + { + foreach ([['mac' => []], ['mac' => ['nested']], ['expires_at' => 9999999999]] as $payload) { + $cookie = base64_encode(json_encode(array_merge(['expires_at' => 9999999999], $payload))); + + $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie, 'test-key')); + } + } + + public function testDispatchEventWhenMaintenanceModeIsEnabled(): void { Event::fake(); @@ -275,7 +357,7 @@ public function testDispatchEventWhenMaintenanceModeIsEnabled() Event::assertDispatched(MaintenanceModeEnabled::class); } - public function testDispatchEventWhenMaintenanceModeIsDisabled() + public function testDispatchEventWhenMaintenanceModeIsDisabled(): void { file_put_contents(storage_path('framework/down'), json_encode([ 'retry' => 60, @@ -323,7 +405,7 @@ public function testDownAttemptsReloadAfterEventFailureAndPreservesTheEventFailu $this->app->make(KernelContract::class)->registerCommand($command); $this->app->make('events')->listen( MaintenanceModeEnabled::class, - static fn () => throw $eventException, + static fn (): never => throw $eventException, ); $handler = m::mock(ExceptionHandler::class); @@ -364,7 +446,7 @@ public function testUpAttemptsReloadAfterEventFailureAndPreservesTheEventFailure $this->app->make(KernelContract::class)->registerCommand($command); $this->app->make('events')->listen( MaintenanceModeDisabled::class, - static fn () => throw $eventException, + static fn (): never => throw $eventException, ); $this->artisan(FailingReloadUpCommand::class) @@ -423,6 +505,9 @@ public function testMaintenanceModeRetryCanAcceptDatetime(string $datetime): voi CarbonImmutable::setTestNow(); } + /** + * Get the supported retry date formats. + */ public static function retryAfterDatetimeProvider(): array { return [ @@ -450,7 +535,7 @@ public function testMaintenanceModeRetryWithHttpDateHeader(): void 'retry' => $expectedHeader, ])); - Route::get('/foo', fn () => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); + Route::get('/foo', fn (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); $response = $this->get('/foo'); @@ -494,7 +579,7 @@ public function testMaintenanceModeCanBeRefreshedWithNewOptions(): void $this->assertSame(120, $data['retry']); } - public function testMaintenanceModeRespectsBootstrapConfiguredExcludedPaths() + public function testMaintenanceModeRespectsBootstrapConfiguredExcludedPaths(): void { PreventRequestsDuringMaintenance::except([ '/api/*', @@ -517,6 +602,9 @@ class FailingReloadDownCommand extends DownCommand public static ?Throwable $reloadFailure = null; + /** + * Simulate a worker reload failure. + */ protected function reloadWorkers(): void { static::$reloadAttempted = true; @@ -533,6 +621,9 @@ class FailingReloadUpCommand extends UpCommand public static ?Throwable $reloadFailure = null; + /** + * Simulate a worker reload failure. + */ protected function reloadWorkers(): void { static::$reloadAttempted = true; diff --git a/tests/Integration/Validation/Rules/FileValidationTest.php b/tests/Integration/Validation/Rules/FileValidationTest.php index a30ac6b88c..5c3825ce00 100644 --- a/tests/Integration/Validation/Rules/FileValidationTest.php +++ b/tests/Integration/Validation/Rules/FileValidationTest.php @@ -94,7 +94,7 @@ public function testFileCustomValidationMessages(string $fileClass): void ], $validator->messages()->all()); } - public function testFileMimesCustomValidationMessages() + public function testFileMimesCustomValidationMessages(): void { $validator = Validator::make( ['document' => UploadedFile::fake()->create('file.pdf')], @@ -106,7 +106,7 @@ public function testFileMimesCustomValidationMessages() $this->assertSame(['Wrong file type'], $validator->messages()->all()); } - public function testFileMinSizeCustomValidationMessages() + public function testFileMinSizeCustomValidationMessages(): void { $validator = Validator::make( ['upload' => UploadedFile::fake()->create('small.pdf', 50)], @@ -118,7 +118,7 @@ public function testFileMinSizeCustomValidationMessages() $this->assertSame(['File too small'], $validator->messages()->all()); } - public function testFileMaxSizeCustomValidationMessages() + public function testFileMaxSizeCustomValidationMessages(): void { $validator = Validator::make( ['upload' => UploadedFile::fake()->create('large.pdf', 2000)], @@ -130,7 +130,7 @@ public function testFileMaxSizeCustomValidationMessages() $this->assertSame(['File exceeds limit'], $validator->messages()->all()); } - public function testFileDimensionCustomValidationMessages() + public function testFileDimensionCustomValidationMessages(): void { $validator = Validator::make( ['image' => UploadedFile::fake()->image('foo.jpg', 100, 100)], @@ -142,7 +142,7 @@ public function testFileDimensionCustomValidationMessages() $this->assertSame(['Invalid dimensions'], $validator->messages()->all()); } - public function testFileBetweenCustomValidationMessages() + public function testFileBetweenCustomValidationMessages(): void { $validator = Validator::make( ['file' => UploadedFile::fake()->create('foo.pdf', 10)], @@ -154,7 +154,7 @@ public function testFileBetweenCustomValidationMessages() $this->assertSame(['Size out of range'], $validator->messages()->all()); } - public function testImageCustomValidationMessages() + public function testImageCustomValidationMessages(): void { $validator = Validator::make( ['avatar' => UploadedFile::fake()->create('foo.txt')], @@ -166,7 +166,7 @@ public function testImageCustomValidationMessages() $this->assertSame(['Not an image'], $validator->messages()->all()); } - public function testFileMultipleCustomValidationMessages() + public function testFileMultipleCustomValidationMessages(): void { $validator = Validator::make( ['photo' => UploadedFile::fake()->create('foo.pdf', 5000)], @@ -185,7 +185,7 @@ public function testFileMultipleCustomValidationMessages() $this->assertContains('Too large', $messages); } - public function testFileSizeCustomValidationMessages() + public function testFileSizeCustomValidationMessages(): void { $validator = Validator::make( ['file' => UploadedFile::fake()->create('doc.pdf', 500)], @@ -197,7 +197,7 @@ public function testFileSizeCustomValidationMessages() $this->assertSame(['File must be exactly 100KB'], $validator->messages()->all()); } - public function testFileExtensionsCustomValidationMessages() + public function testFileExtensionsCustomValidationMessages(): void { $validator = Validator::make( ['file' => UploadedFile::fake()->create('foo.pdf')], @@ -209,7 +209,7 @@ public function testFileExtensionsCustomValidationMessages() $this->assertSame(['Invalid file extension'], $validator->messages()->all()); } - public function testFileEncodingCustomValidationMessages() + public function testFileEncodingCustomValidationMessages(): void { $validator = Validator::make( ['file' => UploadedFile::fake()->createWithContent('foo.txt', "\xf0\x28\x8c\x28")], diff --git a/tests/Mail/MailMailableAssertionsTest.php b/tests/Mail/MailMailableAssertionsTest.php index 098aab394a..b1c5f078ff 100644 --- a/tests/Mail/MailMailableAssertionsTest.php +++ b/tests/Mail/MailMailableAssertionsTest.php @@ -254,27 +254,11 @@ public function testMailableOrderedHtmlAssertionsDoNotSkipStringZero(): void } } -class MailableAssertionsBladeEscapedStub extends Mailable -{ - protected function renderForAssertions(): array - { - $text = "It's a wonderful day"; - - $html = <<<'EOD' - - - -
It's a wonderful day
- - - EOD; - - return [$html, $text]; - } -} - class MailableAssertionsStub extends Mailable { + /** + * Render the HTML and plain-text version of the mailable into views for assertions. + */ protected function renderForAssertions(): array { $text = <<<'EOD' @@ -315,3 +299,25 @@ protected function renderForAssertions(): array return [$html, $text]; } } + +class MailableAssertionsBladeEscapedStub extends Mailable +{ + /** + * Render the HTML and plain-text version of the mailable into views for assertions. + */ + protected function renderForAssertions(): array + { + $text = "It's a wonderful day"; + + $html = <<<'EOD' + + + +
It's a wonderful day
+ + + EOD; + + return [$html, $text]; + } +} diff --git a/tests/Notifications/NotificationChannelManagerTest.php b/tests/Notifications/NotificationChannelManagerTest.php index bf8bcb6215..80b7a253fb 100644 --- a/tests/Notifications/NotificationChannelManagerTest.php +++ b/tests/Notifications/NotificationChannelManagerTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Notifications; +use Closure; use Exception; use Hypervel\Bus\Queueable; use Hypervel\Config\Repository as ConfigRepository; @@ -423,7 +424,7 @@ public function testQueuedNotificationForwardsDeduplicatorSetFromClassToQueueJob $container->make(BusDispatcherContract::class) ->shouldReceive('dispatch')->twice()->withArgs(function ($job) { $this->assertInstanceOf(SendQueuedNotifications::class, $job); - $this->assertEquals($job->notification->deduplicatorResults[$job->channels[0]], call_user_func($job->deduplicator, '', null)); + $this->assertEquals($job->notification->deduplicatorResults[$job->channels[0]], call_user_func($job->deduplicator, 'payload', 'queue')); return true; }); @@ -466,11 +467,11 @@ public function testAfterSendingMethodAfterSendingNotification(): void $events = $container->make(Dispatcher::class); $manager = m::mock(ChannelManager::class . '[driver]', [$container]); - $manager->shouldReceive('driver')->andReturn($driver = m::mock()); - $events->shouldReceive('until')->with(m::type(NotificationSending::class))->andReturn(true); + $manager->shouldReceive('driver')->once()->andReturn($driver = m::mock()); + $events->shouldReceive('until')->once()->with(m::type(NotificationSending::class))->andReturn(true); $driver->shouldReceive('send')->once()->andReturn($response = m::mock()); - $events->shouldReceive('dispatch')->with(m::type(NotificationDelivered::class)); - $events->shouldReceive('dispatch')->with(m::type(NotificationSent::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationDelivered::class)); + $events->shouldReceive('dispatch')->once()->with(m::type(NotificationSent::class)); $manager->send($notifiable = new NotificationChannelManagerTestNotifiable, new NotificationChannelManagerWithAfterSendingMethodNotification); @@ -520,43 +521,46 @@ class NotificationChannelManagerTestNotifiable class NotificationChannelManagerTestNotification extends Notification { - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test']; } - - public function message() - { - return $this->line('test')->action('Text', 'url'); - } } class NotificationChannelManagerTestNotificationWithTwoChannels extends Notification { - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - - public function message() - { - return $this->line('test')->action('Text', 'url'); - } } class NotificationChannelManagerTestCancelledNotification extends Notification { - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function shouldSend($notifiable, $channel) + /** + * Determine if the notification should be sent. + */ + public function shouldSend(mixed $notifiable, string $channel): bool { return false; } @@ -564,17 +568,20 @@ public function shouldSend($notifiable, $channel) class NotificationChannelManagerTestNotCancelledNotification extends Notification { - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function shouldSend($notifiable, $channel) + /** + * Determine if the notification should be sent. + */ + public function shouldSend(mixed $notifiable, string $channel): bool { return true; } @@ -584,47 +591,50 @@ class NotificationChannelManagerTestQueuedNotification extends Notification impl { use Queueable; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test']; } - - public function message() - { - return $this->line('test')->action('Text', 'url'); - } } class NotificationChannelManagerTestQueuedNotificationWithTwoChannels extends Notification implements ShouldQueue { use Queueable; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - - public function message() - { - return $this->line('test')->action('Text', 'url'); - } } class NotificationChannelManagerTestQueuedNotificationWithMessageGroupMethod extends Notification implements ShouldQueue { use Queueable; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function messageGroup() + /** + * Get the notification's message group. + */ + public function messageGroup(): string { return 'group-1'; } @@ -634,17 +644,20 @@ class NotificationChannelManagerTestQueuedNotificationWithMessageGroups extends { use Queueable; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function withMessageGroups($notifiable, $channel) + /** + * Get the message group for the notification's channel. + */ + public function withMessageGroups(mixed $notifiable, string $channel): ?string { return match ($channel) { 'test' => 'group-1', @@ -658,26 +671,34 @@ class NotificationChannelManagerTestQueuedNotificationWithDeduplicators extends { use Queueable; + /** + * @var array + */ public array $deduplicatorResults = [ 'test' => 'deduplication-id-1', 'test2' => 'deduplication-id-2', ]; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function withDeduplicators($notifiable, $channel) + /** + * Get the deduplicator for the notification's channel. + * + * @return null|Closure(string, string): string + */ + public function withDeduplicators(mixed $notifiable, string $channel): ?Closure { return match ($channel) { - 'test' => fn ($payload, $queue) => $this->deduplicatorResults['test'], - 'test2' => fn ($payload, $queue) => $this->deduplicatorResults['test2'], + 'test' => fn (string $payload, string $queue): string => $this->deduplicatorResults['test'], + 'test2' => fn (string $payload, string $queue): string => $this->deduplicatorResults['test2'], default => null, }; } @@ -687,17 +708,20 @@ class NotificationChannelManagerTestQueuedNotificationWithDeduplicationId extend { use Queueable; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test', 'test2']; } - public function message() - { - return $this->line('test')->action('Text', 'url'); - } - - public function deduplicationId($payload, $queue) + /** + * Get the notification's deduplication ID. + */ + public function deduplicationId(string $payload, string $queue): string { return 'deduplication-id-1'; } @@ -711,12 +735,20 @@ class NotificationChannelManagerWithAfterSendingMethodNotification extends Notif public static mixed $afterSendingResponse = null; - public function via() + /** + * Get the notification's delivery channels. + * + * @return list + */ + public function via(): array { return ['test']; } - public function afterSending($notifiable, $channel, $response) + /** + * Handle the notification after it has been sent. + */ + public function afterSending(mixed $notifiable, string $channel, mixed $response): void { static::$afterSendingNotifiable = $notifiable; static::$afterSendingChannel = $channel; diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 0f9de1153e..fe3c3667a9 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -1379,6 +1379,14 @@ public function testBetween($collection): void ); $this->assertEquals([['v' => 1]], $c->whereBetween('v', [-1, 1])->all()); $this->assertEquals([['v' => 3], ['v' => '3']], $c->whereBetween('v', [3, 3])->values()->all()); + $this->assertSame( + [['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->whereBetween('v', new Collection([2, 4]))->values()->all() + ); + $this->assertSame( + [['v' => 2], ['v' => 3], ['v' => '3'], ['v' => 4]], + $c->whereBetween('v', LazyCollection::range(2, 4, 2)->getIterator())->values()->all() + ); } #[DataProvider('collectionClassProvider')] @@ -1389,6 +1397,8 @@ public function testWhereNotBetween($collection): void $this->assertEquals([['v' => 1]], $c->whereNotBetween('v', [2, 4])->values()->all()); $this->assertEquals([['v' => 2], ['v' => 3], ['v' => 3], ['v' => 4]], $c->whereNotBetween('v', [-1, 1])->values()->all()); $this->assertEquals([['v' => 1], ['v' => '2'], ['v' => '4']], $c->whereNotBetween('v', [3, 3])->values()->all()); + $this->assertSame([['v' => 1]], $c->whereNotBetween('v', new Collection([2, 4]))->values()->all()); + $this->assertSame([['v' => 1]], $c->whereNotBetween('v', LazyCollection::range(2, 4, 2)->getIterator())->values()->all()); } #[DataProvider('collectionClassProvider')] @@ -1425,6 +1435,9 @@ public function testFlatten($collection): void // Nested arrays containing collections containing arrays are flattened $c = new $collection([['#foo', new $collection(['#bar', ['#zap']])], ['#baz']]); $this->assertEquals(['#foo', '#bar', '#zap', '#baz'], $c->flatten()->all()); + + $c = new $collection([new LazyCollection(['#foo', ['#bar']]), ['#baz']]); + $this->assertSame(['#foo', '#bar', '#baz'], $c->flatten()->all()); } #[DataProvider('collectionClassProvider')] @@ -1437,9 +1450,11 @@ public function testFlattenWithDepth($collection): void // Specifying a depth only flattens to that depth $c = new $collection([['#foo', ['#bar', ['#baz']]], '#zap']); $this->assertEquals(['#foo', ['#bar', ['#baz']], '#zap'], $c->flatten(1)->all()); + $this->assertSame(['#foo', ['#bar', ['#baz']], '#zap'], $c->flatten(1.0)->all()); $c = new $collection([['#foo', ['#bar', ['#baz']]], '#zap']); $this->assertEquals(['#foo', '#bar', ['#baz'], '#zap'], $c->flatten(2)->all()); + $this->assertSame(['#foo', '#bar', ['#baz'], '#zap'], $c->flatten(2.0)->all()); } #[DataProvider('collectionClassProvider')] @@ -1792,6 +1807,7 @@ public function testEachSpread($collection): void $result[] = [$number, $character, $key]; }); $this->assertEquals([[1, 'a', 0], [2, 'b', 1]], $result); + $this->assertSame([[1, 'a'], [2, 'b']], $c->toArray()); } #[DataProvider('collectionClassProvider')] @@ -1978,6 +1994,9 @@ public function testCollapseWithNestedCollections($collection): void { $data = new $collection([new $collection([1, 2, 3]), new $collection([4, 5, 6])]); $this->assertEquals([1, 2, 3, 4, 5, 6], $data->collapse()->all()); + + $data = new $collection([new LazyCollection([1, 2, 3]), [4, 5, 6]]); + $this->assertSame([1, 2, 3, 4, 5, 6], $data->collapse()->all()); } #[DataProvider('collectionClassProvider')] @@ -1996,6 +2015,9 @@ public function testCollapseWithKeysOnNestedCollections($collection): void { $data = new $collection([new $collection(['a' => '1a', 'b' => '1b']), new $collection(['b' => '2b', 'c' => '2c']), 'drop']); $this->assertEquals(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all()); + + $data = new $collection([new LazyCollection(['a' => '1a', 'b' => '1b']), ['b' => '2b', 'c' => '2c'], 'drop']); + $this->assertSame(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all()); } #[DataProvider('collectionClassProvider')] @@ -2515,6 +2537,101 @@ public function testChunkWhilePreservingStringKeys($collection): void $this->assertEquals(['e' => 3, 'f' => 3, 'g' => 3], $data->last()->toArray()); } + #[DataProvider('collectionClassProvider')] + public function testChunkByWithCallback($collection): void + { + $data = (new $collection([1, 1, 2, 2, 3, 3, 3])) + ->chunkBy(fn ($value) => $value); + + $this->assertInstanceOf($collection, $data); + $this->assertInstanceOf($collection, $data->first()); + $this->assertEquals([0 => 1, 1 => 1], $data->first()->toArray()); + $this->assertEquals([2 => 2, 3 => 2], $data->get(1)->toArray()); + $this->assertEquals([4 => 3, 5 => 3, 6 => 3], $data->last()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByWithStringKey($collection): void + { + $data = (new $collection([ + ['parent' => 'a', 'name' => '1'], + ['parent' => 'a', 'name' => '2'], + ['parent' => 'b', 'name' => '3'], + ['parent' => 'b', 'name' => '4'], + ['parent' => 'a', 'name' => '5'], + ]))->chunkBy('parent'); + + $this->assertInstanceOf($collection, $data); + $this->assertCount(3, $data); + $this->assertEquals([ + ['parent' => 'a', 'name' => '1'], + ['parent' => 'a', 'name' => '2'], + ], $data->first()->values()->toArray()); + $this->assertEquals([ + ['parent' => 'b', 'name' => '3'], + ['parent' => 'b', 'name' => '4'], + ], $data->get(1)->values()->toArray()); + $this->assertEquals([ + ['parent' => 'a', 'name' => '5'], + ], $data->last()->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByPreservesKeys($collection): void + { + $data = (new $collection(['a' => 1, 'b' => 1, 'c' => 2, 'd' => 2, 'e' => 1])) + ->chunkBy(fn ($value) => $value); + + $this->assertInstanceOf($collection, $data); + $this->assertCount(3, $data); + $this->assertEquals(['a' => 1, 'b' => 1], $data->first()->toArray()); + $this->assertEquals(['c' => 2, 'd' => 2], $data->get(1)->toArray()); + $this->assertEquals(['e' => 1], $data->last()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByWithDotNotation($collection): void + { + $data = (new $collection([ + (object) ['address' => (object) ['city' => 'NY']], + (object) ['address' => (object) ['city' => 'NY']], + (object) ['address' => (object) ['city' => 'LA']], + ]))->chunkBy('address.city'); + + $this->assertCount(2, $data); + $this->assertCount(2, $data->first()); + $this->assertCount(1, $data->last()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByWithEmptyCollection($collection): void + { + $data = (new $collection([]))->chunkBy('key'); + + $this->assertInstanceOf($collection, $data); + $this->assertCount(0, $data); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByWithSingleItem($collection): void + { + $data = (new $collection([['key' => 'a']]))->chunkBy('key'); + + $this->assertInstanceOf($collection, $data); + $this->assertCount(1, $data); + $this->assertEquals([['key' => 'a']], $data->first()->values()->toArray()); + } + + #[DataProvider('collectionClassProvider')] + public function testChunkByCallbackReceivesOriginalKeysAndComparesValues($collection): void + { + $data = (new $collection(['a' => 1, 'b' => 2, 'c' => 1]))->chunkBy( + fn ($value, $key) => $key === 'b' ? '1' : $value + ); + + $this->assertSame([['a' => 1, 'b' => 2, 'c' => 1]], $data->toArray()); + } + #[DataProvider('collectionClassProvider')] public function testEvery($collection): void { @@ -3370,6 +3487,12 @@ public function testMapSpread($collection): void return "{$number}-{$character}-{$key}"; }); $this->assertEquals(['1-a-0', '2-b-1'], $result->all()); + $this->assertSame([[1, 'a'], [2, 'b']], $c->toArray()); + + $result = (new $collection([0, 1, 2, 3]))->chunk(2)->mapSpread( + fn ($even, $odd) => $even + $odd + ); + $this->assertSame([1, 5], $result->all()); } #[DataProvider('collectionClassProvider')] @@ -3383,6 +3506,11 @@ public function testFlatMap($collection): void return $person['hobbies']; }); $this->assertEquals(['programming', 'basketball', 'music', 'powerlifting'], $data->all()); + + $this->assertSame( + [1, 1, 2], + (new $collection([1, 2]))->flatMap(fn ($number) => LazyCollection::range(1, $number))->all() + ); } #[DataProvider('collectionClassProvider')] diff --git a/tests/Support/SupportLazyCollectionIsLazyTest.php b/tests/Support/SupportLazyCollectionIsLazyTest.php index 0ff1252137..42b0f85535 100644 --- a/tests/Support/SupportLazyCollectionIsLazyTest.php +++ b/tests/Support/SupportLazyCollectionIsLazyTest.php @@ -78,6 +78,23 @@ public function testChunkWhileIsLazy(): void }); } + public function testChunkByIsLazy(): void + { + $collection = LazyCollection::make(['A', 'A', 'B', 'B', 'C', 'C', 'C']); + + $this->assertDoesNotEnumerateCollection($collection, function ($collection) { + $collection->chunkBy(fn ($value) => $value); + }); + + $this->assertEnumeratesCollection($collection, 3, function ($collection) { + $collection->chunkBy(fn ($value) => $value)->first(); + }); + + $this->assertEnumeratesCollectionOnce($collection, function ($collection) { + $collection->chunkBy(fn ($value) => $value)->all(); + }); + } + public function testCollapseIsLazy(): void { $collection = LazyCollection::make([ diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index ea86fdf2c3..9f6d413c71 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -282,12 +282,13 @@ public function testWhenContainsAll() })); } - public function testDedup() + public function testDedup(): void { $this->assertSame(' hypervel php framework ', (string) $this->stringable(' hypervel php framework ')->deduplicate()); $this->assertSame('what', (string) $this->stringable('whaaat')->deduplicate('a')); $this->assertSame('/some/odd/path/', (string) $this->stringable('/some//odd//path/')->deduplicate('/')); $this->assertSame('ムだム', (string) $this->stringable('ムだだム')->deduplicate('だ')); + $this->assertSame(' hypervel forever ', (string) $this->stringable(' hypervell foreverrr ')->deduplicate([' ', 'l', 'r'])); } public function testDirname() diff --git a/tests/Support/Traits/InteractsWithDataTest.php b/tests/Support/Traits/InteractsWithDataTest.php index 22372a6025..f57bd8e530 100644 --- a/tests/Support/Traits/InteractsWithDataTest.php +++ b/tests/Support/Traits/InteractsWithDataTest.php @@ -18,7 +18,6 @@ use ReflectionMethod; use stdClass; use Stringable; -use TypeError; enum InteractsWithDataTestStringEnum: string { @@ -336,13 +335,11 @@ public function testClampMethod(): void $this->assertSame(9.24, $instance->clamp('float', 1, 10)); } - public function testClampMethodRejectsNonNumericValues(): void + public function testClampMethodUsesDefaultForNonNumericValues(): void { $instance = new TestInteractsWithDataClass(['per_page' => 'abc']); - $this->expectException(TypeError::class); - - $instance->clamp('per_page', 1, 100); + $this->assertSame(1, $instance->clamp('per_page', 1, 100)); } } diff --git a/types/Collections/Arr.php b/types/Collections/Arr.php index ae90dfeb22..85eb439fd6 100644 --- a/types/Collections/Arr.php +++ b/types/Collections/Arr.php @@ -2,45 +2,250 @@ declare(strict_types=1); +use ArrayIterator; use ArrayObject; +use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Arr; +use JsonSerializable; use stdClass; +use Traversable; use function PHPStan\Testing\assertType; +$array = [new User]; +/** @var iterable $iterable */ +$iterable = []; +/** @var Traversable $traversable */ +$traversable = new ArrayIterator([new User]); + +assertType('User|null', Arr::first($array)); +assertType('User|null', Arr::first($array, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::first($array, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::first($array, null, function (): string { + return 'string'; +})); + +assertType('User|null', Arr::first($iterable)); +assertType('User|null', Arr::first($iterable, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::first($iterable, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::first($iterable, null, function (): string { + return 'string'; +})); + +assertType('User|null', Arr::first($traversable)); +assertType('User|null', Arr::first($traversable, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::first($traversable, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::first($traversable, null, function (): string { + return 'string'; +})); + +assertType('User|null', Arr::last($array)); +assertType('User|null', Arr::last($array, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::last($array, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::last($array, null, function (): string { + return 'string'; +})); + +assertType('User|null', Arr::last($iterable)); +assertType('User|null', Arr::last($iterable, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::last($iterable, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::last($iterable, null, function (): string { + return 'string'; +})); + +assertType('User|null', Arr::last($traversable)); +assertType('User|null', Arr::last($traversable, function ($user): bool { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", Arr::last($traversable, function ($user): bool { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", Arr::last($traversable, null, function (): string { + return 'string'; +})); + +assertType("array{array<'a'|'b'>, array<1|2>}", Arr::divide(['a' => 1, 'b' => 2])); +assertType('array{array<0>, array<1>}', Arr::divide([1])); + +/** + * Generate an iterable of integers. + * + * @return iterable + */ +function generateArray(): iterable +{ + yield 1; +} + assertType('true', Arr::arrayable([])); +assertType('true', Arr::arrayable(new class implements Arrayable { + /** + * Get the instance as an array. + */ + public function toArray(): array + { + return []; + } +})); +assertType('true', Arr::arrayable(new class implements Jsonable { + /** + * Convert the object to its JSON representation. + */ + public function toJson(int $options = 0): string + { + return '{"foo":"bar"}'; + } +})); +assertType('true', Arr::arrayable(generateArray())); +assertType('true', Arr::arrayable(new class implements JsonSerializable { + /** + * Return data for JSON serialization. + */ + #[Override] + public function jsonSerialize(): mixed + { + return '{"foo":"bar"}'; + } +})); assertType('true', Arr::arrayable(new ArrayObject)); assertType('false', Arr::arrayable(1)); -assertType("array{array<'a'|'b'>, array<1|2>}", Arr::divide(['a' => 1, 'b' => 2])); -assertType('array>', Arr::crossJoin([1], [2], ['third' => 3])); +assertType('array>', Arr::crossJoin([1], [2], ['a' => 3])); -$array = ['first' => 1, 'second' => 2, 'third' => 3]; +/* @phpstan-ignore staticMethod.impossibleType */ +assertType('false', Arr::isAssoc([1])); -assertType('mixed', Arr::random($array)); -assertType('array', Arr::random($array, 2)); -assertType("array<'first'|'second'|'third', 1|2|3>", Arr::sort($array)); -assertType("array<'first'|'second'|'third', 1|2|3>", Arr::sortDesc($array)); -assertType("array<'first'|'second'|'third', 1|2|3>", Arr::where($array, static fn (int $value): bool => $value > 1)); -assertType("array<'first'|'second'|'third', 1|2|3>", Arr::reject($array, static fn (int $value): bool => $value > 1)); +/* @phpstan-ignore staticMethod.alreadyNarrowedType */ +assertType('true', Arr::isAssoc(['a' => 1])); -/** @var array $nullable */ -$nullable = []; -assertType('array', Arr::whereNotNull($nullable)); +/* @phpstan-ignore staticMethod.alreadyNarrowedType */ +assertType('true', Arr::isList([1])); + +/* @phpstan-ignore staticMethod.impossibleType */ +assertType('false', Arr::isList(['a' => 1])); + +assertType('array<0|1|2, 1|2|3>', Arr::sort([1, 3, 2])); +assertType("array<'a'|'b'|'c', 1|2|3>", Arr::sort(['a' => 1, 'c' => 3, 'b' => 2])); +assertType('array<0|1|2, 1|2|3>', Arr::sortDesc([1, 3, 2])); +assertType("array<'a'|'b'|'c', 1|2|3>", Arr::sortDesc(['a' => 1, 'c' => 3, 'b' => 2])); +assertType('array<0|1|2, 1|2|3>', Arr::sortRecursive([1, 3, 2])); +assertType("array<'a'|'b'|'c', 1|2|3>", Arr::sortRecursive(['a' => 1, 'c' => 3, 'b' => 2])); +assertType('array<0|1|2, 1|2|3>', Arr::sortRecursiveDesc([1, 3, 2])); +assertType("array<'a'|'b'|'c', 1|2|3>", Arr::sortRecursiveDesc(['a' => 1, 'c' => 3, 'b' => 2])); + +// CSS entries do not guarantee a non-empty result, and empty styles gain a semicolon. +assertType('string', Arr::toCssClasses(['hidden' => false])); +assertType('string', Arr::toCssClasses([])); +assertType('string', Arr::toCssClasses('')); +assertType('string', Arr::toCssClasses(['hidden' => true])); +assertType('string', Arr::toCssClasses(['hidden'])); +assertType('string', Arr::toCssClasses('hidden')); + +assertType('string', Arr::toCssStyles(['background: red' => false])); +assertType('string', Arr::toCssStyles([])); +assertType('string', Arr::toCssStyles('')); +assertType('string', Arr::toCssStyles(['background: red' => true])); +assertType('string', Arr::toCssStyles(['background: red'])); +assertType('string', Arr::toCssStyles('background: red')); assertType('array{}', Arr::wrap(null)); +assertType('array{}', Arr::wrap([])); assertType('array{1}', Arr::wrap(1)); -assertType("array<'first'|'second'|'third', 1|2|3>", Arr::wrap($array)); -assertType("''", Arr::toCssClasses([])); -assertType('non-empty-string', Arr::toCssClasses(['hidden' => true])); -assertType("''", Arr::toCssStyles([])); -assertType('non-empty-string', Arr::toCssStyles(['display: none' => true])); +assertType("array{'hello'}", Arr::wrap('hello')); +assertType('array{stdClass}', Arr::wrap(new stdClass)); +assertType('array<0, 1>', Arr::wrap([1])); +assertType("array<'a'|'b', 1|2>", Arr::wrap(['a' => 1, 'b' => 2])); +/** @var list|object $value */ +assertType('array, object>', Arr::wrap($value)); +/** @var null|float|float[] $value */ +assertType('array', Arr::wrap($value)); +/** @var null|float|float[]|string|string[] $value */ +assertType('array', Arr::wrap($value)); +/** @var null|array|float $value */ +assertType('array<0|string, float>', Arr::wrap($value)); +/** @var array $value */ +assertType('array>', Arr::wrap($value)); +/** @var null|stdClass|stdClass[] $value */ +assertType('array', Arr::wrap($value)); + +/** @var array $arr */ +assertType('array', Arr::whereNotNull($arr)); + +/** @var list $arr */ +assertType('array, int>', Arr::whereNotNull($arr)); + +assertType('mixed', Arr::random($array)); +assertType('array', Arr::random($array, 2)); + +assertType('array<0|1, mixed>', Arr::mapSpread([[0, 1], [2, 3]], fn (int $even, int $odd): int => $even + $odd)); + +// Numeric prefixes can produce integer array keys. +assertType('array', Arr::prependKeysWith($array, 'user_')); + +$numbers = ['first' => 1, 'second' => 2, 'third' => 3]; + +assertType("array<'first'|'second'|'third', 1|2|3>", Arr::where($numbers, static fn (int $value): bool => $value > 1)); +assertType("array<'first'|'second'|'third', 1|2|3>", Arr::reject($numbers, static fn (int $value): bool => $value > 1)); /** @var iterable $iterable */ -$iterable = new ArrayObject($array); -assertType('int|null', Arr::last($iterable)); +$iterable = new ArrayObject($numbers); assertType('bool', Arr::every($iterable, static fn (int $value, string $key): bool => $value > 0 && $key !== '')); assertType('bool', Arr::some($iterable, static fn (int $value, string $key): bool => $value > 0 && $key !== '')); +$users = [['name' => 'b'], ['name' => 'a']]; +Arr::sort($users, ['name']); +Arr::sort($users, [['name', false]]); +Arr::sort([5, 1], [fn (int $a, int $b): int => $a - $b]); +Arr::sortDesc($users, [['name', true]]); + +// A comparison list receives two values, while a top-level callback receives a value and key. +Arr::sort($users, fn (array $user, int $key): string => $user['name']); +Arr::sort($users, [fn (array $user, int $key): int => $key]); // @phpstan-ignore argument.type + $target = []; assertType('array', Arr::push($target, 'items', new stdClass)); diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 0d6fa29d81..6b9c4425e6 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -2,203 +2,1227 @@ declare(strict_types=1); -use Generator; +use ArrayIterator; +use Exception; +use Hypervel\Contracts\Support\Arrayable; use Hypervel\Support\Collection; -use Hypervel\Support\Enumerable; use Hypervel\Support\LazyCollection; -use stdClass; +use SortDirection; +use Traversable; use function PHPStan\Testing\assertType; -$collection = new Collection(['first' => 1, 'second' => 2, 'third' => 3]); -$lazy = new LazyCollection(['first' => 1, 'second' => 2, 'third' => 3]); - -/** @return Generator */ -$lazySource = static function (): Generator { - yield 'first' => 1; - yield 'second' => 2; -}; - -assertType('array', $collection->all()); -assertType('Hypervel\Support\Collection', Collection::range(1, 3)); -assertType('Hypervel\Support\Collection', Collection::times(3)); -assertType('Hypervel\Support\Collection', Collection::times(3, static fn (int $number): bool => $number > 1)); -assertType('Hypervel\Support\LazyCollection', LazyCollection::times(3)); -assertType('Hypervel\Support\LazyCollection', LazyCollection::times(3, static fn (int $number): bool => $number > 1)); -assertType('Hypervel\Support\Collection', $collection->flatten()); -assertType('Hypervel\Support\LazyCollection', $lazy->flatten()); -assertType( - "Hypervel\\Support\\Collection<'even'|'odd', Hypervel\\Support\\Collection>", - $collection->groupBy(static fn (int $value): array => [$value % 2 === 0 ? 'even' : 'odd']) -); +/** @implements Arrayable */ +class Users implements Arrayable +{ + /** + * Get the users as an array. + */ + public function toArray(): array + { + return [new User]; + } +} + +$collection = collect([new User]); +$arrayable = new Users; +/** @var iterable $iterable */ +$iterable = [1]; +/** @var Traversable $traversable */ +$traversable = new ArrayIterator(['string']); + +$associativeCollection = collect(['John' => new User]); + +class Invokable +{ + /** + * Return the name. + */ + public function __invoke(): string + { + return 'Taylor'; + } +} +$invokable = new Invokable; + +assertType('Hypervel\Support\Collection', $collection); + +assertType('Hypervel\Support\Collection', collect(['string'])); +assertType('Hypervel\Support\Collection', collect(['string' => new User])); +assertType('Hypervel\Support\Collection', collect($arrayable)); +assertType('Hypervel\Support\Collection', collect($collection)); +assertType('Hypervel\Support\Collection', collect($iterable)); +assertType('Hypervel\Support\Collection', collect($traversable)); -assertType('1|2|3|null', $collection->min()); -assertType("'1'|'2'|'3'|null", $collection->min(static fn (int $value): string => (string) $value)); -assertType('1|2|3|null', $collection->max()); -assertType("'1'|'2'|'3'|null", $collection->max(static fn (int $value): string => (string) $value)); +assertType('Hypervel\Support\Collection', $collection::make(['string'])); +assertType('Hypervel\Support\Collection', $collection::make(['string' => new User])); +assertType('Hypervel\Support\Collection', $collection::make($arrayable)); +assertType('Hypervel\Support\Collection', $collection::make($collection)); +assertType('Hypervel\Support\Collection', $collection::make($iterable)); +assertType('Hypervel\Support\Collection', $collection::make($traversable)); -assertType('float|int', $collection->sum(function (int $value, string $key): int { - assertType('1|2|3', $value); - assertType('string', $key); +assertType('Hypervel\Support\Collection', $collection::times(10, function ($int) { + // assertType('int', $int); - return $value; + return new User; })); -assertType('mixed', $collection->sum('amount')); -assertType('stdClass', $collection->reduceInto(new stdClass, static function (stdClass $result, int $value, string $key): void { - $result->{$key} = $value; +assertType('Hypervel\Support\Collection', $collection::times(10, function () { + return new User; })); -assertType('1|2|3', $collection->random()); -assertType('Hypervel\Support\Collection', $collection->random(2)); -assertType('Hypervel\Support\Collection', $collection->random(2, true)); -assertType('1|2|3', $lazy->random()); -assertType('Hypervel\Support\LazyCollection', $lazy->random(2)); -assertType('Hypervel\Support\LazyCollection', $lazy->random(2, true)); -assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource)); +assertType('Hypervel\Support\Collection', $collection->each(function ($user) { + assertType('User', $user); +})); -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])); +assertType('Hypervel\Support\Collection', $collection::range(1, 100)); + +assertType('Hypervel\Support\Collection<(int|string), string>', $collection::wrap('string')); +assertType('Hypervel\Support\Collection<(int|string), User>', $collection::wrap(new User)); + +assertType('Hypervel\Support\Collection<(int|string), string>', $collection::wrap(['string'])); +assertType('Hypervel\Support\Collection<(int|string), User>', $collection::wrap(['string' => new User])); + +assertType("array<0, 'string'>", $collection::unwrap(['string'])); +assertType('array', $collection::unwrap( + $collection +)); +assertType("'string'", Collection::unwrap('string')); +assertType('null', Collection::unwrap(null)); /** - * Check shared enumerable return and callback types. + * Check unwrapping an array or collection parameter. * - * @param Enumerable $enumerable + * @param array|Collection $items */ -function assertEnumerableTypes(Enumerable $enumerable): void +function assertUnwrapUnion(array|Collection $items): void { - assertType('Hypervel\Support\Enumerable', $enumerable->flatten()); - assertType('Hypervel\Support\Enumerable', $enumerable->random(2)); - assertType('Hypervel\Support\Enumerable', $enumerable->random(2, true)); - assertType('float|int', $enumerable->sum(static fn (int $value): int => $value)); - assertType('mixed', $enumerable->sum('amount')); - - assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->keyBy(static fn () => Digit::One)); - assertType('Hypervel\Support\Enumerable', $enumerable->keyBy(static fn () => new Collection(['key']))); - assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn () => Digit::One)); - assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn (int $value): bool => $value > 1)); - assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn (int $value): bool => $value > 1)); - assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn () => null, preserveKeys: true)); + assertType('array', Collection::unwrap($items)); } -assertEnumerableTypes($collection); +assertType('Hypervel\Support\Collection', $collection::empty()); -/** - * Check eager collection grouping and key inference. - * - * @param Collection $collection - */ -function assertCollectionGroupingTypes(Collection $collection): void -{ - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email'])); - assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { - assertType('User', $user); - assertType('int', $int); +assertType('float|int|null', $collection->average()); +assertType('float|int|null', $collection->average('string')); +assertType('float|int|null', $collection->average(function ($user) { + assertType('User', $user); + + return 1; +})); +assertType('float|int|null', $collection->average(function ($user) { + assertType('User', $user); + + return 0.1; +})); + +assertType('float|int|null', $collection->median()); +assertType('float|int|null', $collection->median('string')); +assertType('float|int|null', $collection->median(['string'])); + +assertType('array|null', $collection->mode()); +assertType('array|null', $collection->mode('string')); +assertType('array|null', $collection->mode(['string'])); + +assertType('Hypervel\Support\Collection', $collection->collapse()); + +assertType('bool', $collection->some(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->some('string', '=', 'string')); + +assertType('bool', $collection->containsStrict(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->containsStrict('string', 'string')); +assertType('bool', $collection::make([[1]])->containsStrict(0)); + +assertType('Hypervel\Support\LazyCollection', $collection->lazy()); + +assertType('float|int|null', $collection->avg()); +assertType('float|int|null', $collection->avg('string')); +assertType('float|int|null', $collection->avg(function ($user) { + assertType('User', $user); + + return 1; +})); +assertType('float|int|null', $collection->avg(function ($user) { + assertType('User', $user); + + return 0.1; +})); + +assertType('bool', $collection->contains(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection->contains(function ($user, $int) { + assertType('int', $int); + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->contains('string', '=', 'string')); - return 'foo'; +assertType('Hypervel\Support\Collection>', $collection->crossJoin($collection::make(['string']))); +assertType('Hypervel\Support\Collection>', $collection->crossJoin([1, 2])); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diff([1, 2])); +assertType('Hypervel\Support\Collection', $collection::make(['string-1'])->diff(['string-2'])); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diffUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\Collection', $collection::make(['string-1'])->diffUsing(['string-2'], function ($stringA, $stringB) { + assertType('string', $stringA); + assertType('string', $stringB); + + return -1; +})); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diffAssoc([1, 2])); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->diffAssoc(['string' => 'string'])); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diffAssocUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\Collection', $collection::make(['string-1'])->diffAssocUsing(['string-2'], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diffKeys([1, 2])); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->diffKeys(['string' => 'string'])); + +assertType('Hypervel\Support\Collection', $collection::make([3, 4])->diffKeysUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\Collection', $collection::make(['string-1'])->diffKeysUsing(['string-2'], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); + +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string']) + ->duplicates()); +assertType('Hypervel\Support\Collection', $collection->duplicates('name', true)); +assertType('Hypervel\Support\Collection', $collection::make([3, 'string']) + ->duplicates(function ($intOrString) { + assertType('int|string', $intOrString); + + return true; })); - assertType('Hypervel\Support\Collection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); - assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { - return 'foo'; - }, preserveKeys: true)); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string']) + ->duplicatesStrict()); +assertType('Hypervel\Support\Collection', $collection->duplicatesStrict('name')); +assertType('Hypervel\Support\Collection', $collection::make([3, 'string']) + ->duplicatesStrict(function ($intOrString) { + assertType('int|string', $intOrString); - assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy('name')); - assertType("Hypervel\\Support\\Collection<'foo', User>", $collection->keyBy(function ($user, $int) { - assertType('User', $user); - assertType('int', $int); + return true; + })); + +assertType('Hypervel\Support\Collection', $collection->each(function ($user) { + assertType('User', $user); - return 'foo'; + return null; +})); +assertType('Hypervel\Support\Collection', $collection->each(function ($user) { + assertType('User', $user); +})); +assertType('Hypervel\Support\Collection', $collection->each(function ($user, $int) { + assertType('int', $int); + assertType('User', $user); +})); + +assertType('Hypervel\Support\Collection', $collection::make([['string']]) + ->eachSpread(function ($int, $string) { + // assertType('int', $int); + // assertType('int', $string); + + return null; + })); +assertType('Hypervel\Support\Collection', $collection::make([[1, 'string']]) + ->eachSpread(function ($int, $string) { + // assertType('int', $int); + // assertType('int', $string); })); - assertType('Hypervel\Support\Collection<0, User>', $collection->keyBy(static fn ($user): int => 0)); - assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); - - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([1])->countBy()); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy('email')); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + +assertType('bool', $collection->every(function ($user, $int) { + assertType('int', $int); + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->every('string', '=', 'string')); + +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->except(['string'])); +assertType('Hypervel\Support\Collection', $collection->except([1])); +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->except([1])); + +assertType('Hypervel\Support\Collection', $collection->filter()); +assertType('Hypervel\Support\Collection', $collection->filter(function ($user) { + assertType('User', $user); + + return true; +})); + +assertType('Hypervel\Support\Collection|true', $collection->when(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->when(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->when(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); +assertType('Hypervel\Support\Collection|null', $collection->when('Taylor', function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); +})); +assertType( + 'Hypervel\Support\Collection|null', + $collection->when( + 'Taylor', + function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); + }, + function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); + } + ) +); +assertType('Hypervel\Support\Collection|null', $collection->when(fn () => 'Taylor', function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); +})); +assertType( + 'Hypervel\Support\Collection|null', + $collection->when( + function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 14; + }, + function ($collection, $count) { + assertType('Hypervel\Support\Collection', $collection); + assertType('14', $count); + }, + function ($collection, $count) { + assertType('Hypervel\Support\Collection', $collection); + assertType('14', $count); + } + ) +); + +assertType('Hypervel\Support\Collection|null', $collection->when($invokable, function ($collection, $param) { + assertType('Hypervel\Support\Collection', $collection); + assertType('Invokable', $param); +})); + +assertType('Hypervel\Support\Collection|true', $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\Collection|true', $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\Collection|true', $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); +assertType('Hypervel\Support\Collection|null', $collection->unless('Taylor', function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); +})); +assertType( + 'Hypervel\Support\Collection|null', + $collection->unless( + 'Taylor', + function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); + }, + function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); + } + ) +); +assertType('Hypervel\Support\Collection|null', $collection->unless(fn () => 'Taylor', function ($collection, $name) { + assertType('Hypervel\Support\Collection', $collection); + assertType("'Taylor'", $name); +})); +assertType( + 'Hypervel\Support\Collection|null', + $collection->unless( + function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 14; + }, + function ($collection, $count) { + assertType('Hypervel\Support\Collection', $collection); + assertType('14', $count); + }, + function ($collection, $count) { + assertType('Hypervel\Support\Collection', $collection); + assertType('14', $count); + } + ) +); + +assertType('Hypervel\Support\Collection|null', $collection->unless($invokable, function ($collection, $param) { + assertType('Hypervel\Support\Collection', $collection); + assertType('Invokable', $param); +})); + +assertType('Hypervel\Support\Collection|true', $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\Collection|true', $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); +assertType('Hypervel\Support\Collection|null', $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); +assertType("'string'|Hypervel\\Support\\Collection", $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 'string']]) + ->where('string')); +assertType('Hypervel\Support\Collection', $collection::make([['string' => 'string']]) + ->where('string', '=', 'string')); +assertType('Hypervel\Support\Collection', $collection::make([['string' => 'string']]) + ->where('string', 'string')); + +assertType('Hypervel\Support\Collection', $collection->whereNull()); +assertType('Hypervel\Support\Collection', $collection->whereNull('foo')); + +assertType('Hypervel\Support\Collection', $collection->whereNotNull()); +assertType('Hypervel\Support\Collection', $collection->whereNotNull('foo')); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereStrict('string', 2)); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereIn('string', [2])); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereInStrict('string', [2])); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereBetween('string', [1, 3])); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereNotBetween('string', [1, 3])); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereNotIn('string', [2])); + +assertType('Hypervel\Support\Collection', $collection::make([['string' => 2]]) + ->whereNotInStrict('string', [2])); + +assertType('Hypervel\Support\Collection', $collection::make([new User, 1]) + ->whereInstanceOf(User::class)); + +assertType('Hypervel\Support\Collection', $collection::make([new User, 1]) + ->whereInstanceOf([User::class, Exception::class])); + +assertType('User|null', $collection->first()); +assertType('User|null', $collection->first(function ($user) { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", $collection->first(function ($user) { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", $collection->first(null, function () { + return 'string'; +})); +if ($collection->isNotEmpty()) { + assertType('User', $collection->first()); + assertType("'foo'|User", $collection->first(null, 'foo')); +} else { + assertType('null', $collection->first()); + assertType("'foo'|User", $collection->first(null, 'foo')); +} +if ($collection->isEmpty()) { + assertType('null', $collection->first()); + assertType("'foo'|User", $collection->first(null, 'foo')); +} else { + assertType('User', $collection->first()); + assertType("'foo'|User", $collection->first(null, 'foo')); +} + +assertType('Hypervel\Support\Collection', $collection->flatten()); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->flatten(4)); + +assertType('User|null', $collection->firstWhere('string', 'string')); +assertType('User|null', $collection->firstWhere('string', 'string', 'string')); + +assertType('User|null', $collection->value('string')); +assertType("'string'|User", $collection->value('string', 'string')); +assertType("'string'|User", $collection->value('string', fn () => 'string')); + +assertType('Hypervel\Support\Collection', $collection::make(['string'])->flip()); + +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection<(int|string), mixed>>', $collection->groupBy(['name', 'email'])); +assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; +})); +assertType('Hypervel\Support\Collection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); + +assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { + return 'foo'; +}, preserveKeys: true)); + +assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy('name')); +assertType("Hypervel\\Support\\Collection<'foo', User>", $collection->keyBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; +})); +assertType('Hypervel\Support\Collection<0, User>', $collection->keyBy(static fn ($user): int => 0)); +assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); + +assertType('bool', $collection->has(0)); +assertType('bool', $collection->has([0, 1])); + +assertType('string', $collection->implode(function ($user, $index) { + assertType('User', $user); + assertType('int', $index); + + return 'string'; +})); + +assertType('Hypervel\Support\Collection', $collection->intersect([new User])); + +assertType('Hypervel\Support\Collection', $collection->intersectByKeys([new User])); + +assertType('Hypervel\Support\Collection', $collection->keys()); + +assertType('User|null', $collection->last()); +assertType('User|null', $collection->last(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); +assertType("'string'|User", $collection->last(function () { + return true; +}, 'string')); +assertType("'string'|User", $collection->last(null, function () { + return 'string'; +})); + +assertType('Hypervel\Support\Collection', $collection->map(function () { + return 1; +})); +assertType('Hypervel\Support\Collection', $collection->map(function () { + return 'string'; +})); + +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->map(function ($string, $int) { assertType('string', $string); assertType('int', $int); - return $string; + return (string) $string; })); - assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn (): bool => true)); - assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn () => null)); - assertType('Hypervel\Support\Collection', $collection->keyBy(static fn () => new Collection(['key']))); - assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy(static fn (): bool => true)); -} +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->mapSpread(function () { + return 'string'; + })); -/** - * Check lazy collection grouping and key inference. - * - * @param LazyCollection $collection - */ -function assertLazyCollectionGroupingTypes(LazyCollection $collection): void -{ - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email'])); - assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { - assertType('User', $user); - assertType('int', $int); +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->mapSpread(function () { + return 1; + })); + +assertType('Hypervel\Support\Collection', Collection::make([[0, 1], [2, 3]]) + ->mapSpread(fn (int $even, int $odd): int => $even + $odd)); + +assertType('Hypervel\Support\Collection>', $collection::make(['string', 'string']) + ->mapToDictionary(function ($stringValue, $stringKey) { + assertType('string', $stringValue); + assertType('int', $stringKey); - return 'foo'; + return ['string' => 1]; })); - assertType('Hypervel\Support\LazyCollection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); - assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { - return 'foo'; - }, preserveKeys: true)); +assertType('Hypervel\Support\Collection>', $collection::make(['string', 'string']) + ->mapToGroups(function ($stringValue, $stringKey) { + assertType('string', $stringValue); + assertType('int', $stringKey); - assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy('name')); - assertType("Hypervel\\Support\\LazyCollection<'foo', User>", $collection->keyBy(function ($user, $int) { - assertType('User', $user); + return ['string' => 1]; + })); + +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->mapWithKeys(function ($string, $int) { + assertType('string', $string); assertType('int', $int); - return 'foo'; + return ['string' => 1]; })); - assertType('Hypervel\Support\LazyCollection<0, User>', $collection->keyBy(static fn ($user): int => 0)); - assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); - - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([1])->countBy()); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy('email')); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->flatMap(function ($string, $int) { assertType('string', $string); assertType('int', $int); - return $string; + return [0 => 'string']; + })); + +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->flatMap(fn ($string) => new LazyCollection([$string]))); + +assertType('Hypervel\Support\Collection', $collection->mapInto(User::class)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->merge([2])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->merge(['string'])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->merge(['string'])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->merge([1])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->mergeRecursive([2 => 'string'])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->mergeRecursive(['string'])); + +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->combine([2])); +assertType('Hypervel\Support\Collection', $collection::make([1])->combine([1])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->combine(['string'])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->union([1])); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->union(['string' => 'string'])); + +assertType('null', $collection::make()->min()); +assertType('int|null', $collection::make([1])->min()); +assertType('mixed', $collection::make([1])->min('string')); +assertType('mixed', $collection::make(['string' => 1])->min('string')); +assertType("'foo'|null", $collection::make([1])->min(function ($int) { + assertType('int', $int); + + return 'foo'; +})); +assertType('mixed', $collection::make([new User])->min('id')); + +assertType('null', $collection::make()->max()); +assertType('int|null', $collection::make([1])->max()); +assertType('mixed', $collection::make([1])->max('string')); +assertType("'foo'|null", $collection::make([1])->max(function ($int) { + assertType('int', $int); + + return 'foo'; +})); +assertType('mixed', $collection::make([new User])->max('id')); + +assertType('Hypervel\Support\Collection', $collection->nth(1, 2)); + +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->only(['string'])); +assertType('Hypervel\Support\Collection', $collection->only([1])); +assertType('Hypervel\Support\Collection', $collection::make(['string']) + ->only([1])); + +assertType('Hypervel\Support\Collection', $collection->forPage(1, 2)); + +assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $collection->partition(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); +assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $collection::make(['string'])->partition('string', '=', 'string')); +assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $collection::make(['string'])->partition('string', 'string')); +assertType('Hypervel\Support\Collection, Hypervel\Support\Collection>', $collection::make(['string'])->partition('string')); + +assertType('Hypervel\Support\Collection', $collection::make([1])->concat([2])); +assertType('Hypervel\Support\Collection', $collection::make(['string'])->concat(['string'])); +assertType('Hypervel\Support\Collection', $collection::make([1])->concat(['string'])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->random(2)); +assertType('string', $collection::make(['string'])->random()); + +assertType('1|null', $collection + ->reduce(function ($null, $user) { + assertType('User', $user); + assertType('1|null', $null); + + return 1; + })); +assertType('0|1', $collection + ->reduce(function ($int, $user) { + assertType('User', $user); + assertType('0|1', $int); + + return 1; + }, 0)); +assertType('0|1', $collection + ->reduce(function ($int, $user, $key) { + assertType('User', $user); + assertType('0|1', $int); + assertType('int', $key); + + return 1; + }, 0)); + +assertType('1|null', $collection + ->reduceWithKeys(function ($null, $user) { + assertType('User', $user); + assertType('1|null', $null); + + return 1; })); +assertType('0|1', $collection + ->reduceWithKeys(function ($int, $user) { + assertType('User', $user); + assertType('0|1', $int); - assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn (): bool => true)); - assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn () => null)); - assertType('Hypervel\Support\LazyCollection', $collection->keyBy(static fn () => new Collection(['key']))); - assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection->countBy(static fn (): bool => true)); + return 1; + }, 0)); +assertType('0|1', $collection + ->reduceWithKeys(function ($int, $user, $key) { + assertType('User', $user); + assertType('0|1', $int); + assertType('int', $key); + + return 1; + }, 0)); +assertType("'bar'|'foo'", $collection::make([])->reduce(static fn (): string => 'foo', 'bar')); + +assertType('Hypervel\Support\Collection', $collection::make([1])->replace([1])); +assertType('Hypervel\Support\Collection', $collection->replace([new User])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->replaceRecursive([1])); +assertType('Hypervel\Support\Collection', $collection->replaceRecursive([new User])); + +assertType('Hypervel\Support\Collection', $collection->reverse()); + +// assertType('int|bool', $collection::make([1])->search(2)); +// assertType('string|bool', $collection::make(['string' => 'string'])->search('string')); +// assertType('int|bool', $collection->search(function ($user, $int) { +// assertType('User', $user); +// assertType('int', $int); +// +// return true; +// })); + +assertType('Hypervel\Support\Collection', $collection::make([1])->shuffle()); +assertType('Hypervel\Support\Collection', $collection->shuffle()); + +assertType('Hypervel\Support\Collection', $collection::make([1])->skip(1)); +assertType('Hypervel\Support\Collection', $collection->skip(1)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->skipUntil(1)); +assertType('Hypervel\Support\Collection', $collection->skipUntil(new User)); +assertType('Hypervel\Support\Collection', $collection->skipUntil(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection', $collection::make([1])->skipWhile(1)); +assertType('Hypervel\Support\Collection', $collection->skipWhile(new User)); +assertType('Hypervel\Support\Collection', $collection->skipWhile(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection', $collection::make([1])->slice(1)); +assertType('Hypervel\Support\Collection', $collection->slice(1, 2)); + +assertType('Hypervel\Support\Collection>', $collection->split(3)); +assertType('Hypervel\Support\Collection>', $collection::make([1])->split(3)); + +assertType('string', $collection::make(['string' => 'string'])->sole('string', 'string')); +assertType('string', $collection::make(['string' => 'string'])->sole('string', '=', 'string')); +assertType('User', $collection->sole(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('User', $collection->firstOrFail()); +assertType('User', $collection->firstOrFail('string', 'string')); +assertType('User', $collection->firstOrFail('string', '=', 'string')); +assertType('User', $collection->firstOrFail(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection>', $collection::make(['string'])->chunk(1)); +assertType('Hypervel\Support\Collection>', $collection->chunk(2)); +assertType('Hypervel\Support\Collection>', $associativeCollection->chunk(2)); +assertType('Hypervel\Support\Collection>', $associativeCollection->chunk(2, false)); + +assertType('Hypervel\Support\Collection>', $collection->chunkWhile(function ($user, $int, $collection) { + assertType('User', $user); + assertType('int', $int); + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); + +assertType('Hypervel\Support\Collection>', $collection->chunkBy(fn ($user) => $user->getKey())); +assertType('Hypervel\Support\Collection>', $collection->chunkBy('name')); + +assertType('Hypervel\Support\Collection', $collection->sort(function ($userA, $userB) { + assertType('User', $userA); + assertType('User', $userB); + + return 1; +})); +assertType('Hypervel\Support\Collection', $collection->sort()); + +assertType('Hypervel\Support\Collection', $collection->sortDesc()); +assertType('Hypervel\Support\Collection', $collection->sortDesc(2)); + +assertType('Hypervel\Support\Collection', $collection->sortBy(function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +})); +assertType('Hypervel\Support\Collection', $collection->sortBy('string')); +assertType('Hypervel\Support\Collection', $collection->sortBy('string', 1, false)); +assertType('Hypervel\Support\Collection', $collection->sortBy([ + ['string', 'asc'], + ['foo', SortDirection::Descending], +])); +assertType('Hypervel\Support\Collection', $collection->sortBy([function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +}])); + +assertType('Hypervel\Support\Collection', $collection->sortByDesc(function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +})); +assertType('Hypervel\Support\Collection', $collection->sortByDesc('string')); +assertType('Hypervel\Support\Collection', $collection->sortByDesc('string', 1)); +assertType('Hypervel\Support\Collection', $collection->sortByDesc([ + ['string', 'asc'], + ['foo', SortDirection::Descending], +])); +assertType('Hypervel\Support\Collection', $collection->sortByDesc([function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +}])); + +assertType('Hypervel\Support\Collection', $collection::make([1])->sortKeys()); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->sortKeys(1, true)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->sortKeysDesc()); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->sortKeysDesc(1)); + +assertType('mixed', $collection::make([1])->sum('string')); +assertType('float|int', $collection::make(['string'])->sum(function ($string) { + assertType('string', $string); + + return mt_rand(1, 2); +})); + +assertType('Hypervel\Support\Collection', $collection::make([1])->take(1)); +assertType('Hypervel\Support\Collection', $collection->take(1)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->takeUntil(1)); +assertType('Hypervel\Support\Collection', $collection->takeUntil(new User)); +assertType('Hypervel\Support\Collection', $collection->takeUntil(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection', $collection::make([1])->takeWhile(1)); +assertType('Hypervel\Support\Collection', $collection->takeWhile(new User)); +assertType('Hypervel\Support\Collection', $collection->takeWhile(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection', $collection->tap(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); +})); + +assertType('Hypervel\Support\Collection', $collection->pipe(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return collect([1]); +})); +assertType('1', $collection::make([1])->pipe(function ($collection) { + assertType('Hypervel\Support\Collection', $collection); + + return 1; +})); + +assertType('User', $collection->pipeInto(User::class)); + +assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string')); +assertType('Hypervel\Support\Collection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string', 'string')); + +assertType('Hypervel\Support\Collection', $collection->reject()); +assertType('Hypervel\Support\Collection', $collection->reject(new User)); +assertType('Hypervel\Support\Collection', $collection->reject(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('Hypervel\Support\Collection', $collection->reject(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\Collection', $collection->unique()); +assertType('Hypervel\Support\Collection', $collection->unique(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return $user->getTable(); +})); +assertType('Hypervel\Support\Collection', $collection::make(['string' => 'string'])->unique(function ($stringA, $stringB) { + assertType('string', $stringA); + assertType('string', $stringB); + + return $stringA; +}, true)); + +assertType('Hypervel\Support\Collection', $collection->uniqueStrict()); +assertType('Hypervel\Support\Collection', $collection->uniqueStrict(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return $user->getTable(); +})); + +assertType('Hypervel\Support\Collection', $collection->values()); +assertType('Hypervel\Support\Collection', $collection::make(['string', 'string'])->values()); +assertType('Hypervel\Support\Collection', $collection::make(['string', 1])->values()); + +assertType('Hypervel\Support\Collection', $collection::make([1])->pad(2, 0)); +assertType('Hypervel\Support\Collection', $collection::make([1])->pad(2, 'string')); +assertType('Hypervel\Support\Collection', $collection->pad(2, 0)); + +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([1])->countBy()); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy('email')); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return $string; +})); + +assertType('Hypervel\Support\Collection>', $collection->zip([1])); +assertType('Hypervel\Support\Collection>', $collection->zip(['string'])); +assertType('Hypervel\Support\Collection>', $collection::make(['string' => 'string'])->zip(['string'])); + +assertType('Hypervel\Support\Collection', $collection->collect()); +assertType('Hypervel\Support\Collection', $collection::make([1])->collect()); + +assertType('Hypervel\Support\Collection', $collection::make([1])->push(2)); + +assertType('array', $collection->all()); + +assertType('User|null', $collection->get(0)); +assertType("'string'|User", $collection->get(0, 'string')); +assertType("'string'|User", $collection->get(0, function () { + return 'string'; +})); + +assertType("'string'|User", $collection->getOrPut(0, 'string')); +assertType("'string'|User", $collection->getOrPut(0, fn () => 'string')); + +assertType('Hypervel\Support\Collection', $collection->forget(1)); +assertType('Hypervel\Support\Collection', $collection->forget([1, 2])); + +assertType('User|null', $collection->pop()); +assertType('Hypervel\Support\Collection', $collection->pop(2)); + +assertType('Hypervel\Support\Collection', $collection::make([ + 'string-key-1' => 'string-value-1', + 'string-key-2' => 'string-value-2', +])->pop(2)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->prepend(2)); +assertType('Hypervel\Support\Collection', $collection->prepend(new User, 2)); + +assertType('Hypervel\Support\Collection', $collection::make([1])->push(2)); +assertType('Hypervel\Support\Collection', $collection->push(new User, new User)); + +assertType('User|null', $collection->pull(1)); +assertType("'string'|User", $collection->pull(1, 'string')); +assertType("'string'|User", $collection->pull(1, function () { + return 'string'; +})); + +assertType('Hypervel\Support\Collection', $collection->put(1, new User)); +assertType('Hypervel\Support\Collection', $collection::make([ + 'string-key-1' => 'string-value-1', +])->put('string-key-2', 'string-value-2')); + +assertType('User|null', $collection->shift()); +assertType('Hypervel\Support\Collection', $collection::make([ + 'string-key-1' => 'string-value-1', + 'string-key-2' => 'string-value-2', +])->shift(2)); + +assertType( + 'Hypervel\Support\Collection>', + $collection->sliding(2) +); + +assertType( + 'Hypervel\Support\Collection>', + $collection::make(['string' => 'string'])->sliding(2, 1) +); + +assertType( + 'Hypervel\Support\Collection>', + $collection->splitIn(2) +); + +assertType( + 'Hypervel\Support\Collection>', + $collection::make(['string' => 'string'])->splitIn(1) +); + +assertType('Hypervel\Support\Collection', $collection->splice(1)); +assertType('Hypervel\Support\Collection', $collection->splice(1, 1, [new User])); + +assertType('Hypervel\Support\Collection', $collection->transform(function ($user, $int): int { + assertType('User', $user); + assertType('int', $int); + + return $int * 2; +})); + +assertType('Hypervel\Support\Collection', $collection->transform(function ($value, $key) { + assertType('int', $value); + assertType('int', $key); + + return new User; +})); + +assertType('Hypervel\Support\Collection', $collection->add(new User)); + +/** + * @template TKey of array-key + * @template TValue + * + * @extends Collection + */ +class CustomCollection extends Collection +{ } +// assertType('CustomCollection', CustomCollection::make([new User])); +assertType('Hypervel\Support\Collection', CustomCollection::make([new User])->toBase()); + +assertType('bool', $collection->offsetExists(0)); +assertType('bool', isset($collection[0])); + +$collection->offsetSet(0, new User); +$collection->offsetSet(null, new User); +assertType('User', $collection[0] = new User); + +$collection->offsetUnset(0); +unset($collection[0]); + +assertType('array', $collection->toArray()); +assertType('array', collect(['string' => 'string'])->toArray()); +assertType('array', collect([1, 2])->toArray()); + +assertType('ArrayIterator', $collection->getIterator()); +foreach ($collection as $int => $user) { + assertType('int', $int); + assertType('User', $user); +} + +class Animal +{ +} +class Tiger extends Animal +{ +} +class Lion extends Animal +{ +} +class Zebra extends Animal +{ +} + +class Zoo +{ + /** + * @var Collection + */ + private Collection $animals; + + /** + * Create a zoo with several animal types. + */ + public function __construct() + { + $this->animals = collect([ + new Tiger, + new Lion, + new Zebra, + ]); + } + + /** + * Get the animals other than zebras. + * + * @return Collection + */ + public function getWithoutZebras(): Collection + { + return $this->animals->filter(fn (Animal $animal) => ! $animal instanceof Zebra); + } +} + +$zoo = new Zoo; + +assertType('Hypervel\Support\Collection', $zoo->getWithoutZebras()); + +$coll = $zoo->getWithoutZebras(); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'average', Animal, Hypervel\\Support\\Collection>", $coll->average); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'avg', Animal, Hypervel\\Support\\Collection>", $coll->avg); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'contains', Animal, Hypervel\\Support\\Collection>", $coll->contains); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'doesntContain', Animal, Hypervel\\Support\\Collection>", $coll->doesntContain); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'each', Animal, Hypervel\\Support\\Collection>", $coll->each); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'every', Animal, Hypervel\\Support\\Collection>", $coll->every); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'filter', Animal, Hypervel\\Support\\Collection>", $coll->filter); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'first', Animal, Hypervel\\Support\\Collection>", $coll->first); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'flatMap', Animal, Hypervel\\Support\\Collection>", $coll->flatMap); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'groupBy', Animal, Hypervel\\Support\\Collection>", $coll->groupBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'keyBy', Animal, Hypervel\\Support\\Collection>", $coll->keyBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'last', Animal, Hypervel\\Support\\Collection>", $coll->last); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'map', Animal, Hypervel\\Support\\Collection>", $coll->map); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'max', Animal, Hypervel\\Support\\Collection>", $coll->max); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'min', Animal, Hypervel\\Support\\Collection>", $coll->min); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'partition', Animal, Hypervel\\Support\\Collection>", $coll->partition); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'percentage', Animal, Hypervel\\Support\\Collection>", $coll->percentage); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'reject', Animal, Hypervel\\Support\\Collection>", $coll->reject); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'skipUntil', Animal, Hypervel\\Support\\Collection>", $coll->skipUntil); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'skipWhile', Animal, Hypervel\\Support\\Collection>", $coll->skipWhile); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'some', Animal, Hypervel\\Support\\Collection>", $coll->some); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sortBy', Animal, Hypervel\\Support\\Collection>", $coll->sortBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sortByDesc', Animal, Hypervel\\Support\\Collection>", $coll->sortByDesc); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sum', Animal, Hypervel\\Support\\Collection>", $coll->sum); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeUntil', Animal, Hypervel\\Support\\Collection>", $coll->takeUntil); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', Animal, Hypervel\\Support\\Collection>", $coll->takeWhile); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', Animal, Hypervel\\Support\\Collection>", $coll->unique); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', Animal, Hypervel\\Support\\Collection>", $coll->unless); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', Animal, Hypervel\\Support\\Collection>", $coll->until); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', Animal, Hypervel\\Support\\Collection>", $coll->when); + enum Digit { case One; diff --git a/types/Collections/Enumerable.php b/types/Collections/Enumerable.php new file mode 100644 index 0000000000..c2fcafed73 --- /dev/null +++ b/types/Collections/Enumerable.php @@ -0,0 +1,114 @@ + 1, 'second' => 2, 'third' => 3]); +$lazy = new LazyCollection(['first' => 1, 'second' => 2, 'third' => 3]); + +LazyCollection::make([['name' => 'b'], ['name' => 'a']])->sortBy([['name', false]]); + +/** @return Generator */ +$lazySource = static function (): Generator { + yield 'first' => 1; + yield 'second' => 2; +}; + +assertType('array', $collection->all()); +assertType('Hypervel\Support\Collection', Collection::range(1, 3)); +assertType('Hypervel\Support\Collection', Collection::times(3)); +assertType('Hypervel\Support\Collection', Collection::times(3, static fn (int $number): bool => $number > 1)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::times(3)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::times(3, static fn (int $number): bool => $number > 1)); +assertType('Hypervel\Support\Collection', $collection->flatten()); +assertType('Hypervel\Support\LazyCollection', $lazy->flatten()); +assertType( + "Hypervel\\Support\\Collection<'even'|'odd', Hypervel\\Support\\Collection>", + $collection->groupBy(static fn (int $value): array => [$value % 2 === 0 ? 'even' : 'odd']) +); + +assertType('1|2|3|null', $collection->min()); +assertType("'1'|'2'|'3'|null", $collection->min(static fn (int $value): string => (string) $value)); +assertType('1|2|3|null', $collection->max()); +assertType("'1'|'2'|'3'|null", $collection->max(static fn (int $value): string => (string) $value)); + +assertType('float|int', $collection->sum(function (int $value, string $key): int { + assertType('1|2|3', $value); + assertType('string', $key); + + return $value; +})); +assertType('mixed', $collection->sum('amount')); + +assertType('stdClass', $collection->reduceInto(new stdClass, static function (stdClass $result, int $value, string $key): void { + $result->{$key} = $value; +})); + +assertType('1|2|3', $collection->random()); +assertType('Hypervel\Support\Collection', $collection->random(2)); +assertType('Hypervel\Support\Collection', $collection->random(2, true)); +assertType('1|2|3', $lazy->random()); +assertType('Hypervel\Support\LazyCollection', $lazy->random(2)); +assertType('Hypervel\Support\LazyCollection', $lazy->random(2, true)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource)); + +/** + * Check shared enumerable return and callback types. + * + * @param Enumerable $enumerable + */ +function assertEnumerableTypes(Enumerable $enumerable): void +{ + assertType('Hypervel\Support\Enumerable', $enumerable->flatten()); + assertType('Hypervel\Support\Enumerable', $enumerable->random(2)); + assertType('Hypervel\Support\Enumerable', $enumerable->random(2, true)); + assertType('float|int', $enumerable->sum(static fn (int $value): int => $value)); + assertType('mixed', $enumerable->sum('amount')); + + $enumerable->tap(function (Enumerable $items): void { + assertType('array', $items->all()); + }); + assertType('Hypervel\Support\Enumerable', $enumerable->flatMap(fn (int $value) => new LazyCollection([$value]))); + + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->keyBy(static fn () => Digit::One)); + assertType('Hypervel\Support\Enumerable', $enumerable->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn () => Digit::One)); + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn (int $value): bool => $value > 1)); + assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn (int $value): bool => $value > 1)); + assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn () => null, preserveKeys: true)); +} + +assertEnumerableTypes($collection); + +/** + * Check eager collection grouping and key inference. + * + * @param Collection $collection + */ +function assertCollectionGroupingTypes(Collection $collection): void +{ + assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn (): bool => true)); + assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn () => null)); + assertType('Hypervel\Support\Collection', $collection->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy(static fn (): bool => true)); +} + +/** + * Check lazy collection grouping and key inference. + * + * @param LazyCollection $collection + */ +function assertLazyCollectionGroupingTypes(LazyCollection $collection): void +{ + assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn (): bool => true)); + assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn () => null)); + assertType('Hypervel\Support\LazyCollection', $collection->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection->countBy(static fn (): bool => true)); +} diff --git a/types/Collections/LazyCollection.php b/types/Collections/LazyCollection.php new file mode 100644 index 0000000000..6d0c50f673 --- /dev/null +++ b/types/Collections/LazyCollection.php @@ -0,0 +1,1038 @@ + */ +class LazyUsers implements Arrayable +{ + /** + * Get the users as an array. + */ + public function toArray(): array + { + return [new User]; + } +} + +$collection = new LazyCollection([new User]); +$arrayable = new LazyUsers; +/** @var iterable $iterable */ +$iterable = [1]; +/** @var Traversable $traversable */ +$traversable = new ArrayIterator(['string']); +$generator = function () { + yield new User; +}; + +$associativeCollection = new LazyCollection(['Sam' => new User]); + +assertType('Hypervel\Support\LazyCollection', $collection); + +assertType("Hypervel\\Support\\LazyCollection", new LazyCollection(['string'])); +assertType('Hypervel\Support\LazyCollection', new LazyCollection(['string' => new User])); +assertType('Hypervel\Support\LazyCollection', new LazyCollection($arrayable)); +assertType('Hypervel\Support\LazyCollection', new LazyCollection($iterable)); +assertType('Hypervel\Support\LazyCollection', new LazyCollection($traversable)); +assertType('Hypervel\Support\LazyCollection', new LazyCollection($generator)); + +assertType('Hypervel\Support\LazyCollection', LazyCollection::make(['string'])); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make(['string' => new User])); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make($arrayable)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make($iterable)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make($traversable)); +assertType('Hypervel\Support\LazyCollection', LazyCollection::make($generator)); + +assertType('Hypervel\Support\LazyCollection', $collection::times(10, function ($int) { + // assertType('int', $int); + + return new User; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::times(10, function () { + return new User; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->each(function ($user) { + assertType('User', $user); +})); + +assertType('Hypervel\Support\LazyCollection', $collection::range(1, 100)); + +assertType('Hypervel\Support\LazyCollection<(int|string), string>', $collection::wrap('string')); +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection::wrap(new User)); + +assertType('Hypervel\Support\LazyCollection<(int|string), string>', $collection::wrap(['string'])); +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection::wrap(['string' => new User])); + +assertType("array<0, 'string'>", $collection::unwrap(['string'])); +assertType('array', $collection::unwrap( + $collection +)); + +assertType('Hypervel\Support\LazyCollection', $collection::empty()); + +assertType('float|int|null', $collection->average()); +assertType('float|int|null', $collection->average('string')); +assertType('float|int|null', $collection->average(function ($user) { + assertType('User', $user); + + return 1; +})); +assertType('float|int|null', $collection->average(function ($user) { + assertType('User', $user); + + return 0.1; +})); + +assertType('float|int|null', $collection->median()); +assertType('float|int|null', $collection->median('string')); +assertType('float|int|null', $collection->median(['string'])); + +assertType('array|null', $collection->mode()); +assertType('array|null', $collection->mode('string')); +assertType('array|null', $collection->mode(['string'])); + +assertType('Hypervel\Support\LazyCollection', $collection->collapse()); + +assertType('bool', $collection->some(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->some('string', '=', 'string')); + +assertType('bool', $collection->containsStrict(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->containsStrict('string', 'string')); + +assertType('float|int|null', $collection->avg()); +assertType('float|int|null', $collection->avg('string')); +assertType('float|int|null', $collection->avg(function ($user) { + assertType('User', $user); + + return 1; +})); +assertType('float|int|null', $collection->avg(function ($user) { + assertType('User', $user); + + return 0.1; +})); + +assertType('bool', $collection->contains(function ($user) { + assertType('User', $user); + + return true; +})); +assertType('bool', $collection->contains(function ($user, $int) { + assertType('int', $int); + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->contains('string', '=', 'string')); + +assertType('Hypervel\Support\LazyCollection>', $collection->crossJoin($collection::make(['string']))); +assertType('Hypervel\Support\LazyCollection>', $collection->crossJoin([1, 2])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diff([1, 2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string-1'])->diff(['string-2'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diffUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string-1'])->diffUsing(['string-2'], function ($stringA, $stringB) { + assertType('string', $stringA); + assertType('string', $stringB); + + return -1; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diffAssoc([1, 2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->diffAssoc(['string' => 'string'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diffAssocUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string-1'])->diffAssocUsing(['string-2'], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diffKeys([1, 2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->diffKeys(['string' => 'string'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 4])->diffKeysUsing([1, 2], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string-1'])->diffKeysUsing(['string-2'], function ($intA, $intB) { + assertType('int', $intA); + assertType('int', $intB); + + return -1; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string']) + ->duplicates()); +assertType('Hypervel\Support\LazyCollection', $collection->duplicates('name', true)); +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 'string']) + ->duplicates(function ($intOrString) { + assertType('int|string', $intOrString); + + return true; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string']) + ->duplicatesStrict()); +assertType('Hypervel\Support\LazyCollection', $collection->duplicatesStrict('name')); +assertType('Hypervel\Support\LazyCollection', $collection::make([3, 'string']) + ->duplicatesStrict(function ($intOrString) { + assertType('int|string', $intOrString); + + return true; + })); + +assertType('Hypervel\Support\LazyCollection', $collection->each(function ($user) { + assertType('User', $user); + + return null; +})); +assertType('Hypervel\Support\LazyCollection', $collection->each(function ($user) { + assertType('User', $user); +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string']]) + ->eachSpread(function ($int, $string) { + // assertType('int', $int); + // assertType('int', $string); + + return null; + })); +assertType('Hypervel\Support\LazyCollection', $collection::make([[1, 'string']]) + ->eachSpread(function ($int, $string) { + // assertType('int', $int); + // assertType('int', $string); + })); + +assertType('bool', $collection->every(function ($user, $int) { + assertType('int', $int); + assertType('User', $user); + + return true; +})); +assertType('bool', $collection::make(['string'])->every('string', '=', 'string')); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->except(['string'])); +assertType('Hypervel\Support\LazyCollection', $collection->except([1])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->except([1])); + +assertType('Hypervel\Support\LazyCollection', $collection->filter()); +assertType('Hypervel\Support\LazyCollection', $collection->filter(function ($user) { + assertType('User', $user); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->when(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->when(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->when(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->whenEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->whenNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unless(true, function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unlessEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection|true', $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return true; +})); +assertType('Hypervel\Support\LazyCollection|null', $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); +assertType("'string'|Hypervel\\Support\\LazyCollection", $collection->unlessNotEmpty(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 'string']]) + ->where('string')); +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 'string']]) + ->where('string', '=', 'string')); +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 'string']]) + ->where('string', 'string')); + +assertType('Hypervel\Support\LazyCollection', $collection->whereNull()); +assertType('Hypervel\Support\LazyCollection', $collection->whereNull('foo')); + +assertType('Hypervel\Support\LazyCollection', $collection->whereNotNull()); +assertType('Hypervel\Support\LazyCollection', $collection->whereNotNull('foo')); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereStrict('string', 2)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereIn('string', [2])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereInStrict('string', [2])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereBetween('string', [1, 3])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereNotBetween('string', [1, 3])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereNotIn('string', [2])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([['string' => 2]]) + ->whereNotInStrict('string', [2])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([new User, 1]) + ->whereInstanceOf(User::class)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([new User, 1]) + ->whereInstanceOf([User::class, User::class])); + +assertType('User|null', $collection->first()); +assertType('User|null', $collection->first(function ($user) { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", $collection->first(function ($user) { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", $collection->first(null, function () { + return 'string'; +})); + +assertType('User|null', $collection->last()); +assertType('User|null', $collection->last(function ($user) { + assertType('User', $user); + + return true; +})); +assertType("'string'|User", $collection->last(function ($user) { + assertType('User', $user); + + return false; +}, 'string')); +assertType("'string'|User", $collection->last(null, function () { + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->flatten()); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->flatten(4)); + +assertType('User|null', $collection->firstWhere('string', 'string')); +assertType('User|null', $collection->firstWhere('string', 'string', 'string')); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->flip()); + +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection<(int|string), mixed>>', $collection->groupBy(['name', 'email'])); +assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; +})); +assertType('Hypervel\Support\LazyCollection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); + +assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { + return 'foo'; +}, preserveKeys: true)); + +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy('name')); +assertType("Hypervel\\Support\\LazyCollection<'foo', User>", $collection->keyBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; +})); +assertType('Hypervel\Support\LazyCollection<0, User>', $collection->keyBy(static fn ($user): int => 0)); +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); + +assertType('bool', $collection->has(0)); +assertType('bool', $collection->has([0, 1])); + +assertType('string', $collection->implode(function ($user, $index) { + assertType('User', $user); + assertType('int', $index); + + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->intersect([new User])); + +assertType('Hypervel\Support\LazyCollection', $collection->intersectByKeys([new User])); + +assertType('Hypervel\Support\LazyCollection', $collection->keys()); + +assertType('User|null', $collection->last()); +assertType('User|null', $collection->last(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); +assertType("'string'|User", $collection->last(function () { + return true; +}, 'string')); +assertType("'string'|User", $collection->last(null, function () { + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->map(function () { + return 1; +})); +assertType('Hypervel\Support\LazyCollection', $collection->map(function () { + return 'string'; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->map(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return (string) $string; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->mapSpread(function () { + return 'string'; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->mapSpread(function () { + return 1; + })); + +assertType('Hypervel\Support\LazyCollection', LazyCollection::make([[0, 1], [2, 3]]) + ->mapSpread(fn (int $even, int $odd): int => $even + $odd)); + +assertType('Hypervel\Support\LazyCollection>', $collection::make(['string', 'string']) + ->mapToDictionary(function ($stringValue, $stringKey) { + assertType('string', $stringValue); + assertType('int', $stringKey); + + return ['string' => 1]; + })); + +assertType('Hypervel\Support\LazyCollection>', $collection::make(['string', 'string']) + ->mapToGroups(function ($stringValue, $stringKey) { + assertType('string', $stringValue); + assertType('int', $stringKey); + + return ['string' => 1]; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->mapWithKeys(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return ['string' => 1]; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->flatMap(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return [0 => 'string']; + })); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->flatMap(fn ($string) => new LazyCollection([$string]))); + +assertType('Hypervel\Support\LazyCollection', $collection->mapInto(User::class)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->merge([2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->merge(['string'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->merge(['string'])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->merge([1])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->mergeRecursive([2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->mergeRecursive(['string'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->combine([2])); +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->combine([1])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->union([1])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->union(['string' => 'string'])); + +assertType('null', $collection::make()->min()); +assertType('int|null', $collection::make([1])->min()); +assertType('mixed', $collection::make([1])->min('string')); +assertType("'foo'|null", $collection::make([1])->min(function ($int) { + assertType('int', $int); + + return 'foo'; +})); +assertType('mixed', $collection::make([new User])->min('id')); + +assertType('null', $collection::make()->max()); +assertType('int|null', $collection::make([1])->max()); +assertType('mixed', $collection::make([1])->max('string')); +assertType("'foo'|null", $collection::make([1])->max(function ($int) { + assertType('int', $int); + + return 'foo'; +})); +assertType('mixed', $collection::make([new User])->max('id')); + +assertType('Hypervel\Support\LazyCollection', $collection->nth(1, 2)); + +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->only(['string'])); +assertType('Hypervel\Support\LazyCollection', $collection->only([1])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string']) + ->only([1])); + +assertType('Hypervel\Support\LazyCollection', $collection->forPage(1, 2)); + +assertType('Hypervel\Support\LazyCollection, Hypervel\Support\LazyCollection>', $collection->partition(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); +assertType('Hypervel\Support\LazyCollection, Hypervel\Support\LazyCollection>', $collection::make(['string'])->partition('string', '=', 'string')); +assertType('Hypervel\Support\LazyCollection, Hypervel\Support\LazyCollection>', $collection::make(['string'])->partition('string', 'string')); +assertType('Hypervel\Support\LazyCollection, Hypervel\Support\LazyCollection>', $collection::make(['string'])->partition('string')); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->concat([2])); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string'])->concat(['string'])); +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->concat(['string'])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->random(2)); +assertType('string', $collection::make(['string'])->random()); + +assertType('1|null', $collection + ->reduce(function ($null, $user) { + assertType('User', $user); + assertType('1|null', $null); + + return 1; + })); +assertType('0|1', $collection + ->reduce(function ($int, $user) { + assertType('User', $user); + assertType('0|1', $int); + + return 1; + }, 0)); +assertType('0|1', $collection + ->reduce(function ($int, $user, $key) { + assertType('User', $user); + assertType('0|1', $int); + assertType('int', $key); + + return 1; + }, 0)); + +assertType('1|null', $collection + ->reduceWithKeys(function ($null, $user) { + assertType('User', $user); + assertType('1|null', $null); + + return 1; + })); +assertType('0|1', $collection + ->reduceWithKeys(function ($int, $user) { + assertType('User', $user); + assertType('0|1', $int); + + return 1; + }, 0)); +assertType('0|1', $collection + ->reduceWithKeys(function ($int, $user, $key) { + assertType('User', $user); + assertType('0|1', $int); + assertType('int', $key); + + return 1; + }, 0)); +assertType("'bar'|'foo'", $collection::make([])->reduce(static fn (): string => 'foo', 'bar')); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->replace([1])); +assertType('Hypervel\Support\LazyCollection', $collection->replace([new User])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->replaceRecursive([1])); +assertType('Hypervel\Support\LazyCollection', $collection->replaceRecursive([new User])); + +assertType('Hypervel\Support\LazyCollection', $collection->reverse()); + +// assertType('int|bool', $collection::make([1])->search(2)); +// assertType('string|bool', $collection::make(['string' => 'string'])->search('string')); +// assertType('int|bool', $collection->search(function ($user, $int) { +// assertType('User', $user); +// assertType('int', $int); + +// return true; +// })); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->shuffle()); +assertType('Hypervel\Support\LazyCollection', $collection->shuffle()); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->skip(1)); +assertType('Hypervel\Support\LazyCollection', $collection->skip(1)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->skipUntil(1)); +assertType('Hypervel\Support\LazyCollection', $collection->skipUntil(new User)); +assertType('Hypervel\Support\LazyCollection', $collection->skipUntil(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->skipWhile(1)); +assertType('Hypervel\Support\LazyCollection', $collection->skipWhile(new User)); +assertType('Hypervel\Support\LazyCollection', $collection->skipWhile(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->slice(1)); +assertType('Hypervel\Support\LazyCollection', $collection->slice(1, 2)); + +assertType('Hypervel\Support\LazyCollection>', $collection->split(3)); +assertType('Hypervel\Support\LazyCollection>', $collection::make([1])->split(3)); + +assertType('string', $collection::make(['string' => 'string'])->sole('string', 'string')); +assertType('string', $collection::make(['string' => 'string'])->sole('string', '=', 'string')); +assertType('User', $collection->sole(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('User', $collection->firstOrFail()); +assertType('User', $collection->firstOrFail('string', 'string')); +assertType('User', $collection->firstOrFail('string', '=', 'string')); +assertType('User', $collection->firstOrFail(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection>', $collection::make(['string'])->chunk(1)); +assertType('Hypervel\Support\LazyCollection>', $collection->chunk(2)); +assertType('Hypervel\Support\LazyCollection>', $associativeCollection->chunk(2)); +assertType('Hypervel\Support\LazyCollection>', $associativeCollection->chunk(2, false)); + +assertType('Hypervel\Support\LazyCollection>', $collection->chunkWhile(function ($user, $int, $collection) { + assertType('User', $user); + assertType('int', $int); + assertType('Hypervel\Support\Collection', $collection); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection>', $collection->chunkBy(fn ($user) => $user->getKey())); +assertType('Hypervel\Support\LazyCollection>', $collection->chunkBy('name')); + +assertType('Hypervel\Support\LazyCollection', $collection->sort(function ($userA, $userB) { + assertType('User', $userA); + assertType('User', $userB); + + return 1; +})); +assertType('Hypervel\Support\LazyCollection', $collection->sort()); + +assertType('Hypervel\Support\LazyCollection', $collection->sortDesc()); +assertType('Hypervel\Support\LazyCollection', $collection->sortDesc(2)); + +assertType('Hypervel\Support\LazyCollection', $collection->sortBy(function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +})); +assertType('Hypervel\Support\LazyCollection', $collection->sortBy('string')); +assertType('Hypervel\Support\LazyCollection', $collection->sortBy('string', 1, false)); +assertType('Hypervel\Support\LazyCollection', $collection->sortBy([ + ['string', 'asc'], + ['foo', SortDirection::Descending], +])); +assertType('Hypervel\Support\LazyCollection', $collection->sortBy([function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +}])); + +assertType('Hypervel\Support\LazyCollection', $collection->sortByDesc(function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +})); +assertType('Hypervel\Support\LazyCollection', $collection->sortByDesc('string')); +assertType('Hypervel\Support\LazyCollection', $collection->sortByDesc('string', 1)); +assertType('Hypervel\Support\LazyCollection', $collection->sortByDesc([ + ['string', 'asc'], + ['foo', SortDirection::Descending], +])); +assertType('Hypervel\Support\LazyCollection', $collection->sortByDesc([function ($user, $int) { + // assertType('User', $user); + // assertType('int', $int); + + return 1; +}])); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->sortKeys()); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->sortKeys(1, true)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->sortKeysDesc()); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->sortKeysDesc(1)); + +assertType('mixed', $collection::make([1])->sum('string')); +assertType('float|int', $collection::make(['string'])->sum(function ($string) { + assertType('string', $string); + + return mt_rand(1, 2); +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->take(1)); +assertType('Hypervel\Support\LazyCollection', $collection->take(1)); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->takeUntil(1)); +assertType('Hypervel\Support\LazyCollection', $collection->takeUntil(new User)); +assertType('Hypervel\Support\LazyCollection', $collection->takeUntil(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->takeUntilTimeout(new DateTimeImmutable)); +assertType('Hypervel\Support\LazyCollection', $collection->takeUntilTimeout(new DateTimeImmutable, function ($user, $int) { + assertType('User|null', $user); + // assertType('int|null', $int); +})); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->takeWhile(1)); +assertType('Hypervel\Support\LazyCollection', $collection->takeWhile(new User)); +assertType('Hypervel\Support\LazyCollection', $collection->takeWhile(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->tap(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); +})); + +assertType('Hypervel\Support\LazyCollection', $collection->pipe(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return new LazyCollection([1]); +})); +assertType('1', $collection::make([1])->pipe(function ($collection) { + assertType('Hypervel\Support\LazyCollection', $collection); + + return 1; +})); + +assertType('User', $collection->pipeInto(User::class)); + +assertType('Hypervel\Support\LazyCollection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string')); +assertType('Hypervel\Support\LazyCollection<(int|string), mixed>', $collection::make(['string' => 'string'])->pluck('string', 'string')); + +assertType('Hypervel\Support\LazyCollection', $collection->reject()); +assertType('Hypervel\Support\LazyCollection', $collection->reject(function ($user) { + assertType('User', $user); + + return true; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->tapEach(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return null; +})); + +assertType('Hypervel\Support\LazyCollection', $collection->unique()); +assertType('Hypervel\Support\LazyCollection', $collection->unique(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return $user->getTable(); +})); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string' => 'string'])->unique(function ($stringA, $stringB) { + assertType('string', $stringA); + assertType('string', $stringB); + + return $stringA; +}, true)); + +assertType('Hypervel\Support\LazyCollection', $collection->uniqueStrict()); +assertType('Hypervel\Support\LazyCollection', $collection->uniqueStrict(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return $user->getTable(); +})); + +assertType('Hypervel\Support\LazyCollection', $collection->values()); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string', 'string'])->values()); +assertType('Hypervel\Support\LazyCollection', $collection::make(['string', 1])->values()); + +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->pad(2, 0)); +assertType('Hypervel\Support\LazyCollection', $collection::make([1])->pad(2, 'string')); +assertType('Hypervel\Support\LazyCollection', $collection->pad(2, 0)); + +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([1])->countBy()); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy('email')); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); +assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return $string; +})); + +assertType('Hypervel\Support\LazyCollection>', $collection->zip([1])); +assertType('Hypervel\Support\LazyCollection>', $collection->zip(['string'])); +assertType('Hypervel\Support\LazyCollection>', $collection::make(['string' => 'string'])->zip(['string'])); + +assertType('Hypervel\Support\Collection', $collection->collect()); +assertType('Hypervel\Support\Collection', $collection::make([1])->collect()); + +assertType('array', $collection->all()); + +assertType('User|null', $collection->get(0)); +assertType("'string'|User", $collection->get(0, 'string')); +assertType("'string'|User", $collection->get(0, function () { + return 'string'; +})); + +assertType( + 'Hypervel\Support\LazyCollection>', + $collection->sliding(2) +); + +assertType( + 'Hypervel\Support\LazyCollection>', + $collection::make(['string' => 'string'])->sliding(2, 1) +); + +assertType( + 'Hypervel\Support\LazyCollection>', + $collection->splitIn(2) +); + +assertType( + 'Hypervel\Support\LazyCollection>', + $collection::make(['string' => 'string'])->splitIn(1) +); + +/** + * @template TKey of array-key + * @template TValue + * + * @extends LazyCollection + */ +class CustomLazyCollection extends LazyCollection +{ +} + +// assertType('CustomLazyCollection', CustomLazyCollection::make([new User])); + +assertType('array', $collection->toArray()); +assertType('array', LazyCollection::make(['string' => 'string'])->toArray()); +assertType('array', LazyCollection::make([1, 2])->toArray()); + +assertType('Iterator', $collection->getIterator()); +foreach ($collection as $int => $user) { + assertType('int', $int); + assertType('User', $user); +} + +class LazyAnimal +{ +} +class LazyTiger extends LazyAnimal +{ +} +class LazyLion extends LazyAnimal +{ +} +class LazyZebra extends LazyAnimal +{ +} + +class LazyZoo +{ + /** + * @var Collection + */ + private Collection $animals; + + /** + * Create a zoo with several animal types. + */ + public function __construct() + { + $this->animals = collect([ + new LazyTiger, + new LazyLion, + new LazyZebra, + ]); + } + + /** + * Get the animals other than zebras. + * + * @return LazyCollection + */ + public function getWithoutZebras(): LazyCollection + { + return $this->animals->lazy()->filter(fn (LazyAnimal $animal) => ! $animal instanceof LazyZebra); + } +} + +$zoo = new LazyZoo; + +$coll = $zoo->getWithoutZebras(); + +assertType('Hypervel\Support\LazyCollection', $coll); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'average', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->average); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'avg', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->avg); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'contains', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->contains); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'doesntContain', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->doesntContain); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'each', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->each); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'every', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->every); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'filter', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->filter); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'first', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->first); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'flatMap', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->flatMap); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'groupBy', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->groupBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'keyBy', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->keyBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'last', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->last); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'map', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->map); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'max', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->max); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'min', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->min); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'partition', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->partition); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'percentage', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->percentage); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'reject', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->reject); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'skipUntil', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->skipUntil); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'skipWhile', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->skipWhile); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'some', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->some); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sortBy', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->sortBy); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sortByDesc', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->sortByDesc); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'sum', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->sum); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeUntil', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->takeUntil); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'takeWhile', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->takeWhile); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unique', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unique); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'unless', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->unless); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'until', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->until); +assertType("Hypervel\\Support\\HigherOrderCollectionProxy<'when', LazyAnimal, Hypervel\\Support\\LazyCollection>", $coll->when); diff --git a/types/Console/Command.php b/types/Console/Command.php new file mode 100644 index 0000000000..d0c8502f69 --- /dev/null +++ b/types/Console/Command.php @@ -0,0 +1,108 @@ +', $arguments); +assertType('array', $options); + +$command = new ConsoleTypingCommand('example'); +assertType('array', $command->argument()); +assertType('array', $command->option()); +assertType('mixed', $command->argument('name')); +assertType('mixed', $command->option('force')); +assertType('true', $command->confirmToProceed(callback: false)); + +assertType('array{1, 2, 3}', $command->withProgressBar([1, 2, 3], fn (int $value): int => $value)); +assertType('null', $command->withProgressBar(3, fn (ProgressBar $bar): int => $bar->getProgress())); + +$generator = (function (): Generator { + yield 'first' => 'value'; +})(); +assertType("Generator<'first', 'value', mixed, void>", $command->withProgressBar($generator, fn (string $value): string => $value)); + +$command->withProgressBar(['value'], fn (int $value): int => $value); // @phpstan-ignore argument.type + +// These supported callbacks and output styles must not be narrowed by the port. +$command->trap(15, fn (): bool => false); +$command->line('example', 'fg=green'); +$command->info('example', 'custom'); +$command->info('example', 100); +Application::starting(fn (Application $application): Application => $application); + +// Command loaders accept container service IDs as well as class names. +$container = new Container; +$loader = new ContainerCommandLoader($container, ['example' => 'console.example']); + +class ConsoleTypingCommand extends Command +{ + use ConfirmableTrait; + + protected array $verbosityMap = ['custom' => 100]; + + /** + * Get the console command arguments. + */ + protected function getArguments(): array + { + return [ + ['name', null, 'An optional argument', null, fn (CompletionInput $input): array => ['example']], + ['items', InputArgument::OPTIONAL | InputArgument::IS_ARRAY], + ]; + } + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ['force', null, null], + ['values', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, '', null, fn (CompletionInput $input): array => ['example']], + ]; + } +} + +abstract class ConsoleTypingGenerator extends GeneratorCommand +{ + /** + * Get the console command arguments. + */ + protected function getArguments(): array + { + return [ + ['name', null], + ['items', InputArgument::OPTIONAL | InputArgument::IS_ARRAY], + ]; + } +} + +abstract class InvalidConsoleCompletionCommand extends Command +{ + /** + * Get the console command arguments. + */ + protected function getArguments(): array + { + // Symfony supplies only the input when invoking a completion callback. + return [ // @phpstan-ignore return.type + ['name', InputArgument::OPTIONAL, '', null, fn (CompletionInput $input, CompletionSuggestions $suggestions): array => ['example']], + ]; + } +} diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index 35b0a93c60..ee84134aa5 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -156,6 +156,9 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Support\Collection', $collection->mapInto(stdClass::class)); +assertType('Hypervel\Database\Eloquent\Collection<(int|string), Hypervel\Database\Eloquent\Collection>', $collection->groupBy('name')); +assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection<(int|string), mixed>>', $collection->groupBy(['name', 'email'])); + assertType( 'Hypervel\Database\Eloquent\Collection', $collection->fresh() diff --git a/types/Support/helpers.php b/types/Support/helpers.php new file mode 100644 index 0000000000..11b66033b4 --- /dev/null +++ b/types/Support/helpers.php @@ -0,0 +1,103 @@ + 1)); +assertType('null', once(function () { /* @phpstan-ignore function.void (testing void) */ +})); + +assertType('Hypervel\Support\Optional', optional()); +assertType('null', optional(null, fn () => 1)); +assertType('1', optional('foo', function ($value) { + assertType("'foo'", $value); + + return 1; +})); + +assertType('1', retry(5, fn () => 1)); + +assertType('object', str()); +assertType('Hypervel\Support\Stringable', str('foo')); + +assertType('User', tap(new User, function ($user) { + assertType('User', $user); +})); +assertType('Hypervel\Support\HigherOrderTapProxy', tap(new User)); + +/** + * Check narrowing after conditional exceptions. + */ +function testThrowIf(float|int $foo, ?DateTimeImmutable $bar = null): void +{ + rescue(fn () => assertType('never', throw_if(true, Exception::class))); + assertType('false', throw_if(false, Exception::class)); + assertType('false', throw_if(empty($foo))); + throw_if(is_float($foo)); + assertType('int', $foo); + throw_if($foo === 0); + assertType('int|int<1, max>', $foo); + + // Truthy/falsey argument + throw_if($bar); + assertType('null', $bar); + assertType('null', throw_if(null, Exception::class)); + assertType("''", throw_if('', Exception::class)); + rescue(fn () => assertType('never', throw_if('foo', Exception::class))); +} + +/** + * Check narrowing after inverse conditional exceptions. + */ +function testThrowUnless(float|int $foo, ?DateTimeImmutable $bar = null): void +{ + assertType('true', throw_unless(true, Exception::class)); + rescue(fn () => assertType('never', throw_unless(false, Exception::class))); + assertType('true', throw_unless(empty($foo))); + throw_unless(is_int($foo)); + assertType('int', $foo); + throw_unless($foo === 0); + assertType('0', $foo); + throw_unless($bar instanceof DateTimeImmutable); + assertType('DateTimeImmutable', $bar); + + // Truthy/falsey argument + rescue(fn () => assertType('never', throw_unless(null, Exception::class))); + rescue(fn () => assertType('never', throw_unless('', Exception::class))); + assertType("'foo'", throw_unless('foo', Exception::class)); +} + +assertType('1', transform('filled', fn () => 1, true)); +assertType('1', transform(['filled'], fn () => 1)); +assertType('null', transform('', fn () => 1)); +assertType('true', transform('', fn () => 1, true)); +assertType('true', transform('', fn () => 1, fn () => true)); + +assertType('User', with(new User)); +assertType('bool', with(new User)->save()); +assertType('10', with(new User, function ($user) { + assertType('User', $user); + + return 10; +}));