From a07d272529bf7e7f9e9d9b0204b13538138bc759 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 08:39:24 +0000 Subject: [PATCH 01/18] Complete console command typing and preserve supported inputs Port the remaining console typing from Laravel #58565 together with its parser, confirmation, progress callback, mode-mask and shortcut follow-ups. Command definitions now describe argument and option tuples precisely; parser results and progress callbacks retain their useful inferred types. Keep accepted custom verbosity values, named container service IDs, nullable definition modes and callbacks whose return values are ignored. Completion callbacks receive one input argument in Symfony, so describe that actual contract rather than copying the incorrect two-argument annotation. Preserve Hypervel's coroutine signal registry and existing worker-lifetime bootstrap warning without changing their execution. Use InputArgument::OPTIONAL for Inertia's optional middleware name. Its value matches the former option constant, and removing the broad local return tags lets this command inherit the checked definition shapes. Add a focused PHPStan fixture covering inference and supported extension inputs, including a negative completion-arity check. Full source and type analysis, the console test suite, formatting and runtime definition probes pass. No runtime state, lifetime or performance behavior changes. Upstream source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. https://github.com/laravel/framework/pull/58565 https://github.com/laravel/framework/pull/58670 https://github.com/laravel/framework/pull/58681 https://github.com/laravel/framework/pull/58766 https://github.com/laravel/framework/pull/58771 https://github.com/laravel/framework/pull/58768 https://github.com/laravel/framework/pull/59082 https://github.com/laravel/framework/pull/60728 https://github.com/laravel/framework/pull/54415 --- src/console/src/Application.php | 8 +- src/console/src/Concerns/HasParameters.php | 20 ++++ src/console/src/Concerns/InteractsWithIO.php | 35 +++++- .../src/Concerns/InteractsWithSignals.php | 5 +- src/console/src/ConfirmableTrait.php | 7 ++ src/console/src/ContainerCommandLoader.php | 2 + src/console/src/Parser.php | 7 ++ src/console/src/SignalRegistry.php | 15 ++- src/inertia/src/Commands/CreateMiddleware.php | 7 +- types/Console/Command.php | 108 ++++++++++++++++++ 10 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 types/Console/Command.php 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/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/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/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/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']], + ]; + } +} From 845d360ab45acfa4b4fd35390e675398ebf8dc85 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:13:56 +0000 Subject: [PATCH 02/18] Complete array type coverage and correct sorting contracts Merge the current upstream Arr type fixture, including iterable defaults, array conversion, sorting, CSS compilation, wrapping and prefixed keys. Retain distinct Hypervel coverage and merge duplicate cases once. Correct sorting annotations across Arr, Collection and Enumerable: a list of comparisons receives two values, while a top-level callback receives a value and key. Include supported property lists and boolean directions. Remove false CSS result refinements and preserve integer keys in the prependKeysWith result while carrying its value type through. The changes affect PHPDoc only. Runtime implementations, Laravel method signatures and coroutine behavior remain unchanged. Focused type fixtures reject the old annotations without adding runtime guards or machinery. Ported from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58518 https://github.com/laravel/framework/pull/58625 https://github.com/laravel/framework/pull/59411 https://github.com/laravel/framework/pull/61034 Validation: full source and type-fixture PHPStan, affected Arr and collection tests through ParaTest, PHP-CS-Fixer and whitespace checks pass. --- src/collections/src/Arr.php | 15 +- src/collections/src/Collection.php | 6 +- src/collections/src/Enumerable.php | 4 +- types/Collections/Arr.php | 241 ++++++++++++++++++++++++++--- types/Collections/Collection.php | 2 + 5 files changed, 238 insertions(+), 30 deletions(-) diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index 2bff323b8b..53a789ccaf 100644 --- a/src/collections/src/Arr.php +++ b/src/collections/src/Arr.php @@ -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 { @@ -1024,7 +1029,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 +1050,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 +1133,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 +1155,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..706a5e1b0a 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -1508,7 +1508,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 +1545,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 +1603,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..c3f38c7fe8 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -918,7 +918,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 +926,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; diff --git a/types/Collections/Arr.php b/types/Collections/Arr.php index ae90dfeb22..024cb617dc 100644 --- a/types/Collections/Arr.php +++ b/types/Collections/Arr.php @@ -2,45 +2,248 @@ 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)); + +// 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..2cbbe85c78 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -13,6 +13,8 @@ $collection = new Collection(['first' => 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; From d25b195b95b28407b58eb136d69e73600c1250af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:14:11 +0000 Subject: [PATCH 03/18] Complete model appended-attribute test coverage Port the two named hasAppended tests for present and absent accessors from the current upstream model suite. Use the existing AppendsStub and native void test signatures, retaining the earlier appending assertions and the withoutAppends test in their upstream order. No source change is needed: hasAppended already implements the behavior. Ported from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58587 Validation: the complete DatabaseEloquentModelTest file, formatting and whitespace checks pass. --- tests/Database/DatabaseEloquentModelTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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; From 7a931bb2b4c5031b3890f5661edfb592d1ab64b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:42:21 +0000 Subject: [PATCH 04/18] Use the default when clamped input is not numeric Complete the request clamp port from Laravel #58608 and its follow-up #61355. Empty strings, null and non-numeric input now use the supplied default before applying the requested bounds instead of raising TypeError. Keep numeric-string conversion in InteractsWithData so the strictly typed Number::clamp call accepts normal query-string numbers without losing fractional values. Remove the obsolete rejection comment and analysis suppression now that the shared input boundary guarantees a numeric value. Port all current upstream request cases, preserve numeric-string coverage, and replace the older rejection expectation with a check that the implicit default is itself clamped. Update the existing request documentation. Validated with both complete changed test files, related ParaTest coverage, ValidatedInput tests, full source and type-fixture PHPStan, formatting and diff checks. Regression checks distinguish the old failure, an unbounded default and missing numeric-string conversion. Upstream: https://github.com/laravel/framework/pull/58608 https://github.com/laravel/framework/pull/61355 Porting source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). --- src/docs/requests.md | 2 +- src/support/src/Traits/InteractsWithData.php | 11 ++++++----- tests/Http/HttpRequestTest.php | 12 +++++++++++- tests/Support/Traits/InteractsWithDataTest.php | 7 ++----- 4 files changed, 20 insertions(+), 12 deletions(-) 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/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/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/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)); } } From baea152f8efa5f4a2b7794becf098d46131dc2ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:44:17 +0000 Subject: [PATCH 05/18] Complete collection contracts, fixtures and adjacent grouping Port the complete current collection and helper type coverage, generic higher-order proxy targets, and chunkBy API from Laravel. Keep distinct Hypervel assertions in the shared Enumerable fixture and preserve precise lazy return types. Regenerate the Route facade after the tap annotations. Correct supported collection operations exposed by the complete fixtures: spread callbacks no longer mutate retained chunks and accept lazy chunks; range filters normalize their advertised iterable inputs; eager flattening accepts lazy collections; lazy flattening recognizes integer-valued float depths. Eloquent grouping, spread and sliding returns admit base collections where map already produces them, without changing those algorithms. Keep chunkBy's value comparison semantics and avoid rebuilding an expanding chunk's key list. Preserve fixed-argument spread callbacks without inventing variadic type machinery. Document adjacent grouping with the public API. The source and fixtures share generic contracts, so these updates form one coherent change rather than temporarily incompatible partial ports. Upstream: https://github.com/laravel/framework/pull/60586 https://github.com/laravel/framework/pull/61357 https://github.com/laravel/framework/pull/61418 Source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). Validated with full source and type analysis, affected collection, Eloquent, helper, proxy and facade tests, focused regression checks, formatting and a clean formatter dry run. No new shared state or compatibility machinery. --- src/collections/src/Arr.php | 8 +- src/collections/src/Collection.php | 23 +- src/collections/src/Enumerable.php | 44 +- .../src/HigherOrderCollectionProxy.php | 10 +- src/collections/src/LazyCollection.php | 95 +- .../src/Traits/EnumeratesValues.php | 130 +- src/docs/collections.md | 23 + src/support/src/Facades/Route.php | 2 +- src/support/src/HigherOrderTapProxy.php | 7 + src/support/src/Sleep.php | 1 - src/support/src/Traits/Tappable.php | 4 +- src/support/src/helpers.php | 6 +- .../DatabaseEloquentCollectionTest.php | 25 +- tests/Support/SupportCollectionTest.php | 128 ++ .../SupportLazyCollectionIsLazyTest.php | 17 + types/Collections/Arr.php | 2 + types/Collections/Collection.php | 1320 +++++++++++++++-- types/Collections/Enumerable.php | 114 ++ types/Collections/LazyCollection.php | 1038 +++++++++++++ types/Database/Eloquent/Collection.php | 3 + types/Support/helpers.php | 103 ++ 21 files changed, 2852 insertions(+), 251 deletions(-) create mode 100644 types/Collections/Enumerable.php create mode 100644 types/Collections/LazyCollection.php create mode 100644 types/Support/helpers.php diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index 53a789ccaf..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; @@ -839,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 { diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 706a5e1b0a..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.'); diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index c3f38c7fe8..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. * @@ -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/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/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/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/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/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/types/Collections/Arr.php b/types/Collections/Arr.php index 024cb617dc..85eb439fd6 100644 --- a/types/Collections/Arr.php +++ b/types/Collections/Arr.php @@ -222,6 +222,8 @@ public function jsonSerialize(): mixed 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_')); diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index 2cbbe85c78..6b9c4425e6 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -2,205 +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]); +/** @implements Arrayable */ +class Users implements Arrayable +{ + /** + * Get the users as an array. + */ + public function toArray(): array + { + return [new User]; + } +} -LazyCollection::make([['name' => 'b'], ['name' => 'a']])->sortBy([['name', false]]); +$collection = collect([new User]); +$arrayable = new Users; +/** @var iterable $iterable */ +$iterable = [1]; +/** @var Traversable $traversable */ +$traversable = new ArrayIterator(['string']); -/** @return Generator */ -$lazySource = static function (): Generator { - yield 'first' => 1; - yield 'second' => 2; -}; +$associativeCollection = collect(['John' => new User]); -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']) -); +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')); + +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'])); - return 'foo'; +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; + })); - return 'foo'; +assertType('Hypervel\Support\Collection', $collection->each(function ($user) { + assertType('User', $user); + + 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<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('Hypervel\Support\Collection', $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\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; + })); - return 'foo'; +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 ['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\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)); +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); + + 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/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; +})); From 611320407ec9dba3db23ea6a617713ec1608f427 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:23:39 +0000 Subject: [PATCH 06/18] Remove unsupported SQL Server schema paths and restore generated-column coverage Remove Blueprint::computed() and the three grammar methods that only reject its SQL Server-only column type. Hypervel does not support SQL Server; typed columns with virtualAs() and storedAs() remain unchanged. Keep source omission comments at the removed methods and drop unreachable SQL Server branches and skips from existing integration tests. Restore the generated-column test to its original conditional PostgreSQL version check, with the current PostgreSQL 18 requirement. The RequiresDatabase attribute inadvertently excluded MySQL, MariaDB and SQLite, so their existing metadata assertions never ran. Preserve every supported-driver assertion and type the edited tests and environment hook. Investigated Laravel https://github.com/laravel/framework/pull/58602 and its revert https://github.com/laravel/framework/pull/58888; this cleanup follows the unsupported-driver policy rather than porting the reverted precision change. The test gate originated in https://github.com/laravel/framework/pull/52851 and was raised to PostgreSQL 18 by https://github.com/laravel/framework/pull/57290. Compared with Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: affected integration test files pass on SQLite, generated-column metadata assertions pass on isolated MySQL 9 and MariaDB 10 databases, and PostgreSQL 17 retains its intended skip. Focused schema, mail and validation tests, full source and type-fixture analysis, formatting and diff checks pass. PostgreSQL 18 remains covered by CI. --- src/database/src/Schema/Blueprint.php | 10 +------ src/database/src/Schema/Grammars/Grammar.php | 8 +---- .../src/Schema/Grammars/MySqlGrammar.php | 11 +------ .../src/Schema/Grammars/SQLiteGrammar.php | 10 +------ .../Database/EloquentBelongsToManyTest.php | 11 ++----- .../Database/EloquentUpdateTest.php | 6 +--- .../Integration/Database/QueryBuilderTest.php | 7 +++-- .../Database/QueryBuilderWhereLikeTest.php | 18 ++--------- .../Database/SchemaBuilderTest.php | 30 +++++++------------ 9 files changed, 27 insertions(+), 84 deletions(-) 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/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/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', } From 329d747334f412d82942144ae623eda3279ab875 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:23:50 +0000 Subject: [PATCH 07/18] Complete file validation custom-message test typing Add native void return types to the ten custom-message regression tests already ported from Laravel. Preserve every fixture value, rule and assertion, including the existing Hypervel file-classification coverage. Reconciles https://github.com/laravel/framework/pull/58598 against Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. File::fail() already keeps translated messages intact, and the translator regression and full custom-message test surface are present; no production change or duplicate tests are needed. Validation: FileValidationTest and the focused validation/schema/mail selection pass, along with full source and type-fixture analysis and formatting. --- .../Validation/Rules/FileValidationTest.php | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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")], From 035fd9fa784a8b46d81b097e1d72994e3e6ca28e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:24:02 +0000 Subject: [PATCH 08/18] Align mailable assertion fixtures with upstream order Restore the relative order of the plain and Blade-escaped mailable stubs and give both renderForAssertions() overrides their parent method title. Preserve all fixture content, tests and Hypervel-specific ordered-string assertions. Reconciles https://github.com/laravel/framework/pull/58595 against Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The HTML assertions already encode quotes correctly and the escaped-apostrophe regression is present, so this completes the porting conventions without another source change or test. Validation: MailMailableAssertionsTest and the focused schema/mail/validation selection pass, together with full source and type-fixture analysis and formatting. --- tests/Mail/MailMailableAssertionsTest.php | 44 +++++++++++++---------- 1 file changed, 25 insertions(+), 19 deletions(-) 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]; + } +} From bf9e8078782913e23c8f3dc553922c094fcc00d9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:50:07 +0000 Subject: [PATCH 09/18] Stop migration commands when a child operation fails migrate:refresh discarded the exit codes from reset, rollback, migrate and seed. A prohibited child could therefore leave the database unrefreshed while later operations ran and the parent reported success. migrate --seed also reported success when db:seed returned a failure. Check those results at the existing call sites and throw RuntimeException, following migrate:fresh. Keep the protected helpers' void signatures and the event-before-seeding order. The existing migration connection cleanup and explicit --graceful behavior remain responsible for those concerns. Normalize refresh's --step at the command-line boundary. Symfony supplies a string for --step=2, which previously failed against the natively typed rollback helper. Update the existing forwarding test to exercise that input. Add focused coverage for each failed refresh child and normal/graceful seed failures. The graceful assertion requires the warning as well as success, so it rejects the former silent-success behavior. Discovered while reconciling https://github.com/laravel/framework/pull/60928 against Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The ignored exit codes are also present upstream; the strict --step error is specific to Hypervel's typing. This commit does not complete the broader PR port. Validation: focused migration and seeding tests, refresh integration tests, PHP CS Fixer and full PHPStan source/type-fixture analysis pass. --- .../src/Console/Migrations/MigrateCommand.php | 14 +-- .../src/Console/Migrations/RefreshCommand.php | 39 ++++++--- .../DatabaseMigrationMigrateCommandTest.php | 36 ++++++++ .../DatabaseMigrationRefreshCommandTest.php | 87 +++++++++++++++++-- 4 files changed, 152 insertions(+), 24 deletions(-) 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..0e8e6f275f 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,14 @@ class RefreshCommand extends Command /** * Execute the console command. + * + * @throws RuntimeException */ public function handle(): int { if ($this->isProhibited() || ! $this->confirmToProceed()) { - return Command::FAILURE; + return self::FAILURE; } // Next we'll gather some of the options so that we can have the right options @@ -48,7 +51,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 +62,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 +83,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 +131,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/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'; } From 641040bb92385a2a1a53a4cc44f13a7dd1e9a008 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:11:51 +0000 Subject: [PATCH 10/18] Handle maintenance deactivation between cache reads Port Laravel #61121 from the current 13.x source, adapting its race correction to Hypervel's array-returning maintenance drivers and worker snapshot cache. When maintenance ends between the activity and payload reads, recheck activity before retaining an empty worker snapshot or returning a generic maintenance response. Keep genuinely active empty payloads valid, preserve file-removal handling, and leave the worker refresh policy unchanged. Normal inactive reads and nonempty snapshot refreshes need no extra I/O. Mark the maintenance activity contract impure for static analysis because external state can change between calls. This expresses the existing contract without an analysis suppression or runtime workaround. Port the upstream direct-cache HTTP regression and cover active empty payloads and snapshot reuse. Both regression paths fail before their source corrections. Complete native typing in the affected test files. Validation: focused maintenance/provider tests, full source and type-fixture analysis, formatting, and diff checks pass. Upstream: https://github.com/laravel/framework/pull/61121 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 --- .../src/Foundation/MaintenanceMode.php | 2 + .../PreventRequestsDuringMaintenance.php | 5 + .../src/WorkerCachedMaintenanceMode.php | 8 +- .../WorkerCachedMaintenanceModeTest.php | 33 ++++-- .../Foundation/MaintenanceModeTest.php | 106 +++++++++++++----- 5 files changed, 120 insertions(+), 34 deletions(-) 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/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php b/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php index a14b2c4f9f..b7a1eb273f 100644 --- a/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php +++ b/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php @@ -56,6 +56,11 @@ public function handle(Request $request, Closure $next): mixed } $data = $this->app->maintenanceMode()->data(); + + // Maintenance may end between reads; an empty payload alone does not mean it ended. + if ($data === [] && ! $this->app->maintenanceMode()->active()) { + return $next($request); + } } catch (FileNotFoundException) { 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/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/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index ab1822c3d2..2800022a60 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -4,11 +4,14 @@ 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\Foundation\CacheBasedMaintenanceMode; use Hypervel\Foundation\Console\DownCommand; use Hypervel\Foundation\Console\UpCommand; use Hypervel\Foundation\Events\MaintenanceModeDisabled; @@ -27,9 +30,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 +43,9 @@ protected function setUp(): void parent::setUp(); } + /** + * Tear down the test environment. + */ protected function tearDown(): void { FailingReloadDownCommand::$reloadAttempted = false; @@ -47,14 +56,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,6 +74,44 @@ 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); @@ -72,7 +119,7 @@ public function testConcurrentMaintenanceFileRemovalAllowsTheRequestToProceed(): $mode->shouldReceive('data')->twice()->andThrow(new FileNotFoundException('removed')); $this->app->instance(MaintenanceModeContract::class, $mode); - 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 +127,14 @@ public function testConcurrentMaintenanceFileRemovalAllowsTheRequestToProceed(): $this->assertSame('Hello World', $response->original); } - public function testMaintenanceModeCanHaveCustomStatus() + 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 +144,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 +170,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 +188,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 +198,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 +222,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 +230,7 @@ public function testMaintenanceModeCanRedirectWithBypassCookie() 'template' => 'Rendered Content', ])); - Route::get('/foo', function () { + Route::get('/foo', function (): string { return 'Hello World'; })->middleware(PreventRequestsDuringMaintenance::class); @@ -193,7 +240,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 +249,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 +261,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 +271,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 +279,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 +288,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 +313,7 @@ public function testCanCreateBypassCookies(): void $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'test-key')); } - public function testDispatchEventWhenMaintenanceModeIsEnabled() + public function testDispatchEventWhenMaintenanceModeIsEnabled(): void { Event::fake(); @@ -275,7 +322,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 +370,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 +411,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 +470,9 @@ public function testMaintenanceModeRetryCanAcceptDatetime(string $datetime): voi CarbonImmutable::setTestNow(); } + /** + * Get the supported retry date formats. + */ public static function retryAfterDatetimeProvider(): array { return [ @@ -450,7 +500,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 +544,7 @@ public function testMaintenanceModeCanBeRefreshedWithNewOptions(): void $this->assertSame(120, $data['retry']); } - public function testMaintenanceModeRespectsBootstrapConfiguredExcludedPaths() + public function testMaintenanceModeRespectsBootstrapConfiguredExcludedPaths(): void { PreventRequestsDuringMaintenance::except([ '/api/*', @@ -517,6 +567,9 @@ class FailingReloadDownCommand extends DownCommand public static ?Throwable $reloadFailure = null; + /** + * Simulate a worker reload failure. + */ protected function reloadWorkers(): void { static::$reloadAttempted = true; @@ -533,6 +586,9 @@ class FailingReloadUpCommand extends UpCommand public static ?Throwable $reloadFailure = null; + /** + * Simulate a worker reload failure. + */ protected function reloadWorkers(): void { static::$reloadAttempted = true; From 12179e541e7721ee54e692e0462a5e3fd3141a74 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:22:53 +0000 Subject: [PATCH 11/18] Complete maintenance cookie validation and usage documentation Port Laravel #61314 from the current 13.x source. Require a string MAC before comparing a maintenance bypass cookie signature, retaining immutable expiry handling and the existing cookie contract. Merge the complete upstream validation test without duplicating unit coverage. Document how to exclude URLs during maintenance and replace maintenance options without bringing the application online. Clarify that options to retain, including secrets and redirects, must be supplied again. These additions complete usage coverage for the already-present #58571, #58798 and #58918 behavior; #60232 exception reporting remains covered by the existing command implementation and tests. Validation: maintenance integration tests, focused maintenance and middleware configuration tests, full source and type-fixture analysis, formatting, and diff checks pass. Upstream: https://github.com/laravel/framework/pull/61314 Related: https://github.com/laravel/framework/pull/58571 Related: https://github.com/laravel/framework/pull/58798 Related: https://github.com/laravel/framework/pull/58918 Related: https://github.com/laravel/framework/pull/60232 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 --- src/docs/configuration.md | 17 +++++++++++++++++ .../src/Http/MaintenanceModeBypassCookie.php | 2 +- .../Foundation/MaintenanceModeTest.php | 9 +++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) 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/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/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index 2800022a60..12f8a3436c 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -313,6 +313,15 @@ public function testCanCreateBypassCookies(): void $this->assertFalse(MaintenanceModeBypassCookie::isValid($cookie->getValue(), 'test-key')); } + 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(); From 95e732ab2497cbb7a2e6cf8ca24a8d5796f45239 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:49:50 +0000 Subject: [PATCH 12/18] Use consistent command exit-code constants Complete Laravel's command success/failure constant cleanup across database, foundation, queue and migration generators, including Hypervel's cache commands and additional migration child-result checks. Preserve the existing exit values, nullable queue-command results, database preflight ordering, coroutine connection cleanup and maintenance reload/error handling. Type ConfigShow's integer result and formatting callback, narrow the database cache pruning command to its actual integer result, and restore the affected command title comments. Correct the event-dispatcher option's help text and update its existing signature fixture. Upstream: https://github.com/laravel/framework/pull/60928 https://github.com/laravel/framework/pull/60934 Compared against framework 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. DumpCommand already had the explicit success return and native integer type; this completes its constant spelling alongside the wider port. Validation: existing command signature, generator, configuration, migration, maintenance and cache command tests pass. Formatting and full source/type fixture analysis pass. No new tests were needed for constant substitutions. --- src/cache/src/Console/CacheTableCommand.php | 2 +- .../src/Console/PruneDbExpiredCommand.php | 6 +++--- .../src/Console/PruneStaleTagsCommand.php | 4 ++-- .../src/Concerns/DisableEventDispatcher.php | 2 +- src/console/src/MigrationGeneratorCommand.php | 4 ++-- src/database/src/Console/DbCommand.php | 6 +++--- src/database/src/Console/DumpCommand.php | 4 ++-- .../src/Console/Migrations/FreshCommand.php | 13 ++++++------- .../src/Console/Migrations/RefreshCommand.php | 3 +-- .../src/Console/Migrations/ResetCommand.php | 10 ++++------ .../src/Console/Migrations/RollbackCommand.php | 8 +++----- .../src/Console/Migrations/StatusCommand.php | 4 ++-- src/database/src/Console/Seeds/SeedCommand.php | 7 +++---- src/database/src/Console/ShowCommand.php | 2 +- src/database/src/Console/ShowModelCommand.php | 4 ++-- src/database/src/Console/TableCommand.php | 4 ++-- src/database/src/Console/WipeCommand.php | 7 +++---- src/foundation/src/Console/AboutCommand.php | 8 +++++++- .../src/Console/ConfigShowCommand.php | 18 +++++++++++++++--- src/foundation/src/Console/DownCommand.php | 4 ++-- src/foundation/src/Console/UpCommand.php | 6 +++--- src/queue/src/Console/ForgetFailedCommand.php | 2 +- .../src/Console/PruneFailedJobsCommand.php | 2 +- tests/Console/Fixtures/command_signatures.php | 4 ++-- 24 files changed, 72 insertions(+), 62 deletions(-) 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/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/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/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/RefreshCommand.php b/src/database/src/Console/Migrations/RefreshCommand.php index 0e8e6f275f..032543b2ab 100644 --- a/src/database/src/Console/Migrations/RefreshCommand.php +++ b/src/database/src/Console/Migrations/RefreshCommand.php @@ -36,8 +36,7 @@ class RefreshCommand extends Command */ public function handle(): int { - if ($this->isProhibited() - || ! $this->confirmToProceed()) { + if ($this->isProhibited() || ! $this->confirmToProceed()) { return self::FAILURE; } 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/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/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/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', ], ], ], From 777a20063d16ff9d15ada9b5a3974e7bc107b603 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:51:50 +0000 Subject: [PATCH 13/18] Complete lazy Eloquent creation values and fix through-relation collisions Complete the callback return contracts and missing provider coverage from Laravel #58639 and #59647 against framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Document closure values and the unique-index requirement for createOrFirst. Keep through-relation lookup attributes separate from creation values so a concurrent insert can be retrieved even when its other values differ. Forward closures into the existing createOrFirst savepoint, preserving rollback of callback database writes and the write-connection retry. Correct the collision test that mistakenly called firstOrCreate instead of updateOrCreate, and require a real update of the winning row. Extend through firstOrNew and updateOrCreate to accept closure values, matching the other relation helpers. Incorporate the two closure integration cases from the closed, unmerged #61137 proposal without adopting its query-cloning changes. Preserve early validation for unsupported builders and all existing relationship behavior. Verified each changed test file, the related SQLite database suite, formatting, full source and type-fixture analysis, and the final review corrections. No new shared state, queries, savepoints, or compatibility machinery. Upstream: https://github.com/laravel/framework/pull/58639 Upstream: https://github.com/laravel/framework/pull/59647 Partial proposal adoption: https://github.com/laravel/framework/pull/61137 --- src/database/src/Eloquent/Builder.php | 6 + .../src/Eloquent/Relations/BelongsToMany.php | 6 + .../src/Eloquent/Relations/HasOneOrMany.php | 6 + .../Relations/HasOneOrManyThrough.php | 16 +- src/docs/eloquent.md | 19 ++- ...tabaseEloquentBuilderCreateOrFirstTest.php | 11 +- ...aseEloquentCreateOrFirstValidationTest.php | 8 +- ...loquentHasManyThroughCreateOrFirstTest.php | 87 ++++++---- .../Database/EloquentHasManyThroughTest.php | 152 ++++++++++++++---- 9 files changed, 238 insertions(+), 73 deletions(-) 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/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/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/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/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); } From 0e40ed9fa2007643af6ff78864f8cb974be2934c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:21:29 +0000 Subject: [PATCH 14/18] Allow fluent string deduplication of multiple characters Port Laravel's array|string characters parameter to Stringable::deduplicate so fluent calls accept the same inputs as Str::deduplicate. Keep the direct forwarding implementation and native static return type, and use Laravel's parameter name for named arguments. Merge the upstream array regression into the existing test. Document arrays in both string references and correct the fluent entry's argument wording. Upstream: https://github.com/laravel/framework/pull/58649 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: affected Stringable tests, focused Support/Notifications/Bus/ Translation tests, full PHPStan source and type-fixture analysis, and repository formatting all pass. --- src/docs/strings.md | 22 +++++++++++++++++++++- src/support/src/Stringable.php | 6 ++++-- tests/Support/SupportStringableTest.php | 3 ++- 3 files changed, 27 insertions(+), 4 deletions(-) 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/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/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() From 4f1ace1715e36c16c548c93945ec1f4eabca78cc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:21:45 +0000 Subject: [PATCH 15/18] Complete notification hook coverage and fixture contracts Finish the afterSending test's exact driver and event expectations from current Laravel, accounting for Hypervel's NotificationDelivered boundary. The hook, its callback ordering and exception behavior are already present. Type the notification fixtures and their deduplication callbacks according to the actual sender and SQS call sites. Use a string queue in the existing callback invocation, matching the resolved queue passed by SQS. Preserve the per-channel assertions and nullable message-group and deduplicator results. Remove ten unused message methods inherited from upstream fixtures. They call a line method that no longer exists on Notification and are not used by any test. No assertion or production notification behavior is removed. Upstream: https://github.com/laravel/framework/pull/58654 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: notification channel manager tests and the focused Support, Notifications, Bus and Translation suites pass. Repository formatting and full PHPStan source and type-fixture analysis pass. --- .../NotificationChannelManagerTest.php | 182 ++++++++++-------- 1 file changed, 107 insertions(+), 75 deletions(-) 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; From cfdc5a74baa67f7ec8bde1771beccd703860926e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:22:04 +0000 Subject: [PATCH 16/18] Complete batch cancellation event documentation and test types Preserve the upstream explanation that BatchCanceled carries the exception that caused cancellation. Its native property type alone does not describe that relationship. Add the native void return and object-to-bool predicate types to the existing event test. Keep its batch and exception identity checks. Event dispatch, listener guards, failure propagation and the fake already cover the current upstream behavior and require no runtime change. Upstream: https://github.com/laravel/framework/pull/58627 https://github.com/laravel/framework/pull/59163 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: BusBatchTest, focused Support/Notifications/Bus/Translation tests, full PHPStan source and type-fixture analysis, and repository formatting pass. --- src/bus/src/Events/BatchCanceled.php | 2 ++ tests/Bus/BusBatchTest.php | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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; From a356387cb34e4edd9b7efcc1eb97908ea4af31f9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:17:49 +0000 Subject: [PATCH 17/18] Handle maintenance file removal during worker refreshes Reading the cached maintenance state also reads the file payload. If artisan up removed that file between the existence check and read, active() threw instead of returning false. Queue workers and scheduled tasks could therefore stop while maintenance was being disabled. Handle disappearance in FileBasedMaintenanceMode so every caller benefits. Return an empty payload only after confirming the file is gone; retain the original exception when an existing file cannot be read. The existing cached state recheck then observes deactivation. Successful reads perform no extra filesystem operations, and JSON validation remains unchanged. Cover missing files, removal during a cached state refresh, and read errors on an existing file. Verified the original reproduction, the affected test files, the focused maintenance suite, formatting and full static analysis. Completes Hypervel's file-driver handling alongside the maintenance race port: https://github.com/laravel/framework/pull/61121 --- .../src/FileBasedMaintenanceMode.php | 16 +++++++- ...FoundationFileBasedMaintenanceModeTest.php | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) 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/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; From df9a48d39a42cfaf5090d5a50b653a8fb7ad4267 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:17:58 +0000 Subject: [PATCH 18/18] Propagate maintenance file read failures through HTTP middleware The middleware's unconditional FileNotFoundException catch treated an unreadable maintenance file as permission to serve the application. The file driver now distinguishes a file removed by artisan up from an existing file that cannot be read, so the middleware must preserve that distinction. Remove the unconditional catch. Requests continue when the file disappears; existing-file read failures reach the exception handler. Keep the empty- payload activity recheck and all maintenance response behavior intact. Exercise the real file driver in the existing concurrent-removal HTTP test and add a separate test proving an unreadable file does not let the request through. The changed files, focused maintenance suite and full static analysis pass; formatting is clean. --- .../PreventRequestsDuringMaintenance.php | 17 ++++------ .../Foundation/MaintenanceModeTest.php | 34 ++++++++++++++++--- 2 files changed, 36 insertions(+), 15 deletions(-) diff --git a/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php b/src/foundation/src/Http/Middleware/PreventRequestsDuringMaintenance.php index b7a1eb273f..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,18 +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(); - // Maintenance may end between reads; an empty payload alone does not mean it ended. - if ($data === [] && ! $this->app->maintenanceMode()->active()) { - return $next($request); - } - } 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/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php index 12f8a3436c..c216721b0e 100644 --- a/tests/Integration/Foundation/MaintenanceModeTest.php +++ b/tests/Integration/Foundation/MaintenanceModeTest.php @@ -11,11 +11,13 @@ 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; @@ -114,10 +116,12 @@ public function testActiveCacheMaintenanceModeWithAnEmptyPayloadBlocksRequests() 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 (): string => 'Hello World')->middleware(PreventRequestsDuringMaintenance::class); @@ -127,6 +131,28 @@ public function testConcurrentMaintenanceFileRemovalAllowsTheRequestToProceed(): $this->assertSame('Hello World', $response->original); } + 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([