diff --git a/config/filesystems.php b/config/filesystems.php index c5f244d7fca0..c9bd1d09468f 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -34,6 +34,7 @@ 'driver' => 'local', 'root' => storage_path('app'), 'throw' => false, + 'report' => false, ], 'public' => [ @@ -42,6 +43,7 @@ 'url' => env('APP_URL').'/storage', 'visibility' => 'public', 'throw' => false, + 'report' => false, ], 's3' => [ @@ -54,6 +56,7 @@ 'endpoint' => env('AWS_ENDPOINT'), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'throw' => false, + 'report' => false, ], ], diff --git a/src/Illuminate/Bus/DatabaseBatchRepository.php b/src/Illuminate/Bus/DatabaseBatchRepository.php index d6130ede6c63..2e0c7c68a9e6 100644 --- a/src/Illuminate/Bus/DatabaseBatchRepository.php +++ b/src/Illuminate/Bus/DatabaseBatchRepository.php @@ -319,7 +319,7 @@ public function transaction(Closure $callback) */ public function rollBack() { - $this->connection->rollBack(); + $this->connection->rollBack(toLevel: 0); } /** diff --git a/src/Illuminate/Bus/UniqueLock.php b/src/Illuminate/Bus/UniqueLock.php index dea12303b719..9a2726e9d8a5 100644 --- a/src/Illuminate/Bus/UniqueLock.php +++ b/src/Illuminate/Bus/UniqueLock.php @@ -64,7 +64,7 @@ public function release($job) * @param mixed $job * @return string */ - protected function getKey($job) + public static function getKey($job) { $uniqueId = method_exists($job, 'uniqueId') ? $job->uniqueId() diff --git a/src/Illuminate/Cache/LuaScripts.php b/src/Illuminate/Cache/LuaScripts.php index 6d22fcd4357b..a61cffe3276f 100644 --- a/src/Illuminate/Cache/LuaScripts.php +++ b/src/Illuminate/Cache/LuaScripts.php @@ -4,6 +4,22 @@ class LuaScripts { + /** + * Get the Lua script that sets a key only when it does not yet exist. + * + * KEYS[1] - The name of the key + * ARGV[1] - The value of the key + * ARGV[2] - The number of seconds the key should be valid + * + * @return string + */ + public static function add() + { + return <<<'LUA' +return redis.call('exists',KEYS[1])<1 and redis.call('setex',KEYS[1],ARGV[2],ARGV[1]) +LUA; + } + /** * Get the Lua script to atomically release a lock. * diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index c61e925f3687..d84c9a505965 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -68,9 +68,11 @@ public function __construct(Redis $redis, $prefix = '', $connection = 'default') */ public function get($key) { - $value = $this->connection()->get($this->prefix.$key); + $connection = $this->connection(); + + $value = $connection->get($this->prefix.$key); - return ! is_null($value) ? $this->unserialize($value) : null; + return ! is_null($value) ? $this->connectionAwareUnserialize($value, $connection) : null; } /** @@ -89,12 +91,14 @@ public function many(array $keys) $results = []; - $values = $this->connection()->mget(array_map(function ($key) { + $connection = $this->connection(); + + $values = $connection->mget(array_map(function ($key) { return $this->prefix.$key; }, $keys)); foreach ($values as $index => $value) { - $results[$keys[$index]] = ! is_null($value) ? $this->unserialize($value) : null; + $results[$keys[$index]] = ! is_null($value) ? $this->connectionAwareUnserialize($value, $connection) : null; } return $results; @@ -110,8 +114,10 @@ public function many(array $keys) */ public function put($key, $value, $seconds) { - return (bool) $this->connection()->setex( - $this->prefix.$key, (int) max(1, $seconds), $this->serialize($value) + $connection = $this->connection(); + + return (bool) $connection->setex( + $this->prefix.$key, (int) max(1, $seconds), $this->connectionAwareSerialize($value, $connection) ); } @@ -165,10 +171,10 @@ public function putMany(array $values, $seconds) */ public function add($key, $value, $seconds) { - $lua = "return redis.call('exists',KEYS[1])<1 and redis.call('setex',KEYS[1],ARGV[2],ARGV[1])"; + $connection = $this->connection(); - return (bool) $this->connection()->eval( - $lua, 1, $this->prefix.$key, $this->serialize($value), (int) max(1, $seconds) + return (bool) $connection->eval( + LuaScripts::add(), 1, $this->prefix.$key, $this->pack($value, $connection), (int) max(1, $seconds) ); } @@ -205,7 +211,9 @@ public function decrement($key, $value = 1) */ public function forever($key, $value) { - return (bool) $this->connection()->set($this->prefix.$key, $this->serialize($value)); + $connection = $this->connection(); + + return (bool) $connection->set($this->prefix.$key, $this->connectionAwareSerialize($value, $connection)); } /** @@ -313,7 +321,7 @@ protected function currentTags($chunkSize = 1000) $prefix = $connectionPrefix.$this->getPrefix(); - return LazyCollection::make(function () use ($connection, $chunkSize, $prefix, $defaultCursorValue) { + return (new LazyCollection(function () use ($connection, $chunkSize, $prefix, $defaultCursorValue) { $cursor = $defaultCursorValue; do { @@ -336,7 +344,7 @@ protected function currentTags($chunkSize = 1000) yield $tag; } } while (((string) $cursor) !== $defaultCursorValue); - })->map(fn (string $tagKey) => Str::match('/^'.preg_quote($prefix, '/').'tag:(.*):entries$/', $tagKey)); + }))->map(fn (string $tagKey) => Str::match('/^'.preg_quote($prefix, '/').'tag:(.*):entries$/', $tagKey)); } /** @@ -414,6 +422,28 @@ public function setPrefix($prefix) $this->prefix = $prefix; } + /** + * Prepare a value to be used with the Redis cache store when used by eval scripts. + * + * @param mixed $value + * @param \Illuminate\Redis\Connections\Connection $connection + * @return mixed + */ + protected function pack($value, $connection) + { + if ($connection instanceof PhpRedisConnection) { + if ($connection->serialized()) { + return $connection->pack([$value])[0]; + } + + if ($connection->compressed()) { + return $connection->pack([$this->serialize($value)])[0]; + } + } + + return $this->serialize($value); + } + /** * Serialize the value. * @@ -435,4 +465,36 @@ protected function unserialize($value) { return is_numeric($value) ? $value : unserialize($value); } + + /** + * Handle connection specific considerations when a value needs to be serialized. + * + * @param mixed $value + * @param \Illuminate\Redis\Connections\Connection $connection + * @return mixed + */ + protected function connectionAwareSerialize($value, $connection) + { + if ($connection instanceof PhpRedisConnection && $connection->serialized()) { + return $value; + } + + return $this->serialize($value); + } + + /** + * Handle connection specific considerations when a value needs to be unserialized. + * + * @param mixed $value + * @param \Illuminate\Redis\Connections\Connection $connection + * @return mixed + */ + protected function connectionAwareUnserialize($value, $connection) + { + if ($connection instanceof PhpRedisConnection && $connection->serialized()) { + return $value; + } + + return $this->unserialize($value); + } } diff --git a/src/Illuminate/Cache/RedisTagSet.php b/src/Illuminate/Cache/RedisTagSet.php index c6aea191a83b..267c11607cd4 100644 --- a/src/Illuminate/Cache/RedisTagSet.php +++ b/src/Illuminate/Cache/RedisTagSet.php @@ -43,7 +43,7 @@ public function entries() default => '0', }; - return LazyCollection::make(function () use ($connection, $defaultCursorValue) { + return new LazyCollection(function () use ($connection, $defaultCursorValue) { foreach ($this->tagIds() as $tagKey) { $cursor = $defaultCursorValue; diff --git a/src/Illuminate/Database/Concerns/BuildsQueries.php b/src/Illuminate/Database/Concerns/BuildsQueries.php index e1292fd1df23..a287c0abcee2 100644 --- a/src/Illuminate/Database/Concerns/BuildsQueries.php +++ b/src/Illuminate/Database/Concerns/BuildsQueries.php @@ -259,7 +259,7 @@ public function lazy($chunkSize = 1000) $this->enforceOrderBy(); - return LazyCollection::make(function () use ($chunkSize) { + return new LazyCollection(function () use ($chunkSize) { $page = 1; while (true) { @@ -327,7 +327,7 @@ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = n $alias ??= $column; - return LazyCollection::make(function () use ($chunkSize, $column, $alias, $descending) { + return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending) { $lastId = null; while (true) { diff --git a/src/Illuminate/Database/Console/Migrations/RollbackCommand.php b/src/Illuminate/Database/Console/Migrations/RollbackCommand.php index 0a88ec5a1a06..8846a5e376cf 100755 --- a/src/Illuminate/Database/Console/Migrations/RollbackCommand.php +++ b/src/Illuminate/Database/Console/Migrations/RollbackCommand.php @@ -2,7 +2,9 @@ namespace Illuminate\Database\Console\Migrations; +use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Console\Prohibitable; use Illuminate\Database\Migrations\Migrator; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -10,7 +12,7 @@ #[AsCommand('migrate:rollback')] class RollbackCommand extends BaseCommand { - use ConfirmableTrait; + use ConfirmableTrait, Prohibitable; /** * The console command name. @@ -53,8 +55,9 @@ public function __construct(Migrator $migrator) */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } $this->migrator->usingConnection($this->option('database'), function () { diff --git a/src/Illuminate/Database/Eloquent/Attributes/UseFactory.php b/src/Illuminate/Database/Eloquent/Attributes/UseFactory.php new file mode 100644 index 000000000000..7f56804e2134 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/UseFactory.php @@ -0,0 +1,19 @@ + $factoryClass + * @return void + */ + public function __construct(public string $factoryClass) + { + } +} diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index a0db35d1023c..e3ee48f9bd55 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -950,7 +950,7 @@ public function pluck($column, $key = null) if (! $this->model->hasAnyGetMutator($column) && ! $this->model->hasCast($column) && ! in_array($column, $this->model->getDates())) { - return $results; + return $this->applyAfterQueryCallbacks($results); } return $this->applyAfterQueryCallbacks( diff --git a/src/Illuminate/Database/Eloquent/Factories/HasFactory.php b/src/Illuminate/Database/Eloquent/Factories/HasFactory.php index 45b3b851badd..2078d9f025f3 100644 --- a/src/Illuminate/Database/Eloquent/Factories/HasFactory.php +++ b/src/Illuminate/Database/Eloquent/Factories/HasFactory.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Eloquent\Factories; +use Illuminate\Database\Eloquent\Attributes\UseFactory; + /** * @template TFactory of \Illuminate\Database\Eloquent\Factories\Factory */ @@ -34,6 +36,27 @@ protected static function newFactory() return static::$factory::new(); } - return null; + return static::getUseFactoryAttribute() ?? null; + } + + /** + * Get the factory from the UseFactory class attribute. + * + * @return TFactory|null + */ + protected static function getUseFactoryAttribute() + { + $attributes = (new \ReflectionClass(static::class)) + ->getAttributes(UseFactory::class); + + if ($attributes !== []) { + $useFactory = $attributes[0]->newInstance(); + + $factory = new $useFactory->factoryClass; + + $factory->guessModelNamesUsing(fn () => static::class); + + return $factory; + } } } diff --git a/src/Illuminate/Filesystem/Filesystem.php b/src/Illuminate/Filesystem/Filesystem.php index 8bba99a2f2ed..67e31c0bf4f9 100644 --- a/src/Illuminate/Filesystem/Filesystem.php +++ b/src/Illuminate/Filesystem/Filesystem.php @@ -168,7 +168,7 @@ public function lines($path) ); } - return LazyCollection::make(function () use ($path) { + return new LazyCollection(function () use ($path) { $file = new SplFileObject($path); $file->setFlags(SplFileObject::DROP_NEW_LINE); diff --git a/src/Illuminate/Filesystem/FilesystemAdapter.php b/src/Illuminate/Filesystem/FilesystemAdapter.php index 18f14235ad0d..46e23072c58d 100644 --- a/src/Illuminate/Filesystem/FilesystemAdapter.php +++ b/src/Illuminate/Filesystem/FilesystemAdapter.php @@ -3,6 +3,8 @@ namespace Illuminate\Filesystem; use Closure; +use Illuminate\Container\Container; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Contracts\Filesystem\Cloud as CloudFilesystemContract; use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract; use Illuminate\Http\File; @@ -35,6 +37,7 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; use Symfony\Component\HttpFoundation\StreamedResponse; +use Throwable; /** * @mixin \League\Flysystem\FilesystemOperator @@ -288,6 +291,8 @@ public function get($path) return $this->driver->read($path); } catch (UnableToReadFile $e) { throw_if($this->throwsExceptions(), $e); + + $this->report($e); } } @@ -417,6 +422,8 @@ public function put($path, $contents, $options = []) } catch (UnableToWriteFile|UnableToSetVisibility $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -502,6 +509,8 @@ public function setVisibility($path, $visibility) } catch (UnableToSetVisibility $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -560,6 +569,8 @@ public function delete($paths) } catch (UnableToDeleteFile $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + $success = false; } } @@ -581,6 +592,8 @@ public function copy($from, $to) } catch (UnableToCopyFile $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -601,6 +614,8 @@ public function move($from, $to) } catch (UnableToMoveFile $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -632,6 +647,8 @@ public function checksum(string $path, array $options = []) } catch (UnableToProvideChecksum $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } } @@ -648,6 +665,8 @@ public function mimeType($path) return $this->driver->mimeType($path); } catch (UnableToRetrieveMetadata $e) { throw_if($this->throwsExceptions(), $e); + + $this->report($e); } return false; @@ -673,6 +692,8 @@ public function readStream($path) return $this->driver->readStream($path); } catch (UnableToReadFile $e) { throw_if($this->throwsExceptions(), $e); + + $this->report($e); } } @@ -686,6 +707,8 @@ public function writeStream($path, $resource, array $options = []) } catch (UnableToWriteFile|UnableToSetVisibility $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -918,6 +941,8 @@ public function makeDirectory($path) } catch (UnableToCreateDirectory|UnableToSetVisibility $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -937,6 +962,8 @@ public function deleteDirectory($directory) } catch (UnableToDeleteDirectory $e) { throw_if($this->throwsExceptions(), $e); + $this->report($e); + return false; } @@ -1026,6 +1053,29 @@ protected function throwsExceptions(): bool return (bool) ($this->config['throw'] ?? false); } + /** + * @param Throwable $exception + * @return void + * + * @throws Throwable + */ + protected function report($exception) + { + if ($this->shouldReport() && Container::getInstance()->bound(ExceptionHandler::class)) { + Container::getInstance()->make(ExceptionHandler::class)->report($exception); + } + } + + /** + * Determine if Flysystem exceptions should be reported. + * + * @return bool + */ + protected function shouldReport(): bool + { + return (bool) ($this->config['report'] ?? false); + } + /** * Pass dynamic methods call onto Flysystem. * diff --git a/src/Illuminate/Foundation/Bus/PendingDispatch.php b/src/Illuminate/Foundation/Bus/PendingDispatch.php index fb85b52b7a68..d7a299d42f81 100644 --- a/src/Illuminate/Foundation/Bus/PendingDispatch.php +++ b/src/Illuminate/Foundation/Bus/PendingDispatch.php @@ -7,9 +7,12 @@ use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Queue\ShouldBeUnique; +use Illuminate\Foundation\Queue\InteractsWithUniqueJobs; class PendingDispatch { + use InteractsWithUniqueJobs; + /** * The job. * @@ -207,12 +210,18 @@ public function __call($method, $parameters) */ public function __destruct() { + $this->addUniqueJobInformationToContext($this->job); + if (! $this->shouldDispatch()) { + $this->removeUniqueJobInformationFromContext($this->job); + return; } elseif ($this->afterResponse) { app(Dispatcher::class)->dispatchAfterResponse($this->job); } else { app(Dispatcher::class)->dispatch($this->job); } + + $this->removeUniqueJobInformationFromContext($this->job); } } diff --git a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php index 3e08ad2de1c3..974af84f947c 100644 --- a/src/Illuminate/Foundation/Console/OptimizeClearCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeClearCommand.php @@ -34,7 +34,7 @@ public function handle() { $this->components->info('Clearing cached bootstrap files.'); - $exceptions = Collection::wrap(explode(',', $this->option('except'))) + $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) ->map(fn ($except) => trim($except)) ->filter() ->unique() diff --git a/src/Illuminate/Foundation/Console/OptimizeCommand.php b/src/Illuminate/Foundation/Console/OptimizeCommand.php index 2440ba8ab34f..ef78e8c0af77 100644 --- a/src/Illuminate/Foundation/Console/OptimizeCommand.php +++ b/src/Illuminate/Foundation/Console/OptimizeCommand.php @@ -34,7 +34,7 @@ public function handle() { $this->components->info('Caching framework bootstrap, configuration, and metadata.'); - $exceptions = Collection::wrap(explode(',', $this->option('except'))) + $exceptions = Collection::wrap(explode(',', $this->option('except') ?? '')) ->map(fn ($except) => trim($except)) ->filter() ->unique() diff --git a/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php new file mode 100644 index 000000000000..064c3f094233 --- /dev/null +++ b/src/Illuminate/Foundation/Queue/InteractsWithUniqueJobs.php @@ -0,0 +1,55 @@ + $this->getUniqueJobCacheStore($job), + 'laravel_unique_job_key' => UniqueLock::getKey($job), + ]); + } + } + + /** + * Remove the unique job information from the context. + * + * @param mixed $job + * @return void + */ + public function removeUniqueJobInformationFromContext($job): void + { + if ($job instanceof ShouldBeUnique) { + Context::forgetHidden([ + 'laravel_unique_job_cache_store', + 'laravel_unique_job_key', + ]); + } + } + + /** + * Determine the cache store used by the unique job to acquire locks. + * + * @param mixed $job + * @return string|null + */ + protected function getUniqueJobCacheStore($job): ?string + { + return method_exists($job, 'uniqueVia') + ? $job->uniqueVia()->getName() + : config('cache.default'); + } +} diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 861905cfe0ac..b77ea8ed0e51 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -800,7 +800,7 @@ protected function makeStylesheetTagWithAttributes($url, $attributes) */ protected function isCssPath($path) { - return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)$/', $path) === 1; + return preg_match('/\.(css|less|sass|scss|styl|stylus|pcss|postcss)(\?[^\.]*)?$/', $path) === 1; } /** diff --git a/src/Illuminate/Queue/CallQueuedHandler.php b/src/Illuminate/Queue/CallQueuedHandler.php index 4f2e9e9ce9ef..4bd5aa30feb2 100644 --- a/src/Illuminate/Queue/CallQueuedHandler.php +++ b/src/Illuminate/Queue/CallQueuedHandler.php @@ -6,6 +6,7 @@ use Illuminate\Bus\Batchable; use Illuminate\Bus\UniqueLock; use Illuminate\Contracts\Bus\Dispatcher; +use Illuminate\Contracts\Cache\Factory as CacheFactory; use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Encryption\Encrypter; @@ -13,6 +14,7 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Log\Context\Repository as ContextRepository; use Illuminate\Pipeline\Pipeline; use Illuminate\Queue\Attributes\DeleteWhenMissingModels; use ReflectionClass; @@ -227,6 +229,8 @@ protected function handleModelNotFound(Job $job, $e) $shouldDelete = false; } + $this->ensureUniqueJobLockIsReleasedViaContext(); + if ($shouldDelete) { return $job->delete(); } @@ -234,6 +238,35 @@ protected function handleModelNotFound(Job $job, $e) return $job->fail($e); } + /** + * Ensure the lock for a unique job is released via context. + * + * This is required when we can't unserialize the job due to missing models. + * + * @return void + */ + protected function ensureUniqueJobLockIsReleasedViaContext() + { + if (! $this->container->bound(ContextRepository::class) || + ! $this->container->bound(CacheFactory::class)) { + return; + } + + $context = $this->container->make(ContextRepository::class); + + [$store, $key] = [ + $context->getHidden('laravel_unique_job_cache_store'), + $context->getHidden('laravel_unique_job_key'), + ]; + + if ($store && $key) { + $this->container->make(CacheFactory::class) + ->store($store) + ->lock($key) + ->forceRelease(); + } + } + /** * Call the failed method on the job instance. * diff --git a/src/Illuminate/Queue/Jobs/Job.php b/src/Illuminate/Queue/Jobs/Job.php index a41b401dddb7..f690b64963be 100755 --- a/src/Illuminate/Queue/Jobs/Job.php +++ b/src/Illuminate/Queue/Jobs/Job.php @@ -204,6 +204,12 @@ public function fail($e = null) } } + if ($this->shouldRollBackDatabaseTransaction($e)) { + $this->container->make('db') + ->connection($this->container['config']['queue.failed.database']) + ->rollBack(toLevel: 0); + } + try { // If the job has failed, we will delete it, call the "failed" method and then call // an event indicating the job has failed so it can be logged if needed. This is @@ -218,6 +224,20 @@ public function fail($e = null) } } + /** + * Determine if the current database transaction should be rolled back to level zero. + * + * @param \Throwable $e + * @return bool + */ + protected function shouldRollBackDatabaseTransaction($e) + { + return $e instanceof TimeoutExceededException && + $this->container['config']['queue.failed.database'] && + in_array($this->container['config']['queue.failed.driver'], ['database', 'database-uuids']) && + $this->container->bound('db'); + } + /** * Process an exception that caused the job to fail. * diff --git a/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php b/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php index 6e337f4fe926..925d2483b244 100644 --- a/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php +++ b/src/Illuminate/Redis/Connections/PacksPhpRedisValues.php @@ -82,6 +82,17 @@ public function pack(array $values): array return array_map($processor, $values); } + /** + * Determine if serialization is enabled. + * + * @return bool + */ + public function serialized(): bool + { + return defined('Redis::OPT_SERIALIZER') && + $this->client->getOption(Redis::OPT_SERIALIZER) !== Redis::SERIALIZER_NONE; + } + /** * Determine if compression is enabled. * diff --git a/src/Illuminate/Redis/Connectors/PhpRedisConnector.php b/src/Illuminate/Redis/Connectors/PhpRedisConnector.php index 2103155af9e9..bddc0f11044f 100644 --- a/src/Illuminate/Redis/Connectors/PhpRedisConnector.php +++ b/src/Illuminate/Redis/Connectors/PhpRedisConnector.php @@ -85,6 +85,22 @@ protected function createClient(array $config) ); } + if (array_key_exists('max_retries', $config)) { + $client->setOption(Redis::OPT_MAX_RETRIES, $config['max_retries']); + } + + if (array_key_exists('backoff_algorithm', $config)) { + $client->setOption(Redis::OPT_BACKOFF_ALGORITHM, $config['backoff_algorithm']); + } + + if (array_key_exists('backoff_base', $config)) { + $client->setOption(Redis::OPT_BACKOFF_BASE, $config['backoff_base']); + } + + if (array_key_exists('backoff_cap', $config)) { + $client->setOption(Redis::OPT_BACKOFF_CAP, $config['backoff_cap']); + } + $this->establishConnection($client, $config); if (! empty($config['password'])) { diff --git a/src/Illuminate/Redis/Connectors/PredisConnector.php b/src/Illuminate/Redis/Connectors/PredisConnector.php index 8769fc53f535..50fc39462ce0 100644 --- a/src/Illuminate/Redis/Connectors/PredisConnector.php +++ b/src/Illuminate/Redis/Connectors/PredisConnector.php @@ -6,6 +6,7 @@ use Illuminate\Redis\Connections\PredisClusterConnection; use Illuminate\Redis\Connections\PredisConnection; use Illuminate\Support\Arr; +use Illuminate\Support\Str; use Predis\Client; class PredisConnector implements Connector @@ -27,6 +28,11 @@ public function connect(array $config, array $options) $formattedOptions['prefix'] = $config['prefix']; } + if (isset($config['host']) && str_starts_with($config['host'], 'tls://')) { + $config['scheme'] = 'tls'; + $config['host'] = Str::after($config['host'], 'tls://'); + } + return new PredisConnection(new Client($config, $formattedOptions)); } diff --git a/src/Illuminate/Session/DatabaseSessionHandler.php b/src/Illuminate/Session/DatabaseSessionHandler.php index 0770c22f46e9..f4c1e9441323 100644 --- a/src/Illuminate/Session/DatabaseSessionHandler.php +++ b/src/Illuminate/Session/DatabaseSessionHandler.php @@ -288,7 +288,7 @@ public function gc($lifetime): int */ protected function getQuery() { - return $this->connection->table($this->table); + return $this->connection->table($this->table)->useWritePdo(); } /** diff --git a/src/Illuminate/Support/Facades/DB.php b/src/Illuminate/Support/Facades/DB.php index cc282903ec79..c2d4249727d2 100644 --- a/src/Illuminate/Support/Facades/DB.php +++ b/src/Illuminate/Support/Facades/DB.php @@ -5,6 +5,7 @@ use Illuminate\Database\Console\Migrations\FreshCommand; use Illuminate\Database\Console\Migrations\RefreshCommand; use Illuminate\Database\Console\Migrations\ResetCommand; +use Illuminate\Database\Console\Migrations\RollbackCommand; use Illuminate\Database\Console\WipeCommand; /** @@ -132,6 +133,7 @@ public static function prohibitDestructiveCommands(bool $prohibit = true) FreshCommand::prohibit($prohibit); RefreshCommand::prohibit($prohibit); ResetCommand::prohibit($prohibit); + RollbackCommand::prohibit($prohibit); WipeCommand::prohibit($prohibit); } diff --git a/src/Illuminate/Support/ValidatedInput.php b/src/Illuminate/Support/ValidatedInput.php index 669ebdeadd0d..f3ac30c79e2a 100644 --- a/src/Illuminate/Support/ValidatedInput.php +++ b/src/Illuminate/Support/ValidatedInput.php @@ -4,13 +4,14 @@ use ArrayIterator; use Illuminate\Contracts\Support\ValidatedData; -use Illuminate\Support\Facades\Date; -use stdClass; +use Illuminate\Support\Traits\InteractsWithData; use Symfony\Component\VarDumper\VarDumper; use Traversable; class ValidatedInput implements ValidatedData { + use InteractsWithData; + /** * The underlying input. * @@ -29,149 +30,6 @@ public function __construct(array $input) $this->input = $input; } - /** - * Determine if the validated input has one or more keys. - * - * @param string|array $key - * @return bool - */ - public function exists($key) - { - return $this->has($key); - } - - /** - * Determine if the validated input has one or more keys. - * - * @param mixed $keys - * @return bool - */ - public function has($keys) - { - $keys = is_array($keys) ? $keys : func_get_args(); - - foreach ($keys as $key) { - if (! Arr::has($this->all(), $key)) { - return false; - } - } - - return true; - } - - /** - * Determine if the validated input contains any of the given keys. - * - * @param string|array $keys - * @return bool - */ - public function hasAny($keys) - { - $keys = is_array($keys) ? $keys : func_get_args(); - - $input = $this->all(); - - return Arr::hasAny($input, $keys); - } - - /** - * Determine if the validated input is missing one or more keys. - * - * @param mixed $keys - * @return bool - */ - public function missing($keys) - { - return ! $this->has($keys); - } - - /** - * Apply the callback if the validated input is missing the given input item key. - * - * @param string $key - * @param callable $callback - * @param callable|null $default - * @return $this|mixed - */ - public function whenMissing($key, callable $callback, ?callable $default = null) - { - if ($this->missing($key)) { - return $callback(data_get($this->all(), $key)) ?: $this; - } - - if ($default) { - return $default(); - } - - return $this; - } - - /** - * Retrieve input from the validated input as a Stringable instance. - * - * @param string $key - * @param mixed $default - * @return \Illuminate\Support\Stringable - */ - public function str($key, $default = null) - { - return $this->string($key, $default); - } - - /** - * Retrieve input from the validated input as a Stringable instance. - * - * @param string $key - * @param mixed $default - * @return \Illuminate\Support\Stringable - */ - public function string($key, $default = null) - { - return Str::of($this->input($key, $default)); - } - - /** - * Get a subset containing the provided keys with values from the input data. - * - * @param mixed $keys - * @return array - */ - public function only($keys) - { - $results = []; - - $input = $this->all(); - - $placeholder = new stdClass; - - foreach (is_array($keys) ? $keys : func_get_args() as $key) { - $value = data_get($input, $key, $placeholder); - - if ($value !== $placeholder) { - Arr::set($results, $key, $value); - } - } - - return $results; - } - - /** - * Get all of the input except for a specified array of items. - * - * @param mixed $keys - * @return array - */ - public function except($keys) - { - $keys = is_array($keys) ? $keys : func_get_args(); - - $results = $this->all(); - - Arr::forget($results, $keys); - - return $results; - } - /** * Merge the validated input with the given array of additional data. * @@ -183,137 +41,37 @@ public function merge(array $items) return new static(array_merge($this->all(), $items)); } - /** - * Get the input as a collection. - * - * @param array|string|null $key - * @return \Illuminate\Support\Collection - */ - public function collect($key = null) - { - return new Collection(is_array($key) ? $this->only($key) : $this->input($key)); - } - /** * Get the raw, underlying input array. * + * @param array|mixed|null $keys * @return array */ - public function all() + public function all($keys = null) { - return $this->input; - } - - /** - * Apply the callback if the validated inputs contains the given input item key. - * - * @param string $key - * @param callable $callback - * @param callable|null $default - * @return $this|mixed - */ - public function whenHas($key, callable $callback, ?callable $default = null) - { - if ($this->has($key)) { - return $callback(data_get($this->all(), $key)) ?: $this; - } - - if ($default) { - return $default(); - } - - return $this; - } - - /** - * Determine if the validated inputs contains a non-empty value for an input item. - * - * @param string|array $key - * @return bool - */ - public function filled($key) - { - $keys = is_array($key) ? $key : func_get_args(); - - foreach ($keys as $value) { - if ($this->isEmptyString($value)) { - return false; - } - } - - return true; - } - - /** - * Determine if the validated inputs contains an empty value for an input item. - * - * @param string|array $key - * @return bool - */ - public function isNotFilled($key) - { - $keys = is_array($key) ? $key : func_get_args(); - - foreach ($keys as $value) { - if (! $this->isEmptyString($value)) { - return false; - } + if (! $keys) { + return $this->input; } - return true; - } - - /** - * Determine if the validated inputs contains a non-empty value for any of the given inputs. - * - * @param string|array $keys - * @return bool - */ - public function anyFilled($keys) - { - $keys = is_array($keys) ? $keys : func_get_args(); - - foreach ($keys as $key) { - if ($this->filled($key)) { - return true; - } - } + $input = []; - return false; - } - - /** - * Apply the callback if the validated inputs contains a non-empty value for the given input item key. - * - * @param string $key - * @param callable $callback - * @param callable|null $default - * @return $this|mixed - */ - public function whenFilled($key, callable $callback, ?callable $default = null) - { - if ($this->filled($key)) { - return $callback(data_get($this->all(), $key)) ?: $this; - } - - if ($default) { - return $default(); + foreach (is_array($keys) ? $keys : func_get_args() as $key) { + Arr::set($input, $key, Arr::get($this->input, $key)); } - return $this; + return $input; } /** - * Determine if the given input key is an empty string for "filled". + * Retrieve data from the instance. * - * @param string $key - * @return bool + * @param string|null $key + * @param mixed $default + * @return mixed */ - protected function isEmptyString($key) + protected function data($key = null, $default = null) { - $value = $this->input($key); - - return ! is_bool($value) && ! is_array($value) && trim((string) $value) === ''; + return $this->input($key, $default); } /** @@ -340,87 +98,6 @@ public function input($key = null, $default = null) ); } - /** - * Retrieve input as a boolean value. - * - * Returns true when value is "1", "true", "on", and "yes". Otherwise, returns false. - * - * @param string|null $key - * @param bool $default - * @return bool - */ - public function boolean($key = null, $default = false) - { - return filter_var($this->input($key, $default), FILTER_VALIDATE_BOOLEAN); - } - - /** - * Retrieve input as an integer value. - * - * @param string $key - * @param int $default - * @return int - */ - public function integer($key, $default = 0) - { - return intval($this->input($key, $default)); - } - - /** - * Retrieve input as a float value. - * - * @param string $key - * @param float $default - * @return float - */ - public function float($key, $default = 0.0) - { - return floatval($this->input($key, $default)); - } - - /** - * Retrieve input from the validated inputs as a Carbon instance. - * - * @param string $key - * @param string|null $format - * @param string|null $tz - * @return \Illuminate\Support\Carbon|null - * - * @throws \Carbon\Exceptions\InvalidFormatException - */ - public function date($key, $format = null, $tz = null) - { - if ($this->isNotFilled($key)) { - return null; - } - - if (is_null($format)) { - return Date::parse($this->input($key), $tz); - } - - return Date::createFromFormat($format, $this->input($key), $tz); - } - - /** - * Retrieve input from the validated inputs as an enum. - * - * @template TEnum - * - * @param string $key - * @param class-string $enumClass - * @return TEnum|null - */ - public function enum($key, $enumClass) - { - if ($this->isNotFilled($key) || - ! enum_exists($enumClass) || - ! method_exists($enumClass, 'tryFrom')) { - return null; - } - - return $enumClass::tryFrom($this->input($key)); - } - /** * Dump the validated inputs items and end the script. * diff --git a/src/Illuminate/Translation/FileLoader.php b/src/Illuminate/Translation/FileLoader.php index b324e7aaecc0..65c4c4abc323 100755 --- a/src/Illuminate/Translation/FileLoader.php +++ b/src/Illuminate/Translation/FileLoader.php @@ -182,6 +182,17 @@ public function namespaces() return $this->hints; } + /** + * Add a new path to the loader. + * + * @param string $path + * @return void + */ + public function addPath($path) + { + $this->paths[] = $path; + } + /** * Add a new JSON path to the loader. * diff --git a/src/Illuminate/Validation/Rules/Email.php b/src/Illuminate/Validation/Rules/Email.php index 850333dac66f..fbfb6996ac3d 100644 --- a/src/Illuminate/Validation/Rules/Email.php +++ b/src/Illuminate/Validation/Rules/Email.php @@ -2,22 +2,13 @@ namespace Illuminate\Validation\Rules; -use Egulias\EmailValidator\EmailValidator; -use Egulias\EmailValidator\Validation\DNSCheckValidation; -use Egulias\EmailValidator\Validation\Extra\SpoofCheckValidation; -use Egulias\EmailValidator\Validation\MultipleValidationWithAnd; -use Egulias\EmailValidator\Validation\NoRFCWarningsValidation; -use Egulias\EmailValidator\Validation\RFCValidation; -use Illuminate\Container\Container; use Illuminate\Contracts\Validation\DataAwareRule; use Illuminate\Contracts\Validation\Rule; use Illuminate\Contracts\Validation\ValidatorAwareRule; use Illuminate\Support\Arr; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\Macroable; -use Illuminate\Validation\Concerns\FilterEmailValidation; use InvalidArgumentException; class Email implements Rule, DataAwareRule, ValidatorAwareRule @@ -199,29 +190,19 @@ public function passes($attribute, $value) return false; } - $emailValidator = Container::getInstance()->make(EmailValidator::class); + $validator = Validator::make( + $this->data, + [$attribute => $this->buildValidationRules()], + $this->validator->customMessages, + $this->validator->customAttributes + ); - $passes = $emailValidator->isValid((string) $value, new MultipleValidationWithAnd($this->buildValidationRules())); - - if (! $passes) { - $this->messages = [trans('validation.email', ['attribute' => $attribute])]; + if ($validator->fails()) { + $this->messages = array_merge($this->messages, $validator->messages()->all()); return false; } - if ($this->customRules) { - $validator = Validator::make( - $this->data, - [$attribute => $this->customRules], - $this->validator->customMessages, - $this->validator->customAttributes - ); - - if ($validator->fails()) { - return $this->fail($validator->messages()->all()); - } - } - return true; } @@ -235,51 +216,36 @@ protected function buildValidationRules() $rules = []; if ($this->rfcCompliant) { - $rules[] = new RFCValidation; + $rules[] = 'rfc'; } if ($this->strictRfcCompliant) { - $rules[] = new NoRFCWarningsValidation; + $rules[] = 'strict'; } if ($this->validateMxRecord) { - $rules[] = new DNSCheckValidation; + $rules[] = 'dns'; } if ($this->preventSpoofing) { - $rules[] = new SpoofCheckValidation; + $rules[] = 'spoof'; } if ($this->nativeValidation) { - $rules[] = new FilterEmailValidation; + $rules[] = 'filter'; } if ($this->nativeValidationWithUnicodeAllowed) { - $rules[] = FilterEmailValidation::unicode(); + $rules[] = 'filter_unicode'; } if ($rules) { - return $rules; + $rules = ['email:'.implode(',', $rules)]; + } else { + $rules = ['email']; } - return [new RFCValidation]; - } - - /** - * Adds the given failures, and return false. - * - * @param array|string $messages - * @return bool - */ - protected function fail($messages) - { - $messages = Collection::wrap($messages) - ->map(fn ($message) => $this->validator->getTranslator()->get($message)) - ->all(); - - $this->messages = array_merge($this->messages, $messages); - - return false; + return array_merge(array_filter($rules), $this->customRules); } /** diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index e74714ea3fe1..3271749ddb61 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -17,6 +17,7 @@ use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Eloquent\Attributes\CollectedBy; use Illuminate\Database\Eloquent\Attributes\ObservedBy; +use Illuminate\Database\Eloquent\Attributes\UseFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\ArrayObject; use Illuminate\Database\Eloquent\Casts\AsArrayObject; @@ -30,6 +31,8 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\MassAssignmentException; use Illuminate\Database\Eloquent\MissingAttributeException; @@ -3191,6 +3194,18 @@ public function testCollectedByAttribute() $this->assertInstanceOf(CustomEloquentCollection::class, $collection); } + + public function testUseFactoryAttribute() + { + $model = new EloquentModelWithUseFactoryAttribute; + $instance = EloquentModelWithUseFactoryAttribute::factory()->make(['name' => 'test name']); + $factory = EloquentModelWithUseFactoryAttribute::factory(); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttribute::class, $instance); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttributeFactory::class, $model::factory()); + $this->assertInstanceOf(EloquentModelWithUseFactoryAttributeFactory::class, $model::newFactory()); + $this->assertEquals(EloquentModelWithUseFactoryAttribute::class, $factory->modelName()); + $this->assertEquals('test name', $instance->name); // Small smoke test to ensure the factory is working + } } class EloquentTestObserverStub @@ -3990,3 +4005,17 @@ class EloquentModelWithCollectedByAttribute extends Model class CustomEloquentCollection extends Collection { } + +class EloquentModelWithUseFactoryAttributeFactory extends Factory +{ + public function definition() + { + return []; + } +} + +#[UseFactory(EloquentModelWithUseFactoryAttributeFactory::class)] +class EloquentModelWithUseFactoryAttribute extends Model +{ + use HasFactory; +} diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 3d509e0c7745..9adba384baa0 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -4,6 +4,8 @@ use Carbon\Carbon; use GuzzleHttp\Psr7\Stream; +use Illuminate\Container\Container; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; @@ -545,6 +547,126 @@ public function testThrowExceptionsForMimeType() $this->fail('Exception was not thrown.'); } + public function testReportExceptionsForGet() + { + $container = Container::getInstance(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + + $exceptionHandler->shouldReceive('report') + ->once() + ->andReturnUsing(function (UnableToReadFile $e) { + self::assertStringContainsString( + 'Unable to read file from location: foo.txt.', + $e->getMessage(), + ); + }); + + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true]); + + try { + $adapter->get('/foo.txt'); + } catch (UnableToReadFile) { + $this->fail('Exception was thrown.'); + } + } + + public function testReportExceptionsForReadStream() + { + $container = Container::getInstance(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + + $exceptionHandler->shouldReceive('report') + ->once() + ->andReturnUsing(function (UnableToReadFile $e) { + self::assertStringContainsString( + 'Unable to read file from location: foo.txt.', + $e->getMessage(), + ); + }); + + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true], $exceptionHandler); + + try { + $adapter->readStream('/foo.txt'); + } catch (UnableToReadFile) { + $this->fail('Exception was thrown.'); + } + } + + public function testReportExceptionsForPut() + { + $container = Container::getInstance(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + + $exceptionHandler->shouldReceive('report') + ->once() + ->andReturnUsing(function (UnableToWriteFile $e) { + self::assertStringContainsString( + 'Unable to write file at location: foo.txt.', + $e->getMessage(), + ); + }); + + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $this->filesystem->write('foo.txt', 'Hello World'); + + chmod(__DIR__.'/tmp/foo.txt', 0400); + + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true], $exceptionHandler); + + try { + $adapter->put('/foo.txt', 'Hello World!'); + } catch (UnableToWriteFile) { + $this->fail('Exception was thrown.'); + } finally { + chmod(__DIR__.'/tmp/foo.txt', 0600); + } + } + + public function testReportExceptionsForMimeType() + { + $container = Container::getInstance(); + + $exceptionHandler = m::mock(ExceptionHandler::class); + + $exceptionHandler->shouldReceive('report') + ->once() + ->andReturnUsing(function (UnableToRetrieveMetadata $e) { + self::assertStringContainsString( + 'Unable to retrieve the mime_type for file at location: unknown.mime-type.', + $e->getMessage(), + ); + }); + + $container->bind(ExceptionHandler::class, function () use ($exceptionHandler) { + return $exceptionHandler; + }); + + $this->filesystem->write('unknown.mime-type', ''); + + $adapter = new FilesystemAdapter($this->filesystem, $this->adapter, ['report' => true], $exceptionHandler); + + try { + $adapter->mimeType('unknown.mime-type'); + } catch (UnableToRetrieveMetadata) { + $this->fail('Exception was thrown.'); + } + } + public function testGetAllFiles() { $this->filesystem->write('body.txt', 'Hello World'); diff --git a/tests/Foundation/Console/CliDumperTest.php b/tests/Foundation/Console/CliDumperTest.php index c92245f9a703..96775860c34f 100644 --- a/tests/Foundation/Console/CliDumperTest.php +++ b/tests/Foundation/Console/CliDumperTest.php @@ -68,7 +68,10 @@ public function testArray() EOF; - $this->assertSame($expected, $output); + $this->assertSame( + str_replace("\r\n", "\n", $expected), + str_replace("\r\n", "\n", $output) + ); } public function testBoolean() @@ -96,7 +99,10 @@ public function testObject() EOF; - $this->assertSame($expected, $output); + $this->assertSame( + str_replace("\r\n", "\n", $expected), + str_replace("\r\n", "\n", $output) + ); } public function testNull() diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 334fe3eb947a..64745bf49987 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -838,7 +838,10 @@ public function testSequenceBuilder() $this->assertSame(200, $response->status()); $response = $this->factory->get('https://example.com'); - $this->assertSame("This is a story about something that happened long ago when your grandfather was a child.\n", $response->body()); + $this->assertSame( + "This is a story about something that happened long ago when your grandfather was a child.\n", + str_replace("\r\n", "\n", $response->body()) + ); $this->assertSame(200, $response->status()); $response = $this->factory->get('https://example.com'); diff --git a/tests/Integration/Database/Queue/Fixtures/TimeOutJobWithNestedTransactions.php b/tests/Integration/Database/Queue/Fixtures/TimeOutJobWithNestedTransactions.php new file mode 100644 index 000000000000..821b7bcac34d --- /dev/null +++ b/tests/Integration/Database/Queue/Fixtures/TimeOutJobWithNestedTransactions.php @@ -0,0 +1,24 @@ + sleep(20)); + }); + } +} diff --git a/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php b/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php new file mode 100644 index 000000000000..1d383e90b8bb --- /dev/null +++ b/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithNestedTransactions.php @@ -0,0 +1,23 @@ + sleep(20)); + }); + } +} diff --git a/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php b/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php new file mode 100644 index 000000000000..494e9fbdd704 --- /dev/null +++ b/tests/Integration/Database/Queue/Fixtures/TimeOutNonBatchableJobWithTransaction.php @@ -0,0 +1,21 @@ + sleep(20)); + } +} diff --git a/tests/Integration/Database/Queue/QueueTransactionTest.php b/tests/Integration/Database/Queue/QueueTransactionTest.php index 9bfa3ca87a64..eac0c3fc452d 100644 --- a/tests/Integration/Database/Queue/QueueTransactionTest.php +++ b/tests/Integration/Database/Queue/QueueTransactionTest.php @@ -7,6 +7,7 @@ use Illuminate\Tests\Integration\Database\DatabaseTestCase; use Orchestra\Testbench\Attributes\WithConfig; use Orchestra\Testbench\Attributes\WithMigration; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use Symfony\Component\Process\Exception\ProcessSignaledException; use Throwable; @@ -29,9 +30,10 @@ protected function setUp(): void } } - public function testItCanHandleTimeoutJob() + #[DataProvider('timeoutJobs')] + public function testItCanHandleTimeoutJob($job) { - dispatch(new Fixtures\TimeOutJobWithTransaction); + dispatch($job); $this->assertSame(1, DB::table('jobs')->count()); $this->assertSame(0, DB::table('failed_jobs')->count()); @@ -49,4 +51,14 @@ public function testItCanHandleTimeoutJob() $this->assertSame(0, DB::table('jobs')->count()); $this->assertSame(1, DB::table('failed_jobs')->count()); } + + public static function timeoutJobs(): array + { + return [ + [new Fixtures\TimeOutJobWithTransaction()], + [new Fixtures\TimeOutJobWithNestedTransactions()], + [new Fixtures\TimeOutNonBatchableJobWithTransaction()], + [new Fixtures\TimeOutNonBatchableJobWithNestedTransactions()], + ]; + } } diff --git a/tests/Integration/Foundation/Console/OptimizeClearCommandTest.php b/tests/Integration/Foundation/Console/OptimizeClearCommandTest.php index 5df437cefeb4..128ced3f5294 100644 --- a/tests/Integration/Foundation/Console/OptimizeClearCommandTest.php +++ b/tests/Integration/Foundation/Console/OptimizeClearCommandTest.php @@ -15,6 +15,8 @@ protected function getPackageProviders($app): array public function testCanListenToOptimizingEvent(): void { + $this->withoutDeprecationHandling(); + $this->artisan('optimize:clear') ->assertSuccessful() ->expectsOutputToContain('ServiceProviderWithOptimizeClear'); diff --git a/tests/Integration/Foundation/Console/OptimizeCommandTest.php b/tests/Integration/Foundation/Console/OptimizeCommandTest.php index 3a1bcd5dd8e2..2ea8d7a3f8bc 100644 --- a/tests/Integration/Foundation/Console/OptimizeCommandTest.php +++ b/tests/Integration/Foundation/Console/OptimizeCommandTest.php @@ -23,6 +23,8 @@ protected function getPackageProviders($app): array public function testCanListenToOptimizingEvent(): void { + $this->withoutDeprecationHandling(); + $this->artisan('optimize') ->assertSuccessful() ->expectsOutputToContain('my package'); diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php index 36eb9aeb5cb7..82e567282a6f 100644 --- a/tests/Integration/Queue/UniqueJobTest.php +++ b/tests/Integration/Queue/UniqueJobTest.php @@ -8,10 +8,14 @@ use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Foundation\Auth\User; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Bus; use Orchestra\Testbench\Attributes\WithMigration; +use Orchestra\Testbench\Factories\UserFactory; #[WithMigration] #[WithMigration('cache')] @@ -130,6 +134,28 @@ public function testLockCanBeReleasedBeforeProcessing() $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); } + public function testLockIsReleasedOnModelNotFoundException() + { + UniqueTestSerializesModelsJob::$handled = false; + + /** @var \Illuminate\Foundation\Auth\User */ + $user = UserFactory::new()->create(); + $job = new UniqueTestSerializesModelsJob($user); + + $this->expectException(ModelNotFoundException::class); + + try { + $user->delete(); + dispatch($job); + $this->runQueueWorkerCommand(['--once' => true]); + unserialize(serialize($job)); + } finally { + $this->assertFalse($job::$handled); + $this->assertModelMissing($user); + $this->assertTrue($this->app->get(Cache::class)->lock($this->getLockKey($job), 10)->get()); + } + } + protected function getLockKey($job) { return 'laravel_unique_job:'.(is_string($job) ? $job : get_class($job)).':'; @@ -185,3 +211,14 @@ class UniqueUntilStartTestJob extends UniqueTestJob implements ShouldBeUniqueUnt { public $tries = 2; } + +class UniqueTestSerializesModelsJob extends UniqueTestJob +{ + use SerializesModels; + + public $deleteWhenMissingModels = true; + + public function __construct(public User $user) + { + } +} diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php index 7b85c1ae30c9..fe821604ba1b 100644 --- a/tests/Support/ValidatedInputTest.php +++ b/tests/Support/ValidatedInputTest.php @@ -17,6 +17,7 @@ public function test_can_access_input() $this->assertSame('Taylor', $input->name); $this->assertSame('Taylor', $input['name']); + $this->assertEquals(['name' => 'Taylor'], $input->all(['name'])); $this->assertEquals(['name' => 'Taylor'], $input->only(['name'])); $this->assertEquals(['name' => 'Taylor'], $input->except(['votes'])); $this->assertEquals(['name' => 'Taylor', 'votes' => 100], $input->all()); @@ -478,6 +479,20 @@ public function test_enum_method() $this->assertNull($input->enum('invalid_enum_value', StringBackedEnum::class)); } + public function test_enums_method() + { + $input = new ValidatedInput([ + 'valid_enum_value' => 'Hello world', + 'invalid_enum_value' => 'invalid', + ]); + + $this->assertEmpty($input->enums('doesnt_exists', StringBackedEnum::class)); + + $this->assertEquals([StringBackedEnum::HELLO_WORLD], $input->enums('valid_enum_value', StringBackedEnum::class)); + + $this->assertEmpty($input->enums('invalid_enum_value', StringBackedEnum::class)); + } + public function test_collect_method() { $input = new ValidatedInput(['users' => [1, 2, 3]]); diff --git a/tests/Validation/ValidationEmailRuleTest.php b/tests/Validation/ValidationEmailRuleTest.php index fb39934fdcdf..b6382f508bc4 100644 --- a/tests/Validation/ValidationEmailRuleTest.php +++ b/tests/Validation/ValidationEmailRuleTest.php @@ -11,31 +11,36 @@ use Illuminate\Validation\Rules\Email; use Illuminate\Validation\ValidationServiceProvider; use Illuminate\Validation\Validator; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; class ValidationEmailRuleTest extends TestCase { + private const ATTRIBUTE = 'my_email'; + private const ATTRIBUTE_REPLACED = 'my email'; + public function testBasic() { $this->fails( Email::default(), 'foo', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); + $this->fails( Rule::email(), 'foo', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( Email::default(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); $this->passes( Rule::email(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); $this->passes(Email::default(), null); @@ -43,321 +48,700 @@ public function testBasic() $this->passes(Rule::email(), null); } - protected function fails($rule, $values, $messages) + /** + * @param mixed $rule + * @param string|array $values + * @param array $expectedMessages + * @param string|null $customValidationMessage + * @return void + */ + protected function fails($rule, $values, $expectedMessages, $customValidationMessage = null) { - $this->assertValidationRules($rule, $values, false, $messages); + $this->assertValidationRules($rule, $values, false, $expectedMessages, $customValidationMessage); } - protected function assertValidationRules($rule, $values, $result, $messages) + /** + * @param mixed $rule + * @param string|array $values + * @param bool $expectToPass + * @param array $expectedMessages + * @param string|null $customValidationMessage + * @return void + */ + protected function assertValidationRules($rule, $values, $expectToPass, $expectedMessages = [], $customValidationMessage = null) { $values = Arr::wrap($values); + $translator = resolve('translator'); + foreach ($values as $value) { $v = new Validator( - resolve('translator'), - ['my_email' => $value], - ['my_email' => is_object($rule) ? clone $rule : $rule] + $translator, + [self::ATTRIBUTE => $value], + [self::ATTRIBUTE => is_object($rule) ? clone $rule : $rule], + $customValidationMessage ? [self::ATTRIBUTE.'.email' => $customValidationMessage] : [] ); - $this->assertSame($result, $v->passes()); + $this->assertSame($expectToPass, $v->passes(), 'Expected email input '.$value.' to '.($expectToPass ? 'pass' : 'fail').'.'); $this->assertSame( - $result ? [] : ['my_email' => $messages], - $v->messages()->toArray() + $expectToPass ? [] : [self::ATTRIBUTE => $expectedMessages], + $v->messages()->toArray(), + 'Expected different message for email input '.$value, ); } } + /** + * @param mixed $rule + * @param string|array $values + * @return void + */ protected function passes($rule, $values) { - $this->assertValidationRules($rule, $values, true, []); + $this->assertValidationRules($rule, $values, true); } - public function testStrict() + public function testRfcCompliantStrict() { + $emailThatFailsBothNonStrictButFailsInStrict = 'username@sub..example.com'; + $emailThatPassesNonStrictButFailsInStrict = '"has space"@example.com'; + $emailThatPassesBothNonStrictAndInStrict = 'plainaddress@example.com'; + $this->fails( - (new Email())->rfcCompliant(true), - 'invalid.@example.com', - ['validation.email'], + (new Email())->rfcCompliant(strict: true), + $emailThatPassesNonStrictButFailsInStrict, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( - Rule::email()->rfcCompliant(true), - 'invalid.@example.com', - ['validation.email'], + Rule::email()->rfcCompliant(strict: true), + $emailThatPassesNonStrictButFailsInStrict, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( - (new Email())->rfcCompliant(true), - 'username@sub..example.com', - ['validation.email'], + (new Email())->rfcCompliant(strict: true), + $emailThatFailsBothNonStrictButFailsInStrict, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( - Rule::email()->rfcCompliant(true), - 'username@sub..example.com', - ['validation.email'], + Rule::email()->rfcCompliant(strict: true), + $emailThatFailsBothNonStrictButFailsInStrict, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( - (new Email())->rfcCompliant(true), - 'plainaddress@example.com', + (new Email())->rfcCompliant(strict: true), + $emailThatPassesBothNonStrictAndInStrict ); $this->passes( - Rule::email()->rfcCompliant(true), - 'plainaddress@example.com', + Rule::email()->rfcCompliant(strict: true), + $emailThatPassesBothNonStrictAndInStrict ); } - public function testDns() + public function testValidateMxRecord() { $this->fails( (new Email())->validateMxRecord(), 'plainaddress@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->validateMxRecord(), 'plainaddress@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( (new Email())->validateMxRecord(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); $this->passes( Rule::email()->validateMxRecord(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); } - public function testSpoof() + public function testPreventSpoofing() { $this->fails( (new Email())->preventSpoofing(), 'admin@examрle.com',// Contains a Cyrillic 'р' (U+0440), not a Latin 'p' - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->preventSpoofing(), 'admin@examрle.com',// Contains a Cyrillic 'р' (U+0440), not a Latin 'p' - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $spoofingEmail = 'admin@exam'."\u{0440}".'le.com'; $this->fails( (new Email())->preventSpoofing(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->preventSpoofing(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( (new Email())->preventSpoofing(), - 'admin@example.com', + 'admin@example.com' ); $this->passes( Rule::email()->preventSpoofing(), - 'admin@example.com', + 'admin@example.com' ); $this->passes( (new Email())->preventSpoofing(), - 'test👨‍💻@domain.com', + 'test👨‍💻@domain.com' ); $this->passes( Rule::email()->preventSpoofing(), - 'test👨‍💻@domain.com', + 'test👨‍💻@domain.com' ); } - public function testFilter() + public function testWithNativeValidation() { $this->fails( (new Email())->withNativeValidation(), 'tést@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->withNativeValidation(), 'tést@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( (new Email())->withNativeValidation(), - 'admin@example.com', + 'admin@example.com' ); $this->passes( Rule::email()->withNativeValidation(), - 'admin@example.com', + 'admin@example.com' ); } - public function testFilterUnicode() + public function testWithNativeValidationAllowUnicode() { $this->fails( - (new Email())->withNativeValidation(true), + (new Email())->withNativeValidation(allowUnicode: true), 'invalid.@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( - Rule::email()->withNativeValidation(true), + Rule::email()->withNativeValidation(allowUnicode: true), 'invalid.@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( - (new Email())->withNativeValidation(true), - 'tést@domain.com', + (new Email())->withNativeValidation(allowUnicode: true), + 'tést@domain.com' ); $this->passes( - Rule::email()->withNativeValidation(true), - 'tést@domain.com', + Rule::email()->withNativeValidation(allowUnicode: true), + 'tést@domain.com' ); $this->passes( - (new Email())->withNativeValidation(true), - 'admin@example.com', + (new Email())->withNativeValidation(allowUnicode: true), + 'admin@example.com' ); $this->passes( - Rule::email()->withNativeValidation(true), - 'admin@example.com', + Rule::email()->withNativeValidation(allowUnicode: true), + 'admin@example.com' ); } - public function testRfc() + public function testRfcCompliantNonStrict() { $this->fails( (new Email())->rfcCompliant(), 'invalid.@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->rfcCompliant(), 'invalid.@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( (new Email())->rfcCompliant(), 'test👨‍💻@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->rfcCompliant(), 'test👨‍💻@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( (new Email())->rfcCompliant(), - 'admin@example.com', + 'admin@example.com' ); $this->passes( Rule::email()->rfcCompliant(), - 'admin@example.com', + 'admin@example.com' ); $this->passes( (new Email())->rfcCompliant(), - 'tést@domain.com', + 'tést@domain.com' ); $this->passes( Rule::email()->rfcCompliant(), - 'tést@domain.com', + 'tést@domain.com' + ); + } + + #[TestWith(['"has space"@example.com'])] // Quoted local part with space + #[TestWith(['some(comment)@example.com'])] // Comment in local part + #[TestWith(['abc."test"@example.com'])] // Mixed quoted/unquoted local part + #[TestWith(['"escaped\\\"quote"@example.com'])] // Escaped quote inside quoted local part + #[TestWith(['test@example'])] // Domain without TLD + #[TestWith(['test@localhost'])] // Domain without TLD + #[TestWith(['name@[127.0.0.1]'])] // Local-part with domain-literal IPv4 address + #[TestWith(['user@[IPv6:::1]'])] // Domain-literal with unusual IPv6 short form + #[TestWith(['a@[IPv6:2001:db8::1]'])] // Domain-literal with normal IPv6 + #[TestWith(['user@[IPv6:::]'])] // invalid shorthand IPv6 + #[TestWith(['"ab\\(c"@example.com'])] + public function testEmailsThatPassOnRfcCompliantButFailOnStrict($email) + { + $this->passes( + Rule::email()->rfcCompliant(), + $email + ); + + $this->fails( + Rule::email()->rfcCompliant(strict: true), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + + #[TestWith(['plainaddress@example.com'])] + #[TestWith(['joe.smith@example.io'])] + #[TestWith(['custom-tag+dev@example.org'])] + #[TestWith(['hyphens--@example.org'])] + #[TestWith(['underscore_name@example.co.uk'])] + #[TestWith(['underscores__@example.org'])] + #[TestWith(['user@subdomain.example.com'])] + #[TestWith(['numbers123@domain.com'])] + #[TestWith(['john-doe@some-domain.com'])] + #[TestWith(['UPPERlower@example.org'])] + #[TestWith(['dots.ok@sub.domain.io'])] + #[TestWith(['some_email+tag@domain.dev'])] + #[TestWith(['a@b.c'])] + #[TestWith(['user@xn--bcher-kva.example'])] + #[TestWith(['user@bücher.example'])] + public function testEmailsThatPassOnBothRfcCompliantAndStrict($email) + { + $this->passes( + Rule::email()->rfcCompliant(), + $email + ); + + $this->passes( + Rule::email()->rfcCompliant(strict: true), + $email + ); + } + + #[TestWith(['invalid.@example.com'])] + #[TestWith(['invalid@.example.com'])] + #[TestWith(['.invalid@example.com'])] + #[TestWith(['invalid@example.com.'])] + #[TestWith(['some..dots@example.com'])] + #[TestWith(['username@sub..example.com'])] + #[TestWith(['test@example..com'])] + #[TestWith(['test@@example.com'])] + #[TestWith(['test👨‍💻@domain.com'])] + #[TestWith(['username@domain-with-hyphen-.com'])] + #[TestWith(['()<>[]:,;@example.com'])] + #[TestWith(['@example.com'])] + #[TestWith(['[test]@example.com'])] + #[TestWith(['user@example.com:3000'])] + #[TestWith(['"unescaped"quote@example.com'])] + #[TestWith(['https://example.com'])] + #[TestWith(['with\\escape@example.com'])] + public function testEmailsThatFailOnBothRfcCompliantAndStrict($email) + { + $this->fails( + Rule::email()->rfcCompliant(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->fails( + Rule::email()->rfcCompliant(strict: true), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + + #[TestWith(['plainaddress@example.com'])] // Simple valid address + #[TestWith(['joe.smith@example.io'])] // Dotted local part with TLD + #[TestWith(['custom-tag+dev@example.org'])] // Plus tag in local part + #[TestWith(['hyphens--@example.org'])] // Hyphens in local part + #[TestWith(['underscore_name@example.co.uk'])] // Underscore in local part + #[TestWith(['underscores__@example.org'])] // Double underscores in local part + #[TestWith(['user@subdomain.example.com'])] // Subdomain in domain part + #[TestWith(['numbers123@domain.com'])] // Numbers in local part + #[TestWith(['john-doe@some-domain.com'])] // Hyphenated domain + #[TestWith(['UPPERlower@example.org'])] // Mixed case local part + #[TestWith(['dots.ok@sub.domain.io'])] // Dots in local and subdomain + #[TestWith(['some_email+tag@domain.dev'])] // Email with plus tag and underscore + #[TestWith(['a@b.c'])] // Minimal email + #[TestWith(['user@xn--bcher-kva.example'])] // Punycode domain (bücher) + #[TestWith(['user@bücher.example'])] // Unicode domain + public function testEmailsThatPassOnBothRfcCompliantAndRfcCompliantStrict($email) + { + $this->passes( + Rule::email()->rfcCompliant(), + $email + ); + + $this->passes( + Rule::email()->rfcCompliant(strict: true), + $email + ); + } + + #[TestWith(['déjà@example.com'])] + #[TestWith(['测试@example.com'])] + public function testEmailsThatFailWithNativeValidationAsciiPassUnicode($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->passes( + Rule::email()->withNativeValidation(allowUnicode: true), + $email + ); + } + + #[TestWith(['test@üñîçødé.com'])] // Unicode domain + #[TestWith(['user@domain..com'])] // Double dots in domain + #[TestWith(['test@.example.com'])] // Domain starts with a dot + #[TestWith(['username@domain-with-hyphen-.com'])] + #[TestWith(['пример@пример.рф'])] // Cyrillic domain + #[TestWith(['例子@例子.公司'])] // Chinese domain + #[TestWith(['name@123.123.123.123'])] // Numeric domain + public function testEmailsThatFailOnBothWithNativeValidationAsciiAndUnicode($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->fails( + Rule::email()->withNativeValidation(allowUnicode: true), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + + #[TestWith(['user@example.com'])] + #[TestWith(['user.name+tag@example.co.uk'])] + #[TestWith(['joe_smith@example.org'])] + #[TestWith(['user@[IPv6:2001:db8:1ff::a0b:dbd0]'])] + #[TestWith(['test@xn--bcher-kva.com'])] // Punycode for bücher.com + public function testEmailsThatPassBothWithNativeValidationAsciiAndUnicode($email) + { + $this->passes( + Rule::email()->withNativeValidation(), + $email + ); + + $this->passes( + Rule::email()->withNativeValidation(allowUnicode: true), + $email + ); + } + + #[TestWith(['some(comment)@example.com'])] // Comment in local part + #[TestWith(['tést@example.com'])] // Accented local part + #[TestWith(['user@üñîçødé.com'])] // Unicode domain + #[TestWith(['user@bücher.example'])] // Unicode domain + #[TestWith(['"has space"@example.com'])] // Quoted local part with space + #[TestWith(['"escaped\\\"quote"@example.com'])] // Escaped quote inside quoted local part + #[TestWith(['test@localhost'])] // Domain without TLD + #[TestWith(['test@example'])] // Domain without TLD + #[TestWith(['пример@пример.рф'])] // Cyrillic local and domain + #[TestWith(['例子@例子.公司'])] // Chinese local and domain + #[TestWith(['name@123.123.123.123'])] // Numeric domain + public function testEmailsThatFailWithNativeValidationAsciiPassRfcCompliant($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->passes( + Rule::email()->rfcCompliant(), + $email + ); + } + + #[TestWith(['plainaddress@example.com'])] // Simple valid address + #[TestWith(['joe.smith@example.io'])] // Dot in local part + #[TestWith(['custom-tag+dev@example.org'])] // Plus tag in local part + #[TestWith(['hyphens--@example.org'])] // Double hyphen in local part + #[TestWith(['underscore_name@example.co.uk'])] // Underscore in local part + #[TestWith(['underscores__@example.org'])] // Double underscores in local part + #[TestWith(['user@subdomain.example.com'])] // Subdomain in domain + #[TestWith(['numbers123@domain.com'])] // Numbers in local part + #[TestWith(['john-doe@some-domain.com'])] // Hyphen in domain + #[TestWith(['UPPERlower@example.org'])] // Mixed-case local part + #[TestWith(['dots.ok@sub.domain.io'])] // Subdomain with dot in local part + #[TestWith(['some_email+tag@domain.dev'])] // Underscore and tag in local part + #[TestWith(['a@b.c'])] // Minimal valid address + #[TestWith(['user@xn--bcher-kva.example'])] // Punycode domain (bücher.example) + #[TestWith(['user_name+tag@example.io'])] // Underscore with tag + #[TestWith(['UPPERCASE@EXAMPLE.IO'])] // All uppercase local and domain + #[TestWith(['abc."test"@example.com'])] // Mixed quoted/unquoted local part + #[TestWith(['name@[127.0.0.1]'])] // IPv4 domain literal + #[TestWith(['user@[IPv6:::1]'])] // IPv6 domain with unusual short form + #[TestWith(['a@[IPv6:2001:db8::1]'])] // IPv6 domain normal form + #[TestWith(['user@[IPv6:2001:db8:1ff::a0b:dbd0]'])] // Fully expanded IPv6 + public function testEmailsThatPassWithNativeValidationAndRfcCompliant($email) + { + $this->passes( + Rule::email()->withNativeValidation(), + $email + ); + + $this->passes( + Rule::email()->rfcCompliant(), + $email + ); + } + + #[TestWith(['test@@example.com'])] // Multiple @ symbols + #[TestWith(['user@domain..com'])] // Double dots in domain + #[TestWith(['.leadingdot@example.com'])] // Leading dot in local part + #[TestWith(['with\\escape@example.com'])] // Backslash in local part + #[TestWith(['@example.com'])] // Missing local part + #[TestWith(['some)@example.com'])] // Unmatched parenthesis in local part + #[TestWith([' space@domain.com'])] // Leading space in local part + #[TestWith(['user@domain:port.com'])] // Colon in domain (mimics a port) + #[TestWith(['username@domain-with-hyphen-.com'])] // Trailing hyphen in domain + public function testEmailsThatFailWithNativeValidationAndRfcCompliant($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->fails( + Rule::email()->rfcCompliant(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + + public function testNativeValidationVsRfcCompliant() + { + $emailsThatPassNativeFailRfc = [ + // none I could find + ]; + + foreach ($emailsThatPassNativeFailRfc as $email) { + $this->passes( + Rule::email()->withNativeValidation(), + $email + ); + + $this->fails( + Rule::email()->rfcCompliant(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + } + + #[TestWith(['abc."test"@example.com'])] // Mixed quotes in local part + #[TestWith(['name@[127.0.0.1]'])] // Local-part with domain-literal IPv4 address + #[TestWith(['user@[IPv6:2001:db8::1]'])] // Domain-literal with normal IPv6 + #[TestWith(['user@[IPv6:2001:db8:1ff::a0b:dbd0]'])] // Domain-literal with full IPv6 address + #[TestWith(['"ab\\(c"@example.com'])] // Quoted local part with escaped character + public function testEmailsThatPassNativeValidationFailRfcCompliantStrict($email) + { + $this->passes( + Rule::email()->withNativeValidation(), + $email + ); + + $this->fails( + Rule::email()->rfcCompliant(true), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + } + + #[TestWith(['пример@пример.рф'])] // Unicode domain in Cyrillic script + #[TestWith(['例子@例子.公司'])] // Unicode domain in Chinese script + #[TestWith(['name@123.123.123.123'])] // IP address in domain part + public function testEmailsThatFailNativeValidationPassRfcCompliantStrict($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->passes( + Rule::email()->rfcCompliant(true), + $email + ); + } + + #[TestWith(['user@example.com'])] // Simple, valid email + #[TestWith(['joe.smith+dev@example.co.uk'])] // Plus-tagged email with subdomain TLD + #[TestWith(['user!#$%&\'*+/=?^_`{|}~@example.com'])] // Unusual valid characters in local part + public function testEmailsThatPassBothNativeValidationAndRfcCompliantStrict($email) + { + $this->passes( + Rule::email()->withNativeValidation(), + $email + ); + + $this->passes( + Rule::email()->rfcCompliant(true), + $email + ); + } + + #[TestWith(['test@@example.com'])] // Multiple @ + #[TestWith(['.leadingdot@example.com'])] // Leading dot in local part + #[TestWith(['user@domain..com'])] // Double dots in domain + #[TestWith(['test@'])] // Missing domain + #[TestWith(['abc"quote@example.com'])] // Unescaped quote in local part + #[TestWith(['some(comment)@example.com'])] // Local part comment + #[TestWith(['"has space"@example.com'])] // Quoted local part with space + #[TestWith(['user@domain(comment)'])] // Comment in domain + #[TestWith(['user@[127.0.0.1(comment)]'])] // Comment in domain-literal IPv4 address + #[TestWith(['some((double))comment@example.com'])] // Nested comment in local part + #[TestWith(['"test\\\"quote"@example.com'])] // Escaped quote in quoted local part + #[TestWith(['" leading.space"@example.com'])] // Leading space in quoted local part + public function testEmailsThatFailBothNativeValidationAndRfcCompliantStrict($email) + { + $this->fails( + Rule::email()->withNativeValidation(), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->fails( + Rule::email()->rfcCompliant(true), + $email, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); } public function testCombiningRules() { $this->passes( - (new Email())->rfcCompliant(true)->preventSpoofing(), - 'test@example.com', + (new Email())->rfcCompliant(strict: true)->preventSpoofing(), + 'test@example.com' ); $this->passes( - Rule::email()->rfcCompliant(true)->preventSpoofing(), - 'test@example.com', + Rule::email()->rfcCompliant(strict: true)->preventSpoofing(), + 'test@example.com' ); $this->fails( - (new Email())->rfcCompliant(true)->preventSpoofing()->validateMxRecord(), + (new Email())->rfcCompliant(strict: true)->preventSpoofing()->validateMxRecord(), 'test@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( - Rule::email()->rfcCompliant(true)->preventSpoofing()->validateMxRecord(), + Rule::email()->rfcCompliant(strict: true)->preventSpoofing()->validateMxRecord(), 'test@example.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->passes( (new Email())->preventSpoofing(), - 'test👨‍💻@domain.com', + 'test👨‍💻@domain.com' ); $this->passes( Rule::email()->preventSpoofing(), - 'test👨‍💻@domain.com', + 'test👨‍💻@domain.com' ); $this->fails( (new Email())->preventSpoofing()->rfcCompliant(), 'test👨‍💻@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->preventSpoofing()->rfcCompliant(), 'test👨‍💻@domain.com', - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $spoofingEmail = 'admin@exam'."\u{0440}".'le.com'; $this->passes( (new Email())->rfcCompliant(), - $spoofingEmail, + $spoofingEmail ); $this->passes( Rule::email()->rfcCompliant(), - $spoofingEmail, + $spoofingEmail ); $this->fails( (new Email())->rfcCompliant()->preventSpoofing(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); $this->fails( Rule::email()->rfcCompliant()->preventSpoofing(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); } @@ -381,12 +765,12 @@ public function testMacro() $this->passes( Email::laravelEmployee(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); $this->passes( Rule::email()->laravelEmployee(), - 'taylor@laravel.com', + 'taylor@laravel.com' ); } @@ -398,7 +782,7 @@ public function testItCanSetDefaultUsing() $this->passes( Email::default(), - $spoofingEmail, + $spoofingEmail ); Email::defaults(function () { @@ -408,7 +792,7 @@ public function testItCanSetDefaultUsing() $this->fails( Email::default(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); Email::defaults(function () { @@ -416,18 +800,54 @@ public function testItCanSetDefaultUsing() }); $this->passes( + Email::default(), + $spoofingEmail + ); + + Email::defaults(function () { + return Rule::email()->preventSpoofing(); + }); + + $this->fails( Email::default(), $spoofingEmail, + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] ); + } + public function testValidationMessages() + { Email::defaults(function () { return Rule::email()->preventSpoofing(); }); + $spoofingEmail = 'admin@exam'."\u{0440}".'le.com'; + $this->fails( Email::default(), $spoofingEmail, - ['validation.email'], + ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'] + ); + + $this->fails( + rule: Email::default(), + values: $spoofingEmail, + expectedMessages: ['The '.self::ATTRIBUTE_REPLACED.' must be a valid email address.'], + customValidationMessage: 'The :attribute must be a valid email address.' + ); + + $this->fails( + rule: Email::default(), + values: $spoofingEmail, + expectedMessages: ['Please check the entered '.self::ATTRIBUTE_REPLACED.", it must be a valid email address, {$spoofingEmail} given."], + customValidationMessage: 'Please check the entered :attribute, it must be a valid email address, :input given.' + ); + + $this->fails( + rule: Email::default(), + values: $spoofingEmail, + expectedMessages: ['Plain text value'], + customValidationMessage: 'Plain text value' ); } @@ -436,9 +856,15 @@ protected function setUp(): void $container = Container::getInstance(); $container->bind('translator', function () { - return new Translator( + $translator = new Translator( new ArrayLoader, 'en' ); + + $translator->addLines([ + 'validation.email' => 'The :attribute must be a valid email address.', + ], 'en'); + + return $translator; }); Facade::setFacadeApplication($container); diff --git a/tests/View/Blade/BladeComponentTagCompilerTest.php b/tests/View/Blade/BladeComponentTagCompilerTest.php index b840f874d27d..bc181d1e57a0 100644 --- a/tests/View/Blade/BladeComponentTagCompilerTest.php +++ b/tests/View/Blade/BladeComponentTagCompilerTest.php @@ -21,7 +21,10 @@ public function testSlotsCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, []) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, []) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testInlineSlotsCanBeCompiled() @@ -30,7 +33,10 @@ public function testInlineSlotsCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, []) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, []) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testDynamicSlotsCanBeCompiled() @@ -39,7 +45,10 @@ public function testDynamicSlotsCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot(\$foo, null, []) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot(\$foo, null, []) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testDynamicSlotsCanBeCompiledWithKeyOfObjects() @@ -48,7 +57,10 @@ public function testDynamicSlotsCanBeCompiledWithKeyOfObjects() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot(\$foo->name, null, []) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot(\$foo->name, null, []) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testSlotsWithAttributesCanBeCompiled() @@ -57,7 +69,10 @@ public function testSlotsWithAttributesCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, ['class' => 'font-bold']) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, ['class' => 'font-bold']) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testInlineSlotsWithAttributesCanBeCompiled() @@ -66,7 +81,10 @@ public function testInlineSlotsWithAttributesCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, ['class' => 'font-bold']) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, ['class' => 'font-bold']) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testSlotsWithDynamicAttributesCanBeCompiled() @@ -75,7 +93,10 @@ public function testSlotsWithDynamicAttributesCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\$classes)]) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\$classes)]) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testSlotsWithClassDirectiveCanBeCompiled() @@ -84,7 +105,10 @@ public function testSlotsWithClassDirectiveCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssClasses(\$classes))]) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, ['class' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssClasses(\$classes))]) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testSlotsWithStyleDirectiveCanBeCompiled() @@ -93,7 +117,10 @@ public function testSlotsWithStyleDirectiveCanBeCompiled() $result = $this->compiler()->compileSlots(' '); - $this->assertSame("@slot('foo', null, ['style' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssStyles(\$styles))]) \n".' @endslot', trim($result)); + $this->assertSame( + "@slot('foo', null, ['style' => \Illuminate\View\Compilers\BladeCompiler::sanitizeComponentAttribute(\Illuminate\Support\Arr::toCssStyles(\$styles))]) \n".' @endslot', + str_replace("\r\n", "\n", trim($result)) + ); } public function testBasicComponentParsing() diff --git a/tests/View/Blade/BladeComponentsTest.php b/tests/View/Blade/BladeComponentsTest.php index 64231b4fd098..92d89e6ef5b9 100644 --- a/tests/View/Blade/BladeComponentsTest.php +++ b/tests/View/Blade/BladeComponentsTest.php @@ -16,12 +16,12 @@ public function testComponentsAreCompiled() public function testClassComponentsAreCompiled() { - $this->assertSame(' + $this->assertSame(str_replace("\r\n", "\n", ' "bar"] + (isset($attributes) && $attributes instanceof Illuminate\View\ComponentAttributeBag ? $attributes->all() : [])); ?> withName(\'test\'); ?> shouldRender()): ?> -startComponent($component->resolveView(), $component->data()); ?>', $this->compiler->compileString('@component(\'Illuminate\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])')); +startComponent($component->resolveView(), $component->data()); ?>'), $this->compiler->compileString('@component(\'Illuminate\Tests\View\Blade\ComponentStub::class\', \'test\', ["foo" => "bar"])')); } public function testEndComponentsAreCompiled() @@ -35,7 +35,7 @@ public function testEndComponentClassesAreCompiled() { $this->compiler->newComponentHash('foo'); - $this->assertSame('renderComponent(); ?> + $this->assertSame(str_replace("\r\n", "\n", 'renderComponent(); ?> @@ -44,7 +44,7 @@ public function testEndComponentClassesAreCompiled() -', $this->compiler->compileString('@endcomponentClass')); +'), $this->compiler->compileString('@endcomponentClass')); } public function testSlotsAreCompiled()