From 9451a9e35f7c5d5439553b4be822e231f1ddfe1d Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Thu, 5 Oct 2023 14:03:55 +0100 Subject: [PATCH 01/50] [10.x] Store blocks after prepare strings (#48641) * Store blocks after prepare strings * Apply fixes from StyleCI --------- Co-authored-by: StyleCI Bot --- src/Illuminate/View/Compilers/BladeCompiler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/View/Compilers/BladeCompiler.php b/src/Illuminate/View/Compilers/BladeCompiler.php index 62005da34610..18d1978af176 100644 --- a/src/Illuminate/View/Compilers/BladeCompiler.php +++ b/src/Illuminate/View/Compilers/BladeCompiler.php @@ -256,12 +256,12 @@ public function compileString($value) { [$this->footer, $result] = [[], '']; - $value = $this->storeUncompiledBlocks($value); - foreach ($this->prepareStringsForCompilationUsing as $callback) { $value = $callback($value); } + $value = $this->storeUncompiledBlocks($value); + // First we will compile the Blade component tags. This is a precompile style // step which compiles the component Blade tags into @component directives // that may be used by Blade. Then we should call any other precompilers. From 4a9ae49f3969c4648b5ee000e6692a449e34c2a8 Mon Sep 17 00:00:00 2001 From: bchalier Date: Thu, 5 Oct 2023 17:45:35 +0200 Subject: [PATCH 02/50] throw TransportException instead of Exception in SES and SES v2 mail driver (#48645) --- .../Mail/Transport/SesTransport.php | 4 ++-- .../Mail/Transport/SesV2Transport.php | 4 ++-- tests/Mail/MailSesTransportTest.php | 20 +++++++++++++++++++ tests/Mail/MailSesV2TransportTest.php | 20 +++++++++++++++++++ 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Mail/Transport/SesTransport.php b/src/Illuminate/Mail/Transport/SesTransport.php index 9db7734c62ad..443ad934e031 100644 --- a/src/Illuminate/Mail/Transport/SesTransport.php +++ b/src/Illuminate/Mail/Transport/SesTransport.php @@ -4,7 +4,7 @@ use Aws\Exception\AwsException; use Aws\Ses\SesClient; -use Exception; +use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; @@ -75,7 +75,7 @@ protected function doSend(SentMessage $message): void } catch (AwsException $e) { $reason = $e->getAwsErrorMessage() ?? $e->getMessage(); - throw new Exception( + throw new TransportException( sprintf('Request to AWS SES API failed. Reason: %s.', $reason), is_int($e->getCode()) ? $e->getCode() : 0, $e diff --git a/src/Illuminate/Mail/Transport/SesV2Transport.php b/src/Illuminate/Mail/Transport/SesV2Transport.php index 5cc3936d85b6..876630b9e1be 100644 --- a/src/Illuminate/Mail/Transport/SesV2Transport.php +++ b/src/Illuminate/Mail/Transport/SesV2Transport.php @@ -4,7 +4,7 @@ use Aws\Exception\AwsException; use Aws\SesV2\SesV2Client; -use Exception; +use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mailer\SentMessage; use Symfony\Component\Mailer\Transport\AbstractTransport; @@ -79,7 +79,7 @@ protected function doSend(SentMessage $message): void } catch (AwsException $e) { $reason = $e->getAwsErrorMessage() ?? $e->getMessage(); - throw new Exception( + throw new TransportException( sprintf('Request to AWS SES V2 API failed. Reason: %s.', $reason), is_int($e->getCode()) ? $e->getCode() : 0, $e diff --git a/tests/Mail/MailSesTransportTest.php b/tests/Mail/MailSesTransportTest.php index f21b7256474f..8d3fd4c6b83d 100755 --- a/tests/Mail/MailSesTransportTest.php +++ b/tests/Mail/MailSesTransportTest.php @@ -2,6 +2,8 @@ namespace Illuminate\Tests\Mail; +use Aws\Command; +use Aws\Exception\AwsException; use Aws\Ses\SesClient; use Illuminate\Config\Repository; use Illuminate\Container\Container; @@ -10,6 +12,7 @@ use Illuminate\View\Factory; use Mockery as m; use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -78,6 +81,23 @@ public function testSend() (new SesTransport($client))->send($message); } + public function testSendError() + { + $message = new Email(); + $message->subject('Foo subject'); + $message->text('Bar body'); + $message->sender('myself@example.com'); + $message->to('me@example.com'); + + $client = m::mock(SesClient::class); + $client->shouldReceive('sendRawEmail')->once() + ->andThrow(new AwsException('Email address is not verified.', new Command('sendRawEmail'))); + + $this->expectException(TransportException::class); + + (new SesTransport($client))->send($message); + } + public function testSesLocalConfiguration() { $container = new Container; diff --git a/tests/Mail/MailSesV2TransportTest.php b/tests/Mail/MailSesV2TransportTest.php index c95a01440ecf..7b7821558ac7 100755 --- a/tests/Mail/MailSesV2TransportTest.php +++ b/tests/Mail/MailSesV2TransportTest.php @@ -2,6 +2,8 @@ namespace Illuminate\Tests\Mail; +use Aws\Command; +use Aws\Exception\AwsException; use Aws\SesV2\SesV2Client; use Illuminate\Config\Repository; use Illuminate\Container\Container; @@ -10,6 +12,7 @@ use Illuminate\View\Factory; use Mockery as m; use PHPUnit\Framework\TestCase; +use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Email; @@ -78,6 +81,23 @@ public function testSend() (new SesV2Transport($client))->send($message); } + public function testSendError() + { + $message = new Email(); + $message->subject('Foo subject'); + $message->text('Bar body'); + $message->sender('myself@example.com'); + $message->to('me@example.com'); + + $client = m::mock(SesV2Client::class); + $client->shouldReceive('sendEmail')->once() + ->andThrow(new AwsException('Email address is not verified.', new Command('sendRawEmail'))); + + $this->expectException(TransportException::class); + + (new SesV2Transport($client))->send($message); + } + public function testSesV2LocalConfiguration() { $container = new Container; From 8f1a56ddc710c67949f09353495e4b0469f27cec Mon Sep 17 00:00:00 2001 From: Choraimy Kroonstuiver <3661474+axlon@users.noreply.github.com> Date: Fri, 6 Oct 2023 15:29:39 +0200 Subject: [PATCH 03/50] Fix `Model::replicate()` when using unique keys (#48636) --- src/Illuminate/Database/Eloquent/Model.php | 1 + tests/Database/DatabaseEloquentModelTest.php | 150 +++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 393e49a51545..c10fac5b9e8b 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -1723,6 +1723,7 @@ public function replicate(array $except = null) $this->getKeyName(), $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), + ...$this->uniqueIds(), ])); $attributes = Arr::except( diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 848066bff7da..239e91eed71a 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -23,6 +23,8 @@ use Illuminate\Database\Eloquent\Casts\AsEnumCollection; use Illuminate\Database\Eloquent\Casts\AsStringable; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Concerns\HasUlids; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\MassAssignmentException; use Illuminate\Database\Eloquent\MissingAttributeException; @@ -1766,6 +1768,98 @@ public function testCloneModelMakesAFreshCopyOfTheModel() $this->assertEquals(['bar'], $clone->foo); } + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUuidPrimaryKey() + { + $class = new EloquentPrimaryUuidModelStub(); + $class->uuid = 'ccf55569-bc4a-4450-875f-b5cffb1b34ec'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->uuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUuid() + { + $class = new EloquentNonPrimaryUuidModelStub(); + $class->id = 1; + $class->uuid = 'ccf55569-bc4a-4450-875f-b5cffb1b34ec'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertNull($clone->uuid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUlidPrimaryKey() + { + $class = new EloquentPrimaryUlidModelStub(); + $class->ulid = '01HBZ975D8606P6CV672KW1AP2'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->ulid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + + public function testCloneModelMakesAFreshCopyOfTheModelWhenModelHasUlid() + { + $class = new EloquentNonPrimaryUlidModelStub(); + $class->id = 1; + $class->ulid = '01HBZ975D8606P6CV672KW1AP2'; + $class->exists = true; + $class->first = 'taylor'; + $class->last = 'otwell'; + $class->created_at = $class->freshTimestamp(); + $class->updated_at = $class->freshTimestamp(); + $class->setRelation('foo', ['bar']); + + $clone = $class->replicate(); + + $this->assertNull($clone->id); + $this->assertNull($clone->ulid); + $this->assertFalse($clone->exists); + $this->assertSame('taylor', $clone->first); + $this->assertSame('otwell', $clone->last); + $this->assertArrayNotHasKey('created_at', $clone->getAttributes()); + $this->assertArrayNotHasKey('updated_at', $clone->getAttributes()); + $this->assertEquals(['bar'], $clone->foo); + } + public function testModelObserversCanBeAttachedToModels() { EloquentModelStub::setEventDispatcher($events = m::mock(Dispatcher::class)); @@ -3158,6 +3252,62 @@ class EloquentDifferentConnectionModelStub extends EloquentModelStub public $connection = 'different_connection'; } +class EloquentPrimaryUuidModelStub extends EloquentModelStub +{ + use HasUuids; + + public $incrementing = false; + protected $keyType = 'string'; + + public function getKeyName() + { + return 'uuid'; + } +} + +class EloquentNonPrimaryUuidModelStub extends EloquentModelStub +{ + use HasUuids; + + public function getKeyName() + { + return 'id'; + } + + public function uniqueIds() + { + return ['uuid']; + } +} + +class EloquentPrimaryUlidModelStub extends EloquentModelStub +{ + use HasUlids; + + public $incrementing = false; + protected $keyType = 'string'; + + public function getKeyName() + { + return 'ulid'; + } +} + +class EloquentNonPrimaryUlidModelStub extends EloquentModelStub +{ + use HasUlids; + + public function getKeyName() + { + return 'id'; + } + + public function uniqueIds() + { + return ['ulid']; + } +} + class EloquentModelSavingEventStub { // From fcceb528c0df100f4b802699193e39fb5abcc8ae Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Fri, 6 Oct 2023 14:41:04 +0100 Subject: [PATCH 04/50] [10.x] Don't crash if replacement cannot be represented as a string (#48530) * Don't crash if replacement cannot be represented as a string * Update Str.php --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Support/Str.php | 19 ++++++++++++++++++- tests/Support/SupportStrTest.php | 2 ++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index ea39ba80be7b..e9278aaa2d5b 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -15,6 +15,7 @@ use Ramsey\Uuid\Uuid; use Ramsey\Uuid\UuidFactory; use Symfony\Component\Uid\Ulid; +use Throwable; use Traversable; use voku\helper\ASCII; @@ -986,12 +987,28 @@ public static function replaceArray($search, $replace, $subject) $result = array_shift($segments); foreach ($segments as $segment) { - $result .= (array_shift($replace) ?? $search).$segment; + $result .= self::toStringOr(array_shift($replace) ?? $search, $search).$segment; } return $result; } + /** + * Convert the given value to a string or return the given fallback on failure. + * + * @param mixed $value + * @param string $fallback + * @return string + */ + private static function toStringOr($value, $fallback) + { + try { + return (string) $value; + } catch (Throwable $e) { + return $fallback; + } + } + /** * Replace the given value in the given string. * diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 73d1770fcb62..62a66737c4aa 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -594,6 +594,8 @@ public function testReplaceArray() // Test for associative array support $this->assertSame('foo/bar', Str::replaceArray('?', [1 => 'foo', 2 => 'bar'], '?/?')); $this->assertSame('foo/bar', Str::replaceArray('?', ['x' => 'foo', 'y' => 'bar'], '?/?')); + // Test does not crash on bad input + $this->assertSame('?', Str::replaceArray('?', [(object) ['foo' => 'bar']], '?')); } public function testReplaceFirst() From cddb4f3bb5231f44f18fce304dbf190ad8348ddc Mon Sep 17 00:00:00 2001 From: Bert Date: Fri, 6 Oct 2023 21:05:18 +0200 Subject: [PATCH 05/50] [10.x] Extended `pluck()` testcases (#48657) * Update QueryBuilderTest.php * style --- .../Integration/Database/QueryBuilderTest.php | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/Integration/Database/QueryBuilderTest.php b/tests/Integration/Database/QueryBuilderTest.php index 6eda04125123..a7f32f42d1e6 100644 --- a/tests/Integration/Database/QueryBuilderTest.php +++ b/tests/Integration/Database/QueryBuilderTest.php @@ -398,4 +398,42 @@ public function testChunkMap() $this->assertSame('Bar Post', $results[1]); $this->assertCount(3, DB::getQueryLog()); } + + public function testPluck() + { + // Test SELECT override, since pluck will take the first column. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->select(['content', 'id', 'title'])->pluck('title')->toArray()); + + // Test without SELECT override. + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], DB::table('posts')->pluck('title')->toArray()); + + // Test specific key. + $this->assertSame([ + 1 => 'Foo Post', + 2 => 'Bar Post', + ], DB::table('posts')->pluck('title', 'id')->toArray()); + + $results = DB::table('posts')->pluck('title', 'created_at'); + + // Test timestamps (truncates RDBMS differences). + $this->assertSame([ + '2017-11-12 13:14:15', + '2018-01-02 03:04:05', + ], $results->keys()->map(fn ($v) => substr($v, 0, 19))->toArray()); + $this->assertSame([ + 'Foo Post', + 'Bar Post', + ], $results->values()->toArray()); + + // Test duplicate keys (a match will override a previous match). + $this->assertSame([ + 'Lorem Ipsum.' => 'Bar Post', + ], DB::table('posts')->pluck('title', 'content')->toArray()); + } } From 4742c90cc894e33c393350dbc0380f79f64a1185 Mon Sep 17 00:00:00 2001 From: Mior Muhammad Zaki Date: Mon, 9 Oct 2023 22:21:07 +0800 Subject: [PATCH 06/50] [10.x] Fixes `GeneratorCommand` not able to prevent uppercase reserved (#48667) name such as `__CLASS__` Signed-off-by: Mior Muhammad Zaki --- src/Illuminate/Console/GeneratorCommand.php | 9 ++++-- .../Console/GeneratorCommandTest.php | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Console/GeneratorCommandTest.php diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index b0543b36ab4d..4afcb4306113 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -449,9 +449,12 @@ protected function userProviderModel() */ protected function isReservedName($name) { - $name = strtolower($name); - - return in_array($name, $this->reservedNames); + return in_array( + strtolower($name), + collect($this->reservedNames) + ->transform(fn ($name) => strtolower($name)) + ->all() + ); } /** diff --git a/tests/Integration/Console/GeneratorCommandTest.php b/tests/Integration/Console/GeneratorCommandTest.php new file mode 100644 index 000000000000..18889bf5ed3b --- /dev/null +++ b/tests/Integration/Console/GeneratorCommandTest.php @@ -0,0 +1,28 @@ +artisan('make:command', ['name' => $given]) + ->expectsOutputToContain('The name "'.$given.'" is reserved by PHP.') + ->assertExitCode(0); + } + + public static function reservedNamesDataProvider() + { + yield ['__halt_compiler']; + yield ['__HALT_COMPILER']; + yield ['array']; + yield ['ARRAY']; + yield ['__class__']; + yield ['__CLASS__']; + } +} From 8b4920470f0a179ecf3128466a06511d7723b4cb Mon Sep 17 00:00:00 2001 From: KentarouTakeda Date: Mon, 9 Oct 2023 23:22:10 +0900 Subject: [PATCH 07/50] Fix timing sensitive flaky test (#48664) --- .../Queue/ThrottlesExceptionsWithRedisTest.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php index 87a7af68a22c..358d0a8b2513 100644 --- a/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php +++ b/tests/Integration/Queue/ThrottlesExceptionsWithRedisTest.php @@ -10,6 +10,7 @@ use Illuminate\Queue\CallQueuedHandler; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\Middleware\ThrottlesExceptionsWithRedis; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Mockery as m; use Orchestra\Testbench\TestCase; @@ -23,6 +24,8 @@ protected function setUp(): void parent::setUp(); $this->setUpRedis(); + + Carbon::setTestNow(now()); } protected function tearDown(): void @@ -31,6 +34,8 @@ protected function tearDown(): void $this->tearDownRedis(); + Carbon::setTestNow(); + m::close(); } @@ -38,10 +43,7 @@ public function testCircuitIsOpenedForJobErrors() { $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key = Str::random()); $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); - - retry(2, function () use ($key) { - $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); - }); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); } public function testCircuitStaysClosedForSuccessfulJobs() @@ -57,10 +59,7 @@ public function testCircuitResetsAfterSuccess() $this->assertJobRanSuccessfully(CircuitBreakerWithRedisSuccessfulJob::class, $key); $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); $this->assertJobWasReleasedImmediately(CircuitBreakerWithRedisTestJob::class, $key); - - retry(2, function () use ($key) { - $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); - }); + $this->assertJobWasReleasedWithDelay(CircuitBreakerWithRedisTestJob::class, $key); } protected function assertJobWasReleasedImmediately($class, $key) From b650a0d8e7fdd836b74bea5c6ad41b72a0f5fb52 Mon Sep 17 00:00:00 2001 From: Saki Takamachi <34942839+SakiTakamachi@users.noreply.github.com> Date: Mon, 9 Oct 2023 23:25:55 +0900 Subject: [PATCH 08/50] [10.x] Fixed implementation related to `afterCommit` on Postgres and MSSQL database drivers (#48662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix expected level in afterCommitCallbacksShouldBeExecuted * remove callbacksShouldIgnore * Fixed transaction level down timing * Fixed transaction level down timing * Add test - after commit is executed on final commit * style fix style fix * [10.x] Test Improvements Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki --------- Signed-off-by: Mior Muhammad Zaki Co-authored-by: 武田 憲太郎 Co-authored-by: Mior Muhammad Zaki --- .../Database/Concerns/ManagesTransactions.php | 11 ++--- .../Database/DatabaseTransactionsManager.php | 30 +----------- .../Testing/DatabaseTransactionsManager.php | 2 +- tests/Database/DatabaseConnectionTest.php | 15 ++++++ ...loquentTransactionWithAfterCommitTests.php | 48 +++++++++++++++++++ 5 files changed, 70 insertions(+), 36 deletions(-) diff --git a/src/Illuminate/Database/Concerns/ManagesTransactions.php b/src/Illuminate/Database/Concerns/ManagesTransactions.php index 80dac1c57b12..a690f7b5cb46 100644 --- a/src/Illuminate/Database/Concerns/ManagesTransactions.php +++ b/src/Illuminate/Database/Concerns/ManagesTransactions.php @@ -47,6 +47,8 @@ public function transaction(Closure $callback, $attempts = 1) $this->getPdo()->commit(); } + $this->transactions = max(0, $this->transactions - 1); + if ($this->afterCommitCallbacksShouldBeExecuted()) { $this->transactionsManager?->commit($this->getName()); } @@ -56,8 +58,6 @@ public function transaction(Closure $callback, $attempts = 1) ); continue; - } finally { - $this->transactions = max(0, $this->transactions - 1); } $this->fireConnectionEvent('committed'); @@ -194,12 +194,12 @@ public function commit() $this->getPdo()->commit(); } + $this->transactions = max(0, $this->transactions - 1); + if ($this->afterCommitCallbacksShouldBeExecuted()) { $this->transactionsManager?->commit($this->getName()); } - $this->transactions = max(0, $this->transactions - 1); - $this->fireConnectionEvent('committed'); } @@ -210,8 +210,7 @@ public function commit() */ protected function afterCommitCallbacksShouldBeExecuted() { - return $this->transactions == 0 || - $this->transactionsManager?->afterCommitCallbacksShouldBeExecuted($this->transactions); + return $this->transactionsManager?->afterCommitCallbacksShouldBeExecuted($this->transactions) || $this->transactions == 0; } /** diff --git a/src/Illuminate/Database/DatabaseTransactionsManager.php b/src/Illuminate/Database/DatabaseTransactionsManager.php index a39924db761b..e198f4f3f6d6 100755 --- a/src/Illuminate/Database/DatabaseTransactionsManager.php +++ b/src/Illuminate/Database/DatabaseTransactionsManager.php @@ -11,13 +11,6 @@ class DatabaseTransactionsManager */ protected $transactions; - /** - * The database transaction that should be ignored by callbacks. - * - * @var \Illuminate\Database\DatabaseTransactionRecord|null - */ - protected $callbacksShouldIgnore; - /** * Create a new database transactions manager instance. * @@ -54,10 +47,6 @@ public function rollback($connection, $level) $this->transactions = $this->transactions->reject( fn ($transaction) => $transaction->connection == $connection && $transaction->level > $level )->values(); - - if ($this->transactions->isEmpty()) { - $this->callbacksShouldIgnore = null; - } } /** @@ -75,10 +64,6 @@ public function commit($connection) $this->transactions = $forOtherConnections->values(); $forThisConnection->map->executeCallbacks(); - - if ($this->transactions->isEmpty()) { - $this->callbacksShouldIgnore = null; - } } /** @@ -96,19 +81,6 @@ public function addCallback($callback) $callback(); } - /** - * Specify that callbacks should ignore the given transaction when determining if they should be executed. - * - * @param \Illuminate\Database\DatabaseTransactionRecord $transaction - * @return $this - */ - public function callbacksShouldIgnore(DatabaseTransactionRecord $transaction) - { - $this->callbacksShouldIgnore = $transaction; - - return $this; - } - /** * Get the transactions that are applicable to callbacks. * @@ -127,7 +99,7 @@ public function callbackApplicableTransactions() */ public function afterCommitCallbacksShouldBeExecuted($level) { - return $level === 1; + return $level === 0; } /** diff --git a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php index b2e2de142e99..08c1635443a6 100644 --- a/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php +++ b/src/Illuminate/Foundation/Testing/DatabaseTransactionsManager.php @@ -42,6 +42,6 @@ public function callbackApplicableTransactions() */ public function afterCommitCallbacksShouldBeExecuted($level) { - return $level === 2; + return $level === 1; } } diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 0df367eed634..9ad3819a0057 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -7,6 +7,7 @@ use Exception; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Connection; +use Illuminate\Database\DatabaseTransactionsManager; use Illuminate\Database\Events\QueryExecuted; use Illuminate\Database\Events\TransactionBeginning; use Illuminate\Database\Events\TransactionCommitted; @@ -289,6 +290,20 @@ public function testCommittingFiresEventsIfSet() $connection->commit(); } + public function testAfterCommitIsExecutedOnFinalCommit() + { + $pdo = $this->getMockBuilder(DatabaseConnectionTestMockPDO::class)->onlyMethods(['beginTransaction', 'commit'])->getMock(); + $transactionsManager = $this->getMockBuilder(DatabaseTransactionsManager::class)->onlyMethods(['afterCommitCallbacksShouldBeExecuted'])->getMock(); + $transactionsManager->expects($this->once())->method('afterCommitCallbacksShouldBeExecuted')->with(0)->willReturn(true); + + $connection = $this->getMockConnection([], $pdo); + $connection->setTransactionManager($transactionsManager); + + $connection->transaction(function () { + // do nothing + }); + } + public function testRollBackedFiresEventsIfSet() { $pdo = $this->createMock(DatabaseConnectionTestMockPDO::class); diff --git a/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php b/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php index 41a2eed8a0da..262ef43d9c5d 100644 --- a/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php +++ b/tests/Integration/Database/EloquentTransactionWithAfterCommitTests.php @@ -2,7 +2,11 @@ namespace Illuminate\Tests\Integration\Database; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Auth\User; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\DB; use Orchestra\Testbench\Concerns\WithLaravelMigrations; use Orchestra\Testbench\Factories\UserFactory; @@ -41,6 +45,21 @@ public function testObserverCalledWithAfterCommitWhenInsideTransaction() $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); } + public function testObserverCalledWithAfterCommitWhenInsideTransactionWithDispatchSync() + { + User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserverUsingDispatchSync::resetting()); + + $user1 = DB::transaction(fn () => User::create(UserFactory::new()->raw())); + + $this->assertTrue($user1->exists); + $this->assertEquals(1, $observer::$calledTimes, 'Failed to assert the observer was called once.'); + + $this->assertDatabaseHas('password_reset_tokens', [ + 'email' => $user1->email, + 'token' => sha1($user1->email), + ]); + } + public function testObserverIsCalledOnTestsWithAfterCommitWhenUsingSavepoint() { User::observe($observer = EloquentTransactionWithAfterCommitTestsUserObserver::resetting()); @@ -102,3 +121,32 @@ public function created($user) static::$calledTimes++; } } + +class EloquentTransactionWithAfterCommitTestsUserObserverUsingDispatchSync extends EloquentTransactionWithAfterCommitTestsUserObserver +{ + public function created($user) + { + dispatch_sync(new EloquentTransactionWithAfterCommitTestsJob($user->email)); + + parent::created($user); + } +} + +class EloquentTransactionWithAfterCommitTestsJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable; + + public function __construct(public string $email) + { + // ... + } + + public function handle(): void + { + DB::transaction(function () { + DB::table('password_reset_tokens')->insert([ + ['email' => $this->email, 'token' => sha1($this->email), 'created_at' => now()], + ]); + }); + } +} From 678ee93afb454de73fa4022362c94a8122863e32 Mon Sep 17 00:00:00 2001 From: Cristian Calara Date: Mon, 9 Oct 2023 17:49:26 +0300 Subject: [PATCH 09/50] [10.x] Implement chunkById in descending order (#48666) * Implement chunkById in descending order * formatting --------- Co-authored-by: Taylor Otwell --- .../Database/Concerns/BuildsQueries.php | 35 ++++++++++++++++++- tests/Database/DatabaseQueryBuilderTest.php | 20 +++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Concerns/BuildsQueries.php b/src/Illuminate/Database/Concerns/BuildsQueries.php index df1f40ce08ed..0d45b0a6f6fc 100644 --- a/src/Illuminate/Database/Concerns/BuildsQueries.php +++ b/src/Illuminate/Database/Concerns/BuildsQueries.php @@ -112,6 +112,35 @@ public function each(callable $callback, $count = 1000) * @return bool */ public function chunkById($count, callable $callback, $column = null, $alias = null) + { + return $this->orderedChunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param int $count + * @param callable $callback + * @param string|null $column + * @param string|null $alias + * @return bool + */ + public function chunkByIdDesc($count, callable $callback, $column = null, $alias = null) + { + return $this->orderedChunkById($count, $callback, $column, $alias, descending: true); + } + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param int $count + * @param callable $callback + * @param string|null $column + * @param string|null $alias + * @param bool $descending + * @return bool + */ + public function orderedChunkById($count, callable $callback, $column = null, $alias = null, $descending = false) { $column ??= $this->defaultKeyName(); @@ -127,7 +156,11 @@ public function chunkById($count, callable $callback, $column = null, $alias = n // We'll execute the query for the given page and get the results. If there are // no results we can just break and return from here. When there are results // we will call the callback with the current chunk of these results here. - $results = $clone->forPageAfterId($count, $lastId, $column)->get(); + if ($descending) { + $results = $clone->forPageBeforeId($count, $lastId, $column)->get(); + } else { + $results = $clone->forPageAfterId($count, $lastId, $column)->get(); + } $countResults = $results->count(); diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 53dc6b40e565..b05798eca8a7 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -4421,6 +4421,26 @@ public function testChunkPaginatesUsingIdWithAlias() }, 'table.id', 'table_id'); } + public function testChunkPaginatesUsingIdDesc() + { + $builder = $this->getMockQueryBuilder(); + $builder->orders[] = ['column' => 'foobar', 'direction' => 'desc']; + + $chunk1 = collect([(object) ['someIdField' => 10], (object) ['someIdField' => 1]]); + $chunk2 = collect([]); + $builder->shouldReceive('forPageBeforeId')->once()->with(2, 0, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('forPageBeforeId')->once()->with(2, 1, 'someIdField')->andReturnSelf(); + $builder->shouldReceive('get')->times(2)->andReturn($chunk1, $chunk2); + + $callbackAssertor = m::mock(stdClass::class); + $callbackAssertor->shouldReceive('doSomething')->once()->with($chunk1); + $callbackAssertor->shouldReceive('doSomething')->never()->with($chunk2); + + $builder->chunkByIdDesc(2, function ($results) use ($callbackAssertor) { + $callbackAssertor->doSomething($results); + }, 'someIdField'); + } + public function testPaginate() { $perPage = 16; From 616f81bd6dd8aa2e26a9fc21d9c95e98bd30803b Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Mon, 9 Oct 2023 10:15:28 -0500 Subject: [PATCH 10/50] version --- src/Illuminate/Foundation/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index fcbbe6b55900..6ac1ededc8fc 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -38,7 +38,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '10.26.2'; + const VERSION = '10.27.0'; /** * The base path for the Laravel installation. From 6bfd7550c94ef8666b3312cae577ddd30db03e03 Mon Sep 17 00:00:00 2001 From: Bojan Lozo <69368941+lozobojan@users.noreply.github.com> Date: Mon, 9 Oct 2023 20:36:56 +0200 Subject: [PATCH 11/50] Fixed issue: Added a call to the getValue method (#48652) * Fixed issue: Added a call to the getValue method. Without this, raw SQL queries in conditions throw an exception. * Added check to prevent call on types other than Expression * Added new test cases for the modified method * Refactored the variable names * Fixed a StyleCI issue * Update Grammar.php --------- Co-authored-by: Bojan Lozo Co-authored-by: Taylor Otwell --- .../Database/Query/Grammars/Grammar.php | 3 +- tests/Database/DatabaseQueryGrammarTest.php | 44 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tests/Database/DatabaseQueryGrammarTest.php diff --git a/src/Illuminate/Database/Query/Grammars/Grammar.php b/src/Illuminate/Database/Query/Grammars/Grammar.php index 5419ad07fbb3..3b4f117693f6 100755 --- a/src/Illuminate/Database/Query/Grammars/Grammar.php +++ b/src/Illuminate/Database/Query/Grammars/Grammar.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Query\Grammars; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Concerns\CompilesJsonPaths; use Illuminate\Database\Grammar as BaseGrammar; use Illuminate\Database\Query\Builder; @@ -246,7 +247,7 @@ protected function concatenateWhereClauses($query, $sql) */ protected function whereRaw(Builder $query, $where) { - return $where['sql']; + return $where['sql'] instanceof Expression ? $where['sql']->getValue($this) : $where['sql']; } /** diff --git a/tests/Database/DatabaseQueryGrammarTest.php b/tests/Database/DatabaseQueryGrammarTest.php new file mode 100644 index 000000000000..aee7f822caba --- /dev/null +++ b/tests/Database/DatabaseQueryGrammarTest.php @@ -0,0 +1,44 @@ +getMethod('whereRaw'); + $expressionArray = ['sql' => new Expression('select * from "users"')]; + + $rawQuery = $method->invoke($grammar, $builder, $expressionArray); + + $this->assertSame('select * from "users"', $rawQuery); + } + + public function testWhereRawReturnsStringWhenStringPassed() + { + $builder = m::mock(Builder::class); + $grammar = new Grammar; + $reflection = new ReflectionClass($grammar); + $method = $reflection->getMethod('whereRaw'); + $stringArray = ['sql' => 'select * from "users"']; + + $rawQuery = $method->invoke($grammar, $builder, $stringArray); + + $this->assertSame('select * from "users"', $rawQuery); + } +} From 3e20cedcb0d24c152828fc23568db96f54e39b15 Mon Sep 17 00:00:00 2001 From: Martin Kluska Date: Tue, 10 Oct 2023 14:54:38 +0200 Subject: [PATCH 12/50] [10.x] Add an example for queue retry range option (#48691) * Add an example for queue retry range option * Update RetryCommand.php * Update RetryCommand.php --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Queue/Console/RetryCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Queue/Console/RetryCommand.php b/src/Illuminate/Queue/Console/RetryCommand.php index 106a8ec4ede8..37827dd01683 100644 --- a/src/Illuminate/Queue/Console/RetryCommand.php +++ b/src/Illuminate/Queue/Console/RetryCommand.php @@ -21,7 +21,7 @@ class RetryCommand extends Command protected $signature = 'queue:retry {id?* : The ID of the failed job or "all" to retry all jobs} {--queue= : Retry all of the failed jobs for the specified queue} - {--range=* : Range of job IDs (numeric) to be retried}'; + {--range=* : Range of job IDs (numeric) to be retried (e.g. 1-5)}'; /** * The console command description. From 219a497cd6d9e287c5c8acd4001c410634f443fc Mon Sep 17 00:00:00 2001 From: Wendell Adriel Date: Tue, 10 Oct 2023 13:55:25 +0100 Subject: [PATCH 13/50] Add percentage to be used as High Order (#48689) --- .../Collections/Traits/EnumeratesValues.php | 2 ++ tests/Support/SupportCollectionTest.php | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/Illuminate/Collections/Traits/EnumeratesValues.php b/src/Illuminate/Collections/Traits/EnumeratesValues.php index 32c7676fbf7e..18af3f18954e 100644 --- a/src/Illuminate/Collections/Traits/EnumeratesValues.php +++ b/src/Illuminate/Collections/Traits/EnumeratesValues.php @@ -37,6 +37,7 @@ * @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 @@ -83,6 +84,7 @@ trait EnumeratesValues 'max', 'min', 'partition', + 'percentage', 'reject', 'skipUntil', 'skipWhile', diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index b1bd699323ec..0c8ebb4aacc0 100755 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -5663,6 +5663,21 @@ public function testPercentageWithNestedCollection($collection) $this->assertSame(0.0, $collection->percentage(fn ($value) => $value['foo'] === 'test')); } + /** + * @dataProvider collectionClassProvider + */ + public function testHighOrderPercentage($collection) + { + $collection = new $collection([ + ['name' => 'Taylor', 'active' => true], + ['name' => 'Nuno', 'active' => true], + ['name' => 'Dries', 'active' => false], + ['name' => 'Jess', 'active' => true], + ]); + + $this->assertSame(75.00, $collection->percentage->active); + } + /** * @dataProvider collectionClassProvider */ From 3b40b3696d37557d2125130fc49fed343b155f80 Mon Sep 17 00:00:00 2001 From: Mohammad ALTAWEEL Date: Tue, 10 Oct 2023 15:56:32 +0300 Subject: [PATCH 14/50] optimize `exists` validation for empty array input (#48684) --- src/Illuminate/Validation/Concerns/ValidatesAttributes.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php index 44545565c2ea..9b131c01dd5e 100644 --- a/src/Illuminate/Validation/Concerns/ValidatesAttributes.php +++ b/src/Illuminate/Validation/Concerns/ValidatesAttributes.php @@ -864,6 +864,10 @@ public function validateExists($attribute, $value, $parameters) $expected = is_array($value) ? count(array_unique($value)) : 1; + if ($expected === 0) { + return true; + } + return $this->getExistCount( $connection, $table, $column, $value, $parameters ) >= $expected; From 09137f50f715c1efc649788a26092dcb1ec4ab6e Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 10 Oct 2023 08:01:37 -0500 Subject: [PATCH 15/50] version --- src/Illuminate/Foundation/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 6ac1ededc8fc..61581c18e1d9 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -38,7 +38,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '10.27.0'; + const VERSION = '10.28.0'; /** * The base path for the Laravel installation. From d8db215fd1ba72944cbf0def3670663721a4630b Mon Sep 17 00:00:00 2001 From: driesvints Date: Tue, 10 Oct 2023 15:24:51 +0000 Subject: [PATCH 16/50] Update CHANGELOG --- CHANGELOG.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a06f738496e..4a5a467fe28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,22 @@ # Release Notes for 10.x -## [Unreleased](https://github.com/laravel/framework/compare/v10.26.2...10.x) +## [Unreleased](https://github.com/laravel/framework/compare/v10.27.0...10.x) + +## [v10.27.0](https://github.com/laravel/framework/compare/v10.26.2...v10.27.0) - 2023-10-09 + +- [10.x] Store blocks after prepare strings by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/framework/pull/48641 +- [10.x] throw TransportException instead of Exception in SES mail drivers by [@bchalier](https://github.com/bchalier) in https://github.com/laravel/framework/pull/48645 +- [10.x] Fix `Model::replicate()` when using unique keys by [@axlon](https://github.com/axlon) in https://github.com/laravel/framework/pull/48636 +- [10.x] Don't crash if replacement cannot be represented as a string by [@GrahamCampbell](https://github.com/GrahamCampbell) in https://github.com/laravel/framework/pull/48530 +- [10.x] Extended `pluck()` testcases by [@bert-w](https://github.com/bert-w) in https://github.com/laravel/framework/pull/48657 +- [10.x] Fixes `GeneratorCommand` not able to prevent uppercase reserved name such as `__CLASS__` by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/framework/pull/48667 +- [10.x] Fix timing sensitive flaky test by [@KentarouTakeda](https://github.com/KentarouTakeda) in https://github.com/laravel/framework/pull/48664 +- [10.x] Fixed implementation related to `afterCommit` on Postgres and MSSQL database drivers by [@SakiTakamachi](https://github.com/SakiTakamachi) in https://github.com/laravel/framework/pull/48662 +- [10.x] Implement chunkById in descending order by [@cristiancalara](https://github.com/cristiancalara) in https://github.com/laravel/framework/pull/48666 ## [v10.26.2](https://github.com/laravel/framework/compare/v10.26.1...v10.26.2) - 2023-10-03 -* Revert "Hint query builder closures (#48562)" by @taylorotwell in https://github.com/laravel/framework/pull/48620 +- Revert "Hint query builder closures (#48562)" by @taylorotwell in https://github.com/laravel/framework/pull/48620 ## [v10.26.1](https://github.com/laravel/framework/compare/v10.26.0...v10.26.1) - 2023-10-03 From 26e2e5364b480d3054ecb327c8d39257b84aedad Mon Sep 17 00:00:00 2001 From: driesvints Date: Tue, 10 Oct 2023 15:25:13 +0000 Subject: [PATCH 17/50] Update CHANGELOG --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a5a467fe28f..fdbac3f4e11f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Release Notes for 10.x -## [Unreleased](https://github.com/laravel/framework/compare/v10.27.0...10.x) +## [Unreleased](https://github.com/laravel/framework/compare/v10.28.0...10.x) + +## [v10.28.0](https://github.com/laravel/framework/compare/v10.27.0...v10.28.0) - 2023-10-10 + +- [10.x] Fixed issue: Added a call to the `getValue` method by [@lozobojan](https://github.com/lozobojan) in https://github.com/laravel/framework/pull/48652 +- [10.x] Add an example for queue retry range option by [@pionl](https://github.com/pionl) in https://github.com/laravel/framework/pull/48691 +- [10.x] Add percentage to be used as High Order Messages by [@WendellAdriel](https://github.com/WendellAdriel) in https://github.com/laravel/framework/pull/48689 +- [10.x] Optimize `exists` validation for empty array input by [@mtawil](https://github.com/mtawil) in https://github.com/laravel/framework/pull/48684 ## [v10.27.0](https://github.com/laravel/framework/compare/v10.26.2...v10.27.0) - 2023-10-09 From 44360d852209d863d089952cbe435734319473d4 Mon Sep 17 00:00:00 2001 From: Mior Muhammad Zaki Date: Wed, 11 Oct 2023 01:20:55 +0800 Subject: [PATCH 18/50] [10.x] Fixes `Str::password()` does not always generate password with numbers (#48681) * wip Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * formatting * Apply fixes from StyleCI --------- Signed-off-by: Mior Muhammad Zaki Co-authored-by: Taylor Otwell Co-authored-by: StyleCI Bot --- src/Illuminate/Support/Str.php | 46 +++++++++++++++++++------------- tests/Support/SupportStrTest.php | 7 +++++ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index e9278aaa2d5b..c1cc95f2fbc9 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -841,25 +841,33 @@ public static function pluralStudly($value, $count = 2) */ public static function password($length = 32, $letters = true, $numbers = true, $symbols = true, $spaces = false) { - return (new Collection) - ->when($letters, fn ($c) => $c->merge([ - 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', - 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', - 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', - 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', - 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', - ])) - ->when($numbers, fn ($c) => $c->merge([ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', - ])) - ->when($symbols, fn ($c) => $c->merge([ - '~', '!', '#', '$', '%', '^', '&', '*', '(', ')', '-', - '_', '.', ',', '<', '>', '?', '/', '\\', '{', '}', '[', - ']', '|', ':', ';', - ])) - ->when($spaces, fn ($c) => $c->merge([' '])) - ->pipe(fn ($c) => Collection::times($length, fn () => $c[random_int(0, $c->count() - 1)])) - ->implode(''); + $password = new Collection(); + + $options = (new Collection([ + 'letters' => $letters === true ? [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', + 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', + 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', + 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + ] : null, + 'numbers' => $numbers === true ? [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + ] : null, + 'symbols' => $symbols === true ? [ + '~', '!', '#', '$', '%', '^', '&', '*', '(', ')', '-', + '_', '.', ',', '<', '>', '?', '/', '\\', '{', '}', '[', + ']', '|', ':', ';', + ] : null, + 'spaces' => $spaces === true ? [' '] : null, + ]))->filter()->each(fn ($c) => $password->push($c[random_int(0, count($c) - 1)]) + )->flatten(); + + $length = $length - $password->count(); + + return $password->merge($options->pipe( + fn ($c) => Collection::times($length, fn () => $c[random_int(0, $c->count() - 1)]) + ))->shuffle()->implode(''); } /** diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 62a66737c4aa..74469575bc05 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -1288,6 +1288,13 @@ public function testItCanSpecifyAFallbackForAUlidSequence() public function testPasswordCreation() { $this->assertTrue(strlen(Str::password()) === 32); + + $this->assertStringNotContainsString(' ', Str::password()); + $this->assertStringContainsString(' ', Str::password(spaces: true)); + + $this->assertTrue( + Str::of(Str::password())->contains(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) + ); } } From 1cae8375e8bbcec8bd1d47102184d34a2ee77747 Mon Sep 17 00:00:00 2001 From: HuanXiang Date: Wed, 11 Oct 2023 20:10:29 +0800 Subject: [PATCH 19/50] [10.x] Fixes cache:prune-stale-tags preg_match delimiter no escaped (#48702) When redis prefix has / delimiter will not escaped. The preg_match will Unknown modifier Exception --- src/Illuminate/Cache/RedisStore.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Cache/RedisStore.php b/src/Illuminate/Cache/RedisStore.php index 437cee78136d..dbbeb40a4b5c 100755 --- a/src/Illuminate/Cache/RedisStore.php +++ b/src/Illuminate/Cache/RedisStore.php @@ -317,7 +317,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)); } /** From 5e444ac8b9f2e39ded13248cc2e91c82ee162691 Mon Sep 17 00:00:00 2001 From: NickSdot <32384907+NickSdot@users.noreply.github.com> Date: Thu, 12 Oct 2023 00:10:41 +0800 Subject: [PATCH 20/50] [10.x] Allow route:list to expand middleware groups in 'VeryVerbose' mode (#48703) * Allow middleware groups to be expanded with -vv * Keep things simple and do not use group nesting * Moved test to correct folder * Removed useless tmp vars * Style Changes * Added test to ensure expanded middleware are correctly sorted * Improved tests once again to ensure ordering via $kernel->prependToMiddlewarePriority() works as well, and that middleware is not displayed in non very verbose cases --- .../Foundation/Console/RouteListCommand.php | 4 +- .../Console/RouteListCommandTest.php | 150 ++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/Foundation/Console/RouteListCommandTest.php diff --git a/src/Illuminate/Foundation/Console/RouteListCommand.php b/src/Illuminate/Foundation/Console/RouteListCommand.php index 7cd48f5d761c..11a8c7247cbe 100644 --- a/src/Illuminate/Foundation/Console/RouteListCommand.php +++ b/src/Illuminate/Foundation/Console/RouteListCommand.php @@ -90,7 +90,9 @@ public function __construct(Router $router) */ public function handle() { - $this->router->flushMiddlewareGroups(); + if (! $this->output->isVeryVerbose()) { + $this->router->flushMiddlewareGroups(); + } if (! $this->router->getRoutes()->count()) { return $this->components->error("Your application doesn't have any routes."); diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php new file mode 100644 index 000000000000..2b9b72c0f981 --- /dev/null +++ b/tests/Foundation/Console/RouteListCommandTest.php @@ -0,0 +1,150 @@ +app = new Application( + $laravel = new \Illuminate\Foundation\Application(__DIR__), + m::mock(Dispatcher::class, ['dispatch' => null, 'fire' => null]), + 'testing', + ); + + $router = new Router(m::mock('Illuminate\Events\Dispatcher')); + + $kernel = new class($laravel, $router) extends Kernel + { + protected $middlewareGroups = [ + 'web' => ['Middleware 1', 'Middleware 2', 'Middleware 5'], + 'auth' => ['Middleware 3', 'Middleware 4'], + ]; + + protected $middlewarePriority = [ + 'Middleware 1', + 'Middleware 4', + 'Middleware 2', + 'Middleware 3', + ]; + }; + + $kernel->prependToMiddlewarePriority('Middleware 5'); + + $laravel->singleton(Kernel::class, function () use ($kernel) { + return $kernel; + }); + + $router->get('/example', function () { + return 'Hello World'; + })->middleware('exampleMiddleware'); + + $router->get('/example-group', function () { + return 'Hello Group'; + })->middleware(['web', 'auth']); + + $command = new RouteListCommand($router); + $command->setLaravel($laravel); + + $this->app->addCommands([$command]); + } + + public function testNoMiddlewareIfNotVerbose() + { + $this->app->call('route:list'); + $output = $this->app->output(); + + $this->assertStringNotContainsString('exampleMiddleware', $output); + } + + public function testMiddlewareGroupsAssignmentInCli() + { + $this->app->call('route:list', ['-v' => true]); + $output = $this->app->output(); + + $this->assertStringContainsString('exampleMiddleware', $output); + $this->assertStringContainsString('web', $output); + $this->assertStringContainsString('auth', $output); + + $this->assertStringNotContainsString('Middleware 1', $output); + $this->assertStringNotContainsString('Middleware 2', $output); + $this->assertStringNotContainsString('Middleware 3', $output); + $this->assertStringNotContainsString('Middleware 4', $output); + $this->assertStringNotContainsString('Middleware 5', $output); + } + + public function testMiddlewareGroupsExpandInCliIfVeryVerbose() + { + $this->app->call('route:list', ['-vv' => true]); + $output = $this->app->output(); + + $this->assertStringContainsString('exampleMiddleware', $output); + $this->assertStringContainsString('Middleware 1', $output); + $this->assertStringContainsString('Middleware 2', $output); + $this->assertStringContainsString('Middleware 3', $output); + $this->assertStringContainsString('Middleware 4', $output); + $this->assertStringContainsString('Middleware 5', $output); + + $this->assertStringNotContainsString('web', $output); + $this->assertStringNotContainsString('auth', $output); + } + + public function testMiddlewareGroupsAssignmentInJson() + { + $this->app->call('route:list', ['--json' => true, '-v' => true]); + $output = $this->app->output(); + + $this->assertStringContainsString('exampleMiddleware', $output); + $this->assertStringContainsString('web', $output); + $this->assertStringContainsString('auth', $output); + + $this->assertStringNotContainsString('Middleware 1', $output); + $this->assertStringNotContainsString('Middleware 2', $output); + $this->assertStringNotContainsString('Middleware 3', $output); + $this->assertStringNotContainsString('Middleware 4', $output); + $this->assertStringNotContainsString('Middleware 5', $output); + } + + public function testMiddlewareGroupsExpandInJsonIfVeryVerbose() + { + $this->app->call('route:list', ['--json' => true, '-vv' => true]); + $output = $this->app->output(); + + $this->assertStringContainsString('exampleMiddleware', $output); + $this->assertStringContainsString('Middleware 1', $output); + $this->assertStringContainsString('Middleware 2', $output); + $this->assertStringContainsString('Middleware 3', $output); + $this->assertStringContainsString('Middleware 4', $output); + $this->assertStringContainsString('Middleware 5', $output); + + $this->assertStringNotContainsString('web', $output); + $this->assertStringNotContainsString('auth', $output); + } + + public function testMiddlewareGroupsExpandCorrectlySortedIfVeryVerbose() + { + $this->app->call('route:list', ['--json' => true, '-vv' => true]); + $output = $this->app->output(); + + $expectedOrder = '[{"domain":null,"method":"GET|HEAD","uri":"example","name":null,"action":"Closure","middleware":["exampleMiddleware"]},{"domain":null,"method":"GET|HEAD","uri":"example-group","name":null,"action":"Closure","middleware":["Middleware 5","Middleware 1","Middleware 4","Middleware 2","Middleware 3"]}]'; + + $this->assertJsonStringEqualsJsonString($expectedOrder, $output); + } +} From 7966308ef7a3b2bf2af2de9692ddf5b166ee261e Mon Sep 17 00:00:00 2001 From: Zlatoslav Desyatnikov Date: Thu, 12 Oct 2023 01:27:06 +0400 Subject: [PATCH 21/50] Fix model:prune error with non-class php files (#48708) --- src/Illuminate/Database/Console/PruneCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Database/Console/PruneCommand.php b/src/Illuminate/Database/Console/PruneCommand.php index 5144247eeff0..0c51beb345b2 100644 --- a/src/Illuminate/Database/Console/PruneCommand.php +++ b/src/Illuminate/Database/Console/PruneCommand.php @@ -138,10 +138,10 @@ protected function models() return $models->reject(function ($model) use ($except) { return in_array($model, $except); }); - })->filter(function ($model) { - return $this->isPrunable($model); })->filter(function ($model) { return class_exists($model); + })->filter(function ($model) { + return $this->isPrunable($model); })->values(); } From 56beb0e3e770a883fba34d61817df40aad4cb984 Mon Sep 17 00:00:00 2001 From: CalebW Date: Thu, 12 Oct 2023 08:32:27 -0500 Subject: [PATCH 22/50] Show CliDumper source content on last line (#48707) --- src/Illuminate/Foundation/Console/CliDumper.php | 2 +- tests/Foundation/Console/CliDumperTest.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Foundation/Console/CliDumper.php b/src/Illuminate/Foundation/Console/CliDumper.php index beed2f2af9f4..304dfcb0c351 100644 --- a/src/Illuminate/Foundation/Console/CliDumper.php +++ b/src/Illuminate/Foundation/Console/CliDumper.php @@ -94,7 +94,7 @@ public function dumpWithSource(Data $data) $output = (string) $this->dump($data, true); $lines = explode("\n", $output); - $lines[0] .= $this->getDumpSourceContent(); + $lines[array_key_last($lines) - 1] .= $this->getDumpSourceContent(); $this->output->write(implode("\n", $lines)); diff --git a/tests/Foundation/Console/CliDumperTest.php b/tests/Foundation/Console/CliDumperTest.php index c8a9281dacde..c92245f9a703 100644 --- a/tests/Foundation/Console/CliDumperTest.php +++ b/tests/Foundation/Console/CliDumperTest.php @@ -55,7 +55,7 @@ public function testArray() $output = $this->dump(['string', 1, 1.1, ['string', 1, 1.1]]); $expected = <<<'EOF' - array:4 [ // app/routes/console.php:18 + array:4 [ 0 => "string" 1 => 1 2 => 1.1 @@ -64,7 +64,7 @@ public function testArray() 1 => 1 2 => 1.1 ] - ] + ] // app/routes/console.php:18 EOF; @@ -90,9 +90,9 @@ public function testObject() $objectId = spl_object_id($user); $expected = << Date: Thu, 12 Oct 2023 22:37:00 +0900 Subject: [PATCH 23/50] [10.x] Revival of the reverted changes in 10.25.0: `firstOrCreate` `updateOrCreate` improvement through `createOrFirst` + additional query tests (#48637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Revert "[10.x] Revert from using `createOrFirst` in other `*OrCreate` methods (#48531)" This reverts commit 408a3e3e3605de03392467bc4722e00de3db79f9. * test: 💍 Add `Builder::createOrFirst()` snapshot tests * test: 💍 Add `Builder::firstOrCreate()` snapshot tests * test: 💍 Add `Builder::updateOrCreate()` snapshot tests * test: 💍 Add test stubs for `DatabaseEloquentHasManyTest` * test: 💍 Add `HasMany::createOrFirst()` snapshot tests * test: 💍 Add `HasMany::firstOrCreate()` snapshot tests * test: 💍 Add `HasMany::updateOrCreate()` snapshot tests * test: 💍 prepare HasManyThrough test * test: 💍 Add HasManyThrough::CreateOrFirst() snapshot tests * test: 💍 Add HasManyThrough::firstOrCreate() snapshot test * test: 💍 Add HasManyThrough::updateOrCreate() snapshot test * refactor: 💡 use createOrFirst in firstOrCreate * test: 💍 fix test * style: 💄 Apply StyleCI fixes * docs: ✏️ Add missing FIXME comments * refactor: 💡 Omit verbose arguments * test: 💍 Rename `DatabaseEloquentHasManyThroughTest` with fixes * test: 💍 Add `BelongsToMany::createOrFirst/firstOrCreate` tests * test: 💍 Extract `DatabaseEloquentHasManyTest` cases with fixes * test: 💍 Extract `DatabaseEloquentBuilderTest` cases with fixes * test: 💍 refactoring * test: 💍 Add `BelongsToMany::updateOrCreate` snapshot tests --------- Co-authored-by: fuwasegu --- src/Illuminate/Database/Eloquent/Builder.php | 8 +- .../Eloquent/Relations/BelongsToMany.php | 20 +- .../Eloquent/Relations/HasManyThrough.php | 12 +- .../Eloquent/Relations/HasOneOrMany.php | 10 +- ...EloquentBelongsToManyCreateOrFirstTest.php | 498 ++++++++++++++++++ ...tabaseEloquentBuilderCreateOrFirstTest.php | 333 ++++++++++++ ...tabaseEloquentHasManyCreateOrFirstTest.php | 367 +++++++++++++ .../Database/DatabaseEloquentHasManyTest.php | 14 +- ...loquentHasManyThroughCreateOrFirstTest.php | 427 +++++++++++++++ tests/Database/DatabaseEloquentMorphTest.php | 14 +- 10 files changed, 1670 insertions(+), 33 deletions(-) create mode 100644 tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php create mode 100755 tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php create mode 100755 tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php create mode 100644 tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index a925336f6bb9..6d502d6cd118 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -567,7 +567,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } - return $this->create(array_merge($attributes, $values)); + return $this->createOrFirst($attributes, $values); } /** @@ -595,8 +595,10 @@ public function createOrFirst(array $attributes = [], array $values = []) */ public function updateOrCreate(array $attributes, array $values = []) { - return tap($this->firstOrNew($attributes), function ($instance) use ($values) { - $instance->fill($values)->save(); + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } }); } diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index dbf71768ccc3..37c698f3d80f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -622,7 +622,7 @@ public function firstOrCreate(array $attributes = [], array $values = [], array { if (is_null($instance = (clone $this)->where($attributes)->first())) { if (is_null($instance = $this->related->where($attributes)->first())) { - $instance = $this->create(array_merge($attributes, $values), $joining, $touch); + $instance = $this->createOrFirst($attributes, $values, $joining, $touch); } else { try { $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); @@ -672,19 +672,13 @@ public function createOrFirst(array $attributes = [], array $values = [], array */ public function updateOrCreate(array $attributes, array $values = [], array $joining = [], $touch = true) { - if (is_null($instance = (clone $this)->where($attributes)->first())) { - if (is_null($instance = $this->related->where($attributes)->first())) { - return $this->create(array_merge($attributes, $values), $joining, $touch); - } else { - $this->attach($instance, $joining, $touch); - } - } - - $instance->fill($values); + return tap($this->firstOrCreate($attributes, $values, $joining, $touch), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values); - $instance->save(['touch' => false]); - - return $instance; + $instance->save(['touch' => false]); + } + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index d8aa1809a9d8..2872bcdbd656 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -277,7 +277,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } - return $this->create(array_merge($attributes, $values)); + return $this->createOrFirst(array_merge($attributes, $values)); } /** @@ -305,11 +305,11 @@ public function createOrFirst(array $attributes = [], array $values = []) */ public function updateOrCreate(array $attributes, array $values = []) { - $instance = $this->firstOrNew($attributes); - - $instance->fill($values)->save(); - - return $instance; + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index f107ec310e41..154dc0e4516f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -236,7 +236,7 @@ public function firstOrNew(array $attributes = [], array $values = []) public function firstOrCreate(array $attributes = [], array $values = []) { if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->create(array_merge($attributes, $values)); + $instance = $this->createOrFirst($attributes, $values); } return $instance; @@ -267,10 +267,10 @@ public function createOrFirst(array $attributes = [], array $values = []) */ public function updateOrCreate(array $attributes, array $values = []) { - return tap($this->firstOrNew($attributes), function ($instance) use ($values) { - $instance->fill($values); - - $instance->save(); + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } }); } diff --git a/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php new file mode 100644 index 000000000000..047dc138a5e9 --- /dev/null +++ b/tests/Database/DatabaseEloquentBelongsToManyCreateOrFirstTest.php @@ -0,0 +1,498 @@ +id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + [456], + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection()->expects('insert')->with( + 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $source->getConnection()->expects('insert')->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + )->andReturnTrue(); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodAssociatesExistingRelated(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with('select * from "related_table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $source->getConnection()->expects('insert')->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + )->andReturnTrue(); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + // Pivot is not loaded when related model is newly created. + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRelatedAlreadyAssociated(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + 'pivot_source_id' => 123, + 'pivot_related_id' => 456, + ]]); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRelatedAssociatedJustNow(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "related_table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with('select * from "related_table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $sql = 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)'; + $bindings = [456, 123]; + + $source->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + false, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + 'pivot_source_id' => 123, + 'pivot_related_id' => 456, + ]]); + + $result = $source->related()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRelatedAndAssociatesIt(): void + { + $source = new BelongsToManyCreateOrFirstTestSourceModel(); + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $source->getConnection() + ->expects('select') + ->with( + 'select * from "related_table" where ("attr" = ?) limit 1', + ['foo'], + true, + ) + ->andReturn([[ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $source->getConnection() + ->expects('insert') + ->with( + 'insert into "pivot_table" ("related_id", "source_id") values (?, ?)', + [456, 123], + ) + ->andReturnTrue(); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + // Pivot is not loaded when related model is newly created. + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodFallsBackToCreateOrFirst(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = Mockery::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = false; + $instance->syncOriginal(); + $relation + ->expects('createOrFirst') + ->with(['attr' => 'foo'], ['val' => 'bar'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('select') + ->with( + 'select "related_table".*, "pivot_table"."source_id" as "pivot_source_id", "pivot_table"."related_id" as "pivot_related_id" from "related_table" inner join "pivot_table" on "related_table"."id" = "pivot_table"."related_id" where "pivot_table"."source_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $source->getConnection() + ->expects('select') + ->with( + 'select * from "related_table" where ("attr" = ?) limit 1', + ['foo'], + true, + ) + ->andReturn([]); + + $result = $source->related()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + 'pivot' => [ + 'source_id' => 123, + 'related_id' => 456, + ], + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRelated(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = Mockery::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = true; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->with(['attr' => 'foo'], ['val' => 'baz'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + + $result = $source->related()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRelated(): void + { + $source = new class() extends BelongsToManyCreateOrFirstTestSourceModel + { + protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName = null): BelongsToMany + { + $relation = Mockery::mock(BelongsToMany::class)->makePartial(); + $relation->__construct(...func_get_args()); + $instance = new BelongsToManyCreateOrFirstTestRelatedModel([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]); + $instance->exists = true; + $instance->wasRecentlyCreated = false; + $instance->syncOriginal(); + $relation + ->expects('firstOrCreate') + ->with(['attr' => 'foo'], ['val' => 'baz'], [], true) + ->andReturn($instance); + + return $relation; + } + }; + $source->id = 123; + $this->mockConnectionForModels( + [$source, new BelongsToManyCreateOrFirstTestRelatedModel()], + 'SQLite', + ); + $source->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $source->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $source->getConnection() + ->expects('update') + ->with( + 'update "related_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + ) + ->andReturn(1); + + $result = $source->related()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertEquals([ + 'id' => 456, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModels(array $models, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Illuminate\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Illuminate\Database\Query\Processors\\'.$database.'Processor'; + $grammar = new $grammarClass; + $processor = new $processorClass; + $connection = Mockery::mock(ConnectionInterface::class, ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new BaseBuilder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = Mockery::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + foreach ($models as $model) { + /** @var Model $model */ + $class = get_class($model); + $class::setConnectionResolver($resolver); + } + + $connection->shouldReceive('getPdo')->andReturn($pdo = Mockery::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + */ +class BelongsToManyCreateOrFirstTestRelatedModel extends Model +{ + protected $table = 'related_table'; + protected $guarded = []; +} + +/** + * @property int $id + */ +class BelongsToManyCreateOrFirstTestSourceModel extends Model +{ + protected $table = 'source_table'; + protected $guarded = []; + + public function related(): BelongsToMany + { + return $this->belongsToMany( + BelongsToManyCreateOrFirstTestRelatedModel::class, + 'pivot_table', + 'source_id', + 'related_id', + ); + } +} diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php new file mode 100755 index 000000000000..30ebf57e9b66 --- /dev/null +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -0,0 +1,333 @@ +mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with('select * from "table" where ("attr" = ?) and ("attr" = ?) limit 1', ['foo', 'foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->newQuery()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite', [123]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $model = new EloquentBuilderCreateOrFirstTestModel(); + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], true) + ->andReturn([]); + + $sql = 'insert into "table" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)'; + $bindings = ['foo', 'baz', '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with('select * from "table" where ("attr" = ?) and ("attr" = ?) limit 1', ['foo', 'foo'], false) + ->andReturn([[ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection() + ->expects('update') + ->with( + 'update "table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 123], + ) + ->andReturn(1); + + $result = $model->newQuery()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Illuminate\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Illuminate\Database\Query\Processors\\'.$database.'Processor'; + $grammar = new $grammarClass; + $processor = new $processorClass; + $connection = Mockery::mock(ConnectionInterface::class, ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = Mockery::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = Mockery::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +class EloquentBuilderCreateOrFirstTestModel extends Model +{ + protected $table = 'table'; + protected $guarded = []; +} diff --git a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php new file mode 100755 index 000000000000..e3b12fa52f31 --- /dev/null +++ b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -0,0 +1,367 @@ +id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) and ("attr" = ?) limit 1', [123, 'foo', 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $model->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite', [456]); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $model->getConnection()->expects('insert')->with( + 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)', + ['foo', 'bar', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $model->getConnection()->expects('update')->with( + 'update "child_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + )->andReturn(1); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $model = new HasManyCreateOrFirstTestParentModel(); + $model->id = 123; + $this->mockConnectionForModel($model, 'SQLite'); + $model->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $model->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $model->getConnection() + ->expects('select') + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], true) + ->andReturn([]); + + $sql = 'insert into "child_table" ("attr", "val", "parent_id", "updated_at", "created_at") values (?, ?, ?, ?, ?)'; + $bindings = ['foo', 'baz', 123, '2023-01-01 00:00:00', '2023-01-01 00:00:00']; + + $model->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $model->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) and ("attr" = ?) limit 1', [123, 'foo', 'foo'], false) + ->andReturn([[ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $model->getConnection()->expects('update')->with( + 'update "child_table" set "val" = ?, "updated_at" = ? where "id" = ?', + ['baz', '2023-01-01 00:00:00', 456], + )->andReturn(1); + + $result = $model->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 456, + 'parent_id' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Illuminate\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Illuminate\Database\Query\Processors\\'.$database.'Processor'; + $grammar = new $grammarClass; + $processor = new $processorClass; + $connection = Mockery::mock(ConnectionInterface::class, ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = Mockery::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = Mockery::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + */ +class HasManyCreateOrFirstTestParentModel extends Model +{ + protected $table = 'parent_table'; + protected $guarded = []; + + public function children(): HasMany + { + return $this->hasMany(HasManyCreateOrFirstTestChildModel::class, 'parent_id'); + } +} + +/** + * @property int $id + * @property int $parent_id + */ +class HasManyCreateOrFirstTestChildModel extends Model +{ + protected $table = 'child_table'; + protected $guarded = []; +} diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index 0bec03bc97fa..caa8b1d8196a 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -155,6 +155,7 @@ public function testFirstOrCreateMethodCreatesNewModelWithForeignKeySet() $relation = $this->getRelation(); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); $model = $this->expectCreatedModel($relation, ['foo']); $this->assertEquals($model, $relation->firstOrCreate(['foo'])); @@ -165,6 +166,7 @@ public function testFirstOrCreateMethodWithValuesCreatesNewModelWithForeignKeySe $relation = $this->getRelation(); $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); $this->assertEquals($model, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); @@ -225,7 +227,9 @@ public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(stdClass::class)); $relation->getRelated()->shouldReceive('newInstance')->never(); - $model->shouldReceive('fill')->once()->with(['bar']); + + $model->wasRecentlyCreated = false; + $model->shouldReceive('fill')->once()->with(['bar'])->andReturn($model); $model->shouldReceive('save')->once(); $this->assertInstanceOf(stdClass::class, $relation->updateOrCreate(['foo'], ['bar'])); @@ -234,11 +238,15 @@ public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() public function testUpdateOrCreateMethodCreatesNewModelWithForeignKeySet() { $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); - $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo', 'bar'])->andReturn($model = m::mock(Model::class)); + + $model->wasRecentlyCreated = true; $model->shouldReceive('save')->once()->andReturn(true); - $model->shouldReceive('fill')->once()->with(['bar']); $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php new file mode 100644 index 000000000000..dac5d821d56a --- /dev/null +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -0,0 +1,427 @@ +id = 123; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + $parent->getConnection()->expects('insert')->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $parent->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testCreateOrFirstMethodRetrievesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $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']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $parent->children()->createOrFirst(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodCreatesNewRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $parent->getConnection()->expects('insert')->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'bar', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + )->andReturnTrue(); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01 00:00:00', + 'updated_at' => '2023-01-01 00:00:00', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->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']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) and ("attr" = ? and "val" = ?) limit 1', + [123, 'foo', 'foo', 'bar'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodCreatesNewRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite', [789]); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([]); + + $parent->getConnection() + ->expects('insert') + ->with( + 'insert into "child" ("attr", "val", "updated_at", "created_at") values (?, ?, ?, ?)', + ['foo', 'baz', '2023-01-01 00:00:00', '2023-01-01 00:00:00'], + ) + ->andReturnTrue(); + + $result = $parent->children()->updateOrCreate(['attr' => 'foo'], ['val' => 'baz']); + $this->assertTrue($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesExistingRecord(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $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'], ['val' => 'baz']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'baz', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void + { + $parent = new HasManyThroughCreateOrFirstTestParentModel(); + $parent->id = 123; + $this->mockConnectionForModel($parent, 'SQLite'); + $parent->getConnection()->shouldReceive('transactionLevel')->andReturn(0); + $parent->getConnection()->shouldReceive('getName')->andReturn('sqlite'); + + $parent->getConnection() + ->expects('select') + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) limit 1', + [123, 'foo'], + true, + ) + ->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']; + + $parent->getConnection() + ->expects('insert') + ->with($sql, $bindings) + ->andThrow(new UniqueConstraintViolationException('sqlite', $sql, $bindings, new Exception())); + + $parent->getConnection() + ->expects('select') + // FIXME: duplicate conditions + ->with( + 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) and ("attr" = ? and "val" = ?) limit 1', + [123, 'foo', 'foo', 'bar'], + true, + ) + ->andReturn([[ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ]]); + + $result = $parent->children()->firstOrCreate(['attr' => 'foo'], ['val' => 'bar']); + $this->assertFalse($result->wasRecentlyCreated); + $this->assertEquals([ + 'id' => 789, + 'pivot_id' => 456, + 'laravel_through_key' => 123, + 'attr' => 'foo', + 'val' => 'bar', + 'created_at' => '2023-01-01T00:00:00.000000Z', + 'updated_at' => '2023-01-01T00:00:00.000000Z', + ], $result->toArray()); + } + + protected function mockConnectionForModel(Model $model, string $database, array $lastInsertIds = []): void + { + $grammarClass = 'Illuminate\Database\Query\Grammars\\'.$database.'Grammar'; + $processorClass = 'Illuminate\Database\Query\Processors\\'.$database.'Processor'; + $grammar = new $grammarClass; + $processor = new $processorClass; + $connection = Mockery::mock(ConnectionInterface::class, ['getQueryGrammar' => $grammar, 'getPostProcessor' => $processor]); + $connection->shouldReceive('query')->andReturnUsing(function () use ($connection, $grammar, $processor) { + return new Builder($connection, $grammar, $processor); + }); + $connection->shouldReceive('getDatabaseName')->andReturn('database'); + $resolver = Mockery::mock(ConnectionResolverInterface::class, ['connection' => $connection]); + + $class = get_class($model); + $class::setConnectionResolver($resolver); + + $connection->shouldReceive('getPdo')->andReturn($pdo = Mockery::mock(PDO::class)); + + foreach ($lastInsertIds as $id) { + $pdo->expects('lastInsertId')->andReturn($id); + } + } +} + +/** + * @property int $id + * @property int $pivot_id + */ +class HasManyThroughCreateOrFirstTestChildModel extends Model +{ + protected $table = 'child'; + protected $guarded = []; +} + +/** + * @property int $id + * @property int $parent_id + */ +class HasManyThroughCreateOrFirstTestPivotModel extends Model +{ + protected $table = 'pivot'; + protected $guarded = []; +} + +/** + * @property int $id + */ +class HasManyThroughCreateOrFirstTestParentModel extends Model +{ + protected $table = 'parent'; + protected $guarded = []; + + public function children(): HasManyThrough + { + return $this->hasManyThrough( + HasManyThroughCreateOrFirstTestChildModel::class, + HasManyThroughCreateOrFirstTestPivotModel::class, + 'parent_id', + 'pivot_id', + ); + } +} diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index 924cd17fc97b..bb9da590e7e0 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -195,6 +195,7 @@ public function testFirstOrCreateMethodCreatesNewMorphModel() $relation = $this->getOneRelation(); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); @@ -208,6 +209,7 @@ public function testFirstOrCreateMethodWithValuesCreatesNewMorphModel() $relation = $this->getOneRelation(); $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(fn ($scope) => $scope()); $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn($model = m::mock(Model::class)); $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); @@ -300,8 +302,10 @@ public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); $relation->getRelated()->shouldReceive('newInstance')->never(); + + $model->wasRecentlyCreated = false; $model->shouldReceive('setAttribute')->never(); - $model->shouldReceive('fill')->once()->with(['bar']); + $model->shouldReceive('fill')->once()->with(['bar'])->andReturn($model); $model->shouldReceive('save')->once(); $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); @@ -310,13 +314,17 @@ public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() public function testUpdateOrCreateMethodCreatesNewMorphModel() { $relation = $this->getOneRelation(); + $relation->getQuery()->shouldReceive('withSavepointIfNeeded')->once()->andReturnUsing(function ($scope) { + return $scope(); + }); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn(null); - $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo'])->andReturn($model = m::mock(Model::class)); + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo', 'bar'])->andReturn($model = m::mock(Model::class)); + + $model->wasRecentlyCreated = true; $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); $model->shouldReceive('save')->once()->andReturn(true); - $model->shouldReceive('fill')->once()->with(['bar']); $this->assertInstanceOf(Model::class, $relation->updateOrCreate(['foo'], ['bar'])); } From 7817052daa05542aecd171e881960736984d577f Mon Sep 17 00:00:00 2001 From: Pedro Oliveira <38913462+PH7-Jack@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:15:52 -0300 Subject: [PATCH 24/50] [10.x] allow resolving view from closure (#48719) * allow resolving view from closure * fix styling --- src/Illuminate/View/Component.php | 4 ++++ tests/View/ComponentTest.php | 37 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Illuminate/View/Component.php b/src/Illuminate/View/Component.php index 150129ca65f9..dcbe764c506b 100644 --- a/src/Illuminate/View/Component.php +++ b/src/Illuminate/View/Component.php @@ -143,6 +143,10 @@ public function resolveView() } $resolver = function ($view) { + if ($view instanceof ViewContract) { + return $view; + } + return $this->extractBladeViewFromString($view); }; diff --git a/tests/View/ComponentTest.php b/tests/View/ComponentTest.php index 18318f0038a5..a5ff36d82531 100644 --- a/tests/View/ComponentTest.php +++ b/tests/View/ComponentTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\View; +use Closure; use Illuminate\Config\Repository as Config; use Illuminate\Container\Container; use Illuminate\Contracts\Container\BindingResolutionException; @@ -72,6 +73,42 @@ public function testRegularViewsGetReturnedUsingViewHelper() $this->assertSame($view, $component->resolveView()); } + public function testRenderingStringClosureFromComponent() + { + $this->config->shouldReceive('get')->once()->with('view.compiled')->andReturn('/tmp'); + $this->viewFactory->shouldReceive('exists')->once()->andReturn(false); + $this->viewFactory->shouldReceive('addNamespace')->once()->with('__components', '/tmp'); + + $component = new class() extends Component + { + protected $title; + + public function __construct($title = 'World') + { + $this->title = $title; + } + + public function render() + { + return function (array $data) { + return "

Hello {$this->title}

"; + }; + } + }; + + $closure = $component->resolveView(); + + $viewPath = $closure([]); + + $this->viewFactory->shouldReceive('make')->with($viewPath, [], [])->andReturn('

Hello World

'); + + $this->assertInstanceOf(Closure::class, $closure); + $this->assertSame('__components::9cc08f5001b343c093ee1a396da820dc', $viewPath); + + $hash = str_replace('__components::', '', $viewPath); + $this->assertSame('

Hello World

', file_get_contents("/tmp/{$hash}.blade.php")); + } + public function testRegularViewsGetReturnedUsingViewMethod() { $view = m::mock(View::class); From 967dab6eada1c007b76602701267eb1fec8d9f97 Mon Sep 17 00:00:00 2001 From: Tim MacDonald Date: Sat, 14 Oct 2023 01:20:11 +1100 Subject: [PATCH 25/50] [10.x] Allow creation of PSR request with merged data (#48696) * Allow creation of PSR request with merged data * Add dependencies to require-dev --- composer.json | 6 +- .../Routing/RoutingServiceProvider.php | 6 +- .../Foundation/RoutingServiceProviderTest.php | 109 ++++++++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/Integration/Foundation/RoutingServiceProviderTest.php diff --git a/composer.json b/composer.json index 3e3ee6692170..a79770c3f587 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "symfony/console": "^6.2", "symfony/error-handler": "^6.2", "symfony/finder": "^6.2", - "symfony/http-foundation": "^6.2", + "symfony/http-foundation": "^6.3", "symfony/http-kernel": "^6.2", "symfony/mailer": "^6.2", "symfony/mime": "^6.2", @@ -104,13 +104,15 @@ "league/flysystem-read-only": "^3.3", "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.5.1", + "nyholm/psr7": "^1.2", "orchestra/testbench-core": "^8.12", "pda/pheanstalk": "^4.0", "phpstan/phpstan": "^1.4.7", "phpunit/phpunit": "^10.0.7", "predis/predis": "^2.0.2", "symfony/cache": "^6.2", - "symfony/http-client": "^6.2.4" + "symfony/http-client": "^6.2.4", + "symfony/psr-http-message-bridge": "^2.0" }, "provide": { "psr/container-implementation": "1.1|2.0", diff --git a/src/Illuminate/Routing/RoutingServiceProvider.php b/src/Illuminate/Routing/RoutingServiceProvider.php index 696e4ca4868d..0221839fc8df 100755 --- a/src/Illuminate/Routing/RoutingServiceProvider.php +++ b/src/Illuminate/Routing/RoutingServiceProvider.php @@ -137,8 +137,10 @@ protected function registerPsrRequest() if (class_exists(Psr17Factory::class) && class_exists(PsrHttpFactory::class)) { $psr17Factory = new Psr17Factory; - return (new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory)) - ->createRequest($app->make('request')); + return with((new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory)) + ->createRequest($illuminateRequest = $app->make('request')), fn ($request) => $request->withParsedBody( + array_merge($request->getParsedBody(), $illuminateRequest->getPayload()->all()) + )); } throw new BindingResolutionException('Unable to resolve PSR request. Please install the symfony/psr-http-message-bridge and nyholm/psr7 packages.'); diff --git a/tests/Integration/Foundation/RoutingServiceProviderTest.php b/tests/Integration/Foundation/RoutingServiceProviderTest.php new file mode 100644 index 000000000000..859b348eb774 --- /dev/null +++ b/tests/Integration/Foundation/RoutingServiceProviderTest.php @@ -0,0 +1,109 @@ +getParsedBody(); + })->middleware(MergeDataMiddleware::class); + + $response = $this->withoutExceptionHandling()->get('test-route?'.http_build_query([ + 'sent' => 'sent-data', + 'overridden' => 'overriden-sent-data', + ])); + + $response->assertOk(); + $response->assertExactJson([ + 'request-data' => 'request-data', + ]); + } + + public function testItIncludesMergedDataInServerRequestInterfaceInstancesUsingGetJsonRequests() + { + Route::get('test-route', function (ServerRequestInterface $request) { + return $request->getParsedBody(); + })->middleware(MergeDataMiddleware::class); + + $response = $this->getJson('test-route?'.http_build_query([ + 'sent' => 'sent-data', + 'overridden' => 'overriden-sent-data', + ])); + + $response->assertOk(); + $response->assertExactJson([ + 'json-data' => 'json-data', + 'merged' => 'replaced-merged-data', + 'overridden' => 'overriden-merged-data', + 'request-data' => 'request-data', + ]); + } + + public function testItIncludesMergedDataInServerRequestInterfaceInstancesUsingPostRequests() + { + Route::post('test-route', function (ServerRequestInterface $request) { + return $request->getParsedBody(); + })->middleware(MergeDataMiddleware::class); + + $response = $this->post('test-route', [ + 'sent' => 'sent-data', + 'overridden' => 'overriden-sent-data', + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'sent' => 'sent-data', + 'merged' => 'replaced-merged-data', + 'overridden' => 'overriden-merged-data', + 'request-data' => 'request-data', + ]); + } + + public function testItIncludesMergedDataInServerRequestInterfaceInstancesUsingPostJsonRequests() + { + Route::post('test-route', function (ServerRequestInterface $request) { + return $request->getParsedBody(); + })->middleware(MergeDataMiddleware::class); + + $response = $this->postJson('test-route', [ + 'sent' => 'sent-data', + 'overridden' => 'overriden-sent-data', + ]); + + $response->assertOk(); + $response->assertExactJson([ + 'json-data' => 'json-data', + 'sent' => 'sent-data', + 'merged' => 'replaced-merged-data', + 'overridden' => 'overriden-merged-data', + 'request-data' => 'request-data', + ]); + } +} + +class MergeDataMiddleware +{ + public function handle(Request $request, $next) + { + $request->merge(['merged' => 'first-merged-data']); + + $request->merge(['merged' => 'replaced-merged-data']); + + $request->merge(['overridden' => 'overriden-merged-data']); + + $request->request->set('request-data', 'request-data'); + + $request->query->set('query-data', 'query-data'); + + $request->json()->set('json-data', 'json-data'); + + return $next($request); + } +} From 0d23fb340e2d6b9b862a2e5d494cb6d1b784201d Mon Sep 17 00:00:00 2001 From: Saleh Hashemi <81674631+salehhashemi1992@users.noreply.github.com> Date: Mon, 16 Oct 2023 11:47:41 +0330 Subject: [PATCH 26/50] convertCase method doc block fix (#48729) --- src/Illuminate/Support/Str.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index c1cc95f2fbc9..da9de3b9f002 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -297,7 +297,7 @@ public static function containsAll($haystack, $needles, $ignoreCase = false) * * @param string $string * @param int $mode - * @param string $encoding + * @param string|null $encoding * @return string */ public static function convertCase(string $string, int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8') From 73ab11e114d7c36873c79b9f4981f3523b306d0b Mon Sep 17 00:00:00 2001 From: Ahmedul Haque Abid Date: Tue, 17 Oct 2023 02:03:33 +0600 Subject: [PATCH 27/50] [10.x] Use ValidationException class from Validator Property (#48736) * Use ValidationException class from Validator Property * Remove unused import * Update Validator.php --------- Co-authored-by: Taylor Otwell --- .../Foundation/Http/FormRequest.php | 5 +++-- .../Validation/ValidatesWhenResolvedTrait.php | 4 +++- src/Illuminate/Validation/Validator.php | 10 ++++++++++ tests/Validation/ValidationExceptionTest.php | 19 +++++++++++++++++-- 4 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Illuminate/Foundation/Http/FormRequest.php b/src/Illuminate/Foundation/Http/FormRequest.php index 2a88d9c5c4a4..772a2635a3b4 100644 --- a/src/Illuminate/Foundation/Http/FormRequest.php +++ b/src/Illuminate/Foundation/Http/FormRequest.php @@ -11,7 +11,6 @@ use Illuminate\Http\Request; use Illuminate\Routing\Redirector; use Illuminate\Validation\ValidatesWhenResolvedTrait; -use Illuminate\Validation\ValidationException; class FormRequest extends Request implements ValidatesWhenResolved { @@ -152,7 +151,9 @@ public function validationData() */ protected function failedValidation(Validator $validator) { - throw (new ValidationException($validator)) + $exception = $validator->getException(); + + throw (new $exception($validator)) ->errorBag($this->errorBag) ->redirectTo($this->getRedirectUrl()); } diff --git a/src/Illuminate/Validation/ValidatesWhenResolvedTrait.php b/src/Illuminate/Validation/ValidatesWhenResolvedTrait.php index 251f312bedbe..596585551d22 100644 --- a/src/Illuminate/Validation/ValidatesWhenResolvedTrait.php +++ b/src/Illuminate/Validation/ValidatesWhenResolvedTrait.php @@ -75,7 +75,9 @@ protected function passedValidation() */ protected function failedValidation(Validator $validator) { - throw new ValidationException($validator); + $exception = $validator->getException(); + + throw new $exception($validator); } /** diff --git a/src/Illuminate/Validation/Validator.php b/src/Illuminate/Validation/Validator.php index 4a7b5f99fe2d..579e82135282 100755 --- a/src/Illuminate/Validation/Validator.php +++ b/src/Illuminate/Validation/Validator.php @@ -1481,6 +1481,16 @@ public function setPresenceVerifier(PresenceVerifierInterface $presenceVerifier) $this->presenceVerifier = $presenceVerifier; } + /** + * Get the exception to throw upon failed validation. + * + * @return string + */ + public function getException() + { + return $this->exception; + } + /** * Set the exception to throw upon failed validation. * diff --git a/tests/Validation/ValidationExceptionTest.php b/tests/Validation/ValidationExceptionTest.php index 7fcf266ae3d0..2fbec79f5a53 100755 --- a/tests/Validation/ValidationExceptionTest.php +++ b/tests/Validation/ValidationExceptionTest.php @@ -87,11 +87,26 @@ public function testExceptionGetResponseOneError() $this->assertNull($exception->getResponse()); } + public function testGetExceptionClassFromValidator() + { + $validator = $this->getValidator(); + + $exception = $validator->getException(); + + $this->assertEquals(ValidationException::class, $exception); + } + protected function getException($data = [], $rules = []) { - $translator = new Translator(new ArrayLoader, 'en'); - $validator = new Validator($translator, $data, $rules); + $validator = $this->getValidator($data, $rules); return new ValidationException($validator); } + + protected function getValidator($data = [], $rules = []) + { + $translator = new Translator(new ArrayLoader, 'en'); + + return new Validator($translator, $data, $rules); + } } From f67c47516c3e7f5d381d3976b9819b20790484bc Mon Sep 17 00:00:00 2001 From: Saleh Hashemi <81674631+salehhashemi1992@users.noreply.github.com> Date: Mon, 16 Oct 2023 23:37:22 +0330 Subject: [PATCH 28/50] add convertCase tests (#48730) --- tests/Support/SupportStrTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 74469575bc05..6ae8a694d335 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -302,6 +302,29 @@ public function testStrContainsAll($haystack, $needles, $expected, $ignoreCase = $this->assertEquals($expected, Str::containsAll($haystack, $needles, $ignoreCase)); } + public function testConvertCase() + { + // Upper Case Conversion + $this->assertSame('HELLO', Str::convertCase('hello', MB_CASE_UPPER)); + $this->assertSame('WORLD', Str::convertCase('WORLD', MB_CASE_UPPER)); + + // Lower Case Conversion + $this->assertSame('hello', Str::convertCase('HELLO', MB_CASE_LOWER)); + $this->assertSame('world', Str::convertCase('WORLD', MB_CASE_LOWER)); + + // Case Folding + $this->assertSame('hello', Str::convertCase('HeLLo', MB_CASE_FOLD)); + $this->assertSame('world', Str::convertCase('WoRLD', MB_CASE_FOLD)); + + // Multi-byte String + $this->assertSame('ÜÖÄ', Str::convertCase('üöä', MB_CASE_UPPER, 'UTF-8')); + $this->assertSame('üöä', Str::convertCase('ÜÖÄ', MB_CASE_LOWER, 'UTF-8')); + + // Unsupported Mode + $this->expectException(\ValueError::class); + Str::convertCase('Hello', -1); + } + public function testParseCallback() { $this->assertEquals(['Class', 'method'], Str::parseCallback('Class@method', 'foo')); From cdcb9ca79b0a47c60eed35592a0e836611594f3b Mon Sep 17 00:00:00 2001 From: Saleh Hashemi <81674631+salehhashemi1992@users.noreply.github.com> Date: Mon, 16 Oct 2023 23:37:40 +0330 Subject: [PATCH 29/50] extend Str::take function tests (#48728) --- tests/Support/SupportStrTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 6ae8a694d335..813451447042 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -876,6 +876,11 @@ public function testTake() { $this->assertSame('ab', Str::take('abcdef', 2)); $this->assertSame('ef', Str::take('abcdef', -2)); + $this->assertSame('', Str::take('abcdef', 0)); + $this->assertSame('', Str::take('', 2)); + $this->assertSame('abcdef', Str::take('abcdef', 10)); + $this->assertSame('abcdef', Str::take('abcdef', 6)); + $this->assertSame('ü', Str::take('üöä', 1)); } public function testLcfirst() From 1bae4cdd1efe8339ac548108234a1802eb5664ea Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Mon, 16 Oct 2023 14:25:46 -0600 Subject: [PATCH 30/50] [10.x] Add `replaceMatches` to Str class (#48727) * add replaceMatches to Str class * Update Str.php --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Support/Str.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index da9de3b9f002..2d7d44ecbb74 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -1141,6 +1141,24 @@ public static function replaceEnd($search, $replace, $subject) return $subject; } + /** + * Replace the patterns matching the given regular expression. + * + * @param string $pattern + * @param \Closure|string $replace + * @param array|string $subject + * @param int $limit + * @return string|string[]|null + */ + public function replaceMatches($pattern, $replace, $subject, $limit = -1) + { + if ($replace instanceof Closure) { + return preg_replace_callback($pattern, $replace, $subject, $limit); + } + + return preg_replace($pattern, $replace, $subject, $limit); + } + /** * Remove any occurrence of the given string in the subject. * From 2f88a3072ccec7ad47f910ca079f6b7cbebbfe44 Mon Sep 17 00:00:00 2001 From: KentarouTakeda Date: Tue, 17 Oct 2023 05:27:14 +0900 Subject: [PATCH 31/50] [10.x] Fix duplicate conditions on retrying `SELECT` calls under `createOrFirst()` (#48725) * fix: duplicate conditions with `firstOrCreate` and `createOrFirst` * fix: missing mocks due to additional code --- src/Illuminate/Database/Eloquent/Builder.php | 2 +- .../Database/Eloquent/Relations/HasManyThrough.php | 2 +- .../Database/Eloquent/Relations/HasOneOrMany.php | 2 +- .../DatabaseEloquentBuilderCreateOrFirstTest.php | 6 ++---- .../DatabaseEloquentHasManyCreateOrFirstTest.php | 6 ++---- tests/Database/DatabaseEloquentHasManyTest.php | 4 +++- ...DatabaseEloquentHasManyThroughCreateOrFirstTest.php | 10 ++++------ tests/Database/DatabaseEloquentMorphTest.php | 4 +++- 8 files changed, 17 insertions(+), 19 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 6d502d6cd118..8ab8e055e77f 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -563,7 +563,7 @@ public function firstOrNew(array $attributes = [], array $values = []) */ public function firstOrCreate(array $attributes = [], array $values = []) { - if (! is_null($instance = $this->where($attributes)->first())) { + if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php index 2872bcdbd656..b0b4b1fdebe1 100644 --- a/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasManyThrough.php @@ -273,7 +273,7 @@ public function firstOrNew(array $attributes = [], array $values = []) */ public function firstOrCreate(array $attributes = [], array $values = []) { - if (! is_null($instance = $this->where($attributes)->first())) { + if (! is_null($instance = (clone $this)->where($attributes)->first())) { return $instance; } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 154dc0e4516f..af263baf854f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -235,7 +235,7 @@ public function firstOrNew(array $attributes = [], array $values = []) */ public function firstOrCreate(array $attributes = [], array $values = []) { - if (is_null($instance = $this->where($attributes)->first())) { + if (is_null($instance = (clone $this)->where($attributes)->first())) { $instance = $this->createOrFirst($attributes, $values); } diff --git a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php index 30ebf57e9b66..ea09aa234b91 100755 --- a/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentBuilderCreateOrFirstTest.php @@ -165,8 +165,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void $model->getConnection() ->expects('select') - // FIXME: duplicate conditions - ->with('select * from "table" where ("attr" = ?) and ("attr" = ?) limit 1', ['foo', 'foo'], false) + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) ->andReturn([[ 'id' => 123, 'attr' => 'foo', @@ -273,8 +272,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void $model->getConnection() ->expects('select') - // FIXME: duplicate conditions - ->with('select * from "table" where ("attr" = ?) and ("attr" = ?) limit 1', ['foo', 'foo'], false) + ->with('select * from "table" where ("attr" = ?) limit 1', ['foo'], false) ->andReturn([[ 'id' => 123, 'attr' => 'foo', diff --git a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php index e3b12fa52f31..09faed3f2654 100755 --- a/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyCreateOrFirstTest.php @@ -177,8 +177,7 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void $model->getConnection() ->expects('select') - // FIXME: duplicate conditions - ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) and ("attr" = ?) limit 1', [123, 'foo', 'foo'], false) + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) ->andReturn([[ 'id' => 456, 'parent_id' => 123, @@ -290,8 +289,7 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void $model->getConnection() ->expects('select') - // FIXME: duplicate conditions - ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) and ("attr" = ?) limit 1', [123, 'foo', 'foo'], false) + ->with('select * from "child_table" where "child_table"."parent_id" = ? and "child_table"."parent_id" is not null and ("attr" = ?) limit 1', [123, 'foo'], false) ->andReturn([[ 'id' => 456, 'parent_id' => 123, diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index caa8b1d8196a..eca4cefede25 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\UniqueConstraintViolationException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -343,7 +344,8 @@ public function testCreateManyCreatesARelatedModelForEachRecord() protected function getRelation() { - $builder = m::mock(Builder::class); + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); $related = m::mock(Model::class); diff --git a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php index dac5d821d56a..bb1142027c5a 100644 --- a/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php +++ b/tests/Database/DatabaseEloquentHasManyThroughCreateOrFirstTest.php @@ -196,10 +196,9 @@ public function testFirstOrCreateMethodRetrievesRecordCreatedJustNow(): void $parent->getConnection() ->expects('select') - // FIXME: duplicate conditions ->with( - 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) and ("attr" = ? and "val" = ?) limit 1', - [123, 'foo', 'foo', 'bar'], + 'select "child".*, "pivot"."parent_id" as "laravel_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'], true, ) ->andReturn([[ @@ -334,10 +333,9 @@ public function testUpdateOrCreateMethodUpdatesRecordCreatedJustNow(): void $parent->getConnection() ->expects('select') - // FIXME: duplicate conditions ->with( - 'select "child".*, "pivot"."parent_id" as "laravel_through_key" from "child" inner join "pivot" on "pivot"."id" = "child"."pivot_id" where "pivot"."parent_id" = ? and ("attr" = ?) and ("attr" = ? and "val" = ?) limit 1', - [123, 'foo', 'foo', 'bar'], + 'select "child".*, "pivot"."parent_id" as "laravel_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'], true, ) ->andReturn([[ diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index bb9da590e7e0..7804226dc885 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\UniqueConstraintViolationException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -443,7 +444,8 @@ public function testIsNotModelWithAnotherConnection() protected function getOneRelation() { - $builder = m::mock(Builder::class); + $queryBuilder = m::mock(QueryBuilder::class); + $builder = m::mock(Builder::class, [$queryBuilder]); $builder->shouldReceive('whereNotNull')->once()->with('table.morph_id'); $builder->shouldReceive('where')->once()->with('table.morph_id', '=', 1); $related = m::mock(Model::class); From 09096e8d284d0e5391743c615e7e07e574bdc90a Mon Sep 17 00:00:00 2001 From: Nuno Maduro Date: Wed, 18 Oct 2023 09:39:12 +0100 Subject: [PATCH 32/50] Uses `stefanzweifel/git-auto-commit-action@v5` --- .github/workflows/facades.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/facades.yml b/.github/workflows/facades.yml index cb786b3e42c5..2e246fa2e38c 100644 --- a/.github/workflows/facades.yml +++ b/.github/workflows/facades.yml @@ -81,7 +81,7 @@ jobs: Illuminate\\Support\\Facades\\Vite - name: Commit facade docblocks - uses: stefanzweifel/git-auto-commit-action@v4 + uses: stefanzweifel/git-auto-commit-action@v5 with: commit_message: Update facade docblocks file_pattern: src/ From 18de32cfe1cc0d085e6292f1bf65389199d2fec0 Mon Sep 17 00:00:00 2001 From: Nikolas Evers Date: Wed, 18 Oct 2023 15:54:15 +0200 Subject: [PATCH 33/50] fix typo in comment (#48770) --- src/Illuminate/Session/Middleware/AuthenticateSession.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Session/Middleware/AuthenticateSession.php b/src/Illuminate/Session/Middleware/AuthenticateSession.php index 7e96f683e0f6..43982374b456 100644 --- a/src/Illuminate/Session/Middleware/AuthenticateSession.php +++ b/src/Illuminate/Session/Middleware/AuthenticateSession.php @@ -111,7 +111,7 @@ protected function guard() } /** - * Get the path the user should be redirected to when their session is not autheneticated. + * Get the path the user should be redirected to when their session is not authenticated. * * @param \Illuminate\Http\Request $request * @return string|null From ada0a010d8dfed670e3e107696df1ecfd414e2d4 Mon Sep 17 00:00:00 2001 From: Julius Kiekbusch Date: Wed, 18 Oct 2023 15:54:35 +0200 Subject: [PATCH 34/50] Require DBAL 3 when installing (#48769) --- src/Illuminate/Database/Console/DatabaseInspectionCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Console/DatabaseInspectionCommand.php b/src/Illuminate/Database/Console/DatabaseInspectionCommand.php index 3cad6c2f697e..42568fc2c02d 100644 --- a/src/Illuminate/Database/Console/DatabaseInspectionCommand.php +++ b/src/Illuminate/Database/Console/DatabaseInspectionCommand.php @@ -224,7 +224,7 @@ protected function ensureDependenciesExist() protected function installDependencies() { $command = collect($this->composer->findComposer()) - ->push('require doctrine/dbal') + ->push('require doctrine/dbal:^3.5.1') ->implode(' '); $process = Process::fromShellCommandline($command, null, null, null, null); From 19c045403dc7a610a0424a3ae67654826c9a0fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stan=20Dani=C3=ABls?= <1199737+standaniels@users.noreply.github.com> Date: Wed, 18 Oct 2023 15:58:19 +0200 Subject: [PATCH 35/50] Escape the delimiter (#48765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stan Daniëls --- src/Illuminate/Support/Str.php | 2 +- tests/Support/SupportStrTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index 2d7d44ecbb74..3f9465035039 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -340,7 +340,7 @@ public static function excerpt($text, $phrase = '', $options = []) $radius = $options['radius'] ?? 100; $omission = $options['omission'] ?? '...'; - preg_match('/^(.*?)('.preg_quote((string) $phrase).')(.*)$/iu', (string) $text, $matches); + preg_match('/^(.*?)('.preg_quote((string) $phrase, '/').')(.*)$/iu', (string) $text, $matches); if (empty($matches)) { return null; diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 813451447042..231e9fa66599 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -203,6 +203,7 @@ public function testStrExcerpt() $this->assertSame('...ocê e...', Str::excerpt('Como você está', 'Ê', ['radius' => 2])); $this->assertSame('João...', Str::excerpt('João Antônio ', 'jo', ['radius' => 2])); $this->assertSame('João Antô...', Str::excerpt('João Antônio', 'JOÃO', ['radius' => 5])); + $this->assertNull(Str::excerpt('', '/')); } public function testStrBefore() From 8d8d5f7b413e271522e4559104eb1a5d062b4954 Mon Sep 17 00:00:00 2001 From: Hosmel Quintana Date: Wed, 18 Oct 2023 07:58:39 -0600 Subject: [PATCH 36/50] add missing static keyword (#48760) --- src/Illuminate/Support/Str.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Support/Str.php b/src/Illuminate/Support/Str.php index 3f9465035039..39cefe6054fd 100644 --- a/src/Illuminate/Support/Str.php +++ b/src/Illuminate/Support/Str.php @@ -1150,7 +1150,7 @@ public static function replaceEnd($search, $replace, $subject) * @param int $limit * @return string|string[]|null */ - public function replaceMatches($pattern, $replace, $subject, $limit = -1) + public static function replaceMatches($pattern, $replace, $subject, $limit = -1) { if ($replace instanceof Closure) { return preg_replace_callback($pattern, $replace, $subject, $limit); From 2dcc960d1e384471b9b9766f00f312cbd8d139a5 Mon Sep 17 00:00:00 2001 From: Rodrigo Pedra Brum Date: Wed, 18 Oct 2023 11:07:55 -0300 Subject: [PATCH 37/50] [10.x] Moves logger instance creation to a protected method (#48759) * moves logger instance creation to a protected method * formatting --------- Co-authored-by: Taylor Otwell --- src/Illuminate/Foundation/Exceptions/Handler.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index 301918317dd7..4d78a1a00f5d 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -302,7 +302,7 @@ protected function reportThrowable(Throwable $e): void } try { - $logger = $this->container->make(LoggerInterface::class); + $logger = $this->newLogger(); } catch (Exception) { throw $e; } @@ -872,4 +872,14 @@ protected function isHttpException(Throwable $e) { return $e instanceof HttpExceptionInterface; } + + /** + * Create a new logger instance. + * + * @return \Psr\Log\LoggerInterface + */ + protected function newLogger() + { + return $this->container->make(LoggerInterface::class); + } } From 2a4c30c47a7161049d7e2f2d71081fa16d884703 Mon Sep 17 00:00:00 2001 From: Trevor Gehman Date: Wed, 18 Oct 2023 09:12:13 -0500 Subject: [PATCH 38/50] Add command (#48751) --- src/Illuminate/Foundation/Application.php | 18 ++++++++++++++++++ src/Illuminate/Support/Facades/App.php | 1 + 2 files changed, 19 insertions(+) diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 61581c18e1d9..4d536cdb98b5 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -709,6 +709,24 @@ public function runningInConsole() return $this->isRunningInConsole; } + /** + * Determine if the application is running any of the given console commands. + * + * @param string|array ...$commands + * @return bool + */ + public function runningConsoleCommand(...$commands) + { + if (! $this->runningInConsole()) { + return false; + } + + return in_array( + $_SERVER['argv'][1] ?? null, + is_array($commands[0]) ? $commands[0] : $commands + ); + } + /** * Determine if the application is running unit tests. * diff --git a/src/Illuminate/Support/Facades/App.php b/src/Illuminate/Support/Facades/App.php index 80e300664331..a2b23eb49453 100755 --- a/src/Illuminate/Support/Facades/App.php +++ b/src/Illuminate/Support/Facades/App.php @@ -38,6 +38,7 @@ * @method static bool isProduction() * @method static string detectEnvironment(\Closure $callback) * @method static bool runningInConsole() + * @method static bool runningConsoleCommand(string|array ...$commands) * @method static bool runningUnitTests() * @method static bool hasDebugModeEnabled() * @method static void registerConfiguredProviders() From 10ba03e5541cceb66acf861ea56d7ce7a18c5078 Mon Sep 17 00:00:00 2001 From: Saleh Hashemi <81674631+salehhashemi1992@users.noreply.github.com> Date: Wed, 18 Oct 2023 17:44:53 +0330 Subject: [PATCH 39/50] Update @param annotation for wrap method to include Collection type (#48746) --- src/Illuminate/Http/Resources/Json/ResourceResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Http/Resources/Json/ResourceResponse.php b/src/Illuminate/Http/Resources/Json/ResourceResponse.php index 51f36576f0ae..430e41a72950 100644 --- a/src/Illuminate/Http/Resources/Json/ResourceResponse.php +++ b/src/Illuminate/Http/Resources/Json/ResourceResponse.php @@ -53,7 +53,7 @@ public function toResponse($request) /** * Wrap the given data if necessary. * - * @param array $data + * @param \Illuminate\Support\Collection|array $data * @param array $with * @param array $additional * @return array From 041aa190f710ee333f580b543821cded6c63e03d Mon Sep 17 00:00:00 2001 From: Saleh Hashemi <81674631+salehhashemi1992@users.noreply.github.com> Date: Wed, 18 Oct 2023 19:56:47 +0330 Subject: [PATCH 40/50] Add unit tests for replaceMatches method (#48771) --- tests/Support/SupportStringableTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index dc82faf86859..39506394a044 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -1187,6 +1187,21 @@ public function testStripTags() $this->assertSame('before
after', (string) $this->stringable('before
after')->stripTags('
')); } + public function testReplaceMatches() + { + $stringable = $this->stringable('Hello world!'); + $result = $stringable->replaceMatches('/world/', function ($match) { + return strtoupper($match[0]); + }); + + $this->assertSame('Hello WORLD!', $result->value); + + $stringable = $this->stringable('apple orange apple'); + $result = $stringable->replaceMatches('/apple/', 'fruit', 1); + + $this->assertSame('fruit orange apple', $result->value); + } + public function testScan() { $this->assertSame([123456], $this->stringable('SN/123456')->scan('SN/%d')->toArray()); From dda4e0f36e4cacdbee7e275c9998cfbf03b53661 Mon Sep 17 00:00:00 2001 From: Beau Simensen Date: Wed, 18 Oct 2023 11:31:27 -0500 Subject: [PATCH 41/50] [10.x] Do not bubble exceptions thrown rendering error view when debug is false (prevent infinite loops) (#48732) * Do not bubble exceptions thrown rendering error view when debug is false * Add failing tests * Only re-throw when debug mode it `true` * Manually report exception if debug mode is `false` * Remove unit tests * lint * Revert "Remove unit tests" This reverts commit f4222ef7648fcac2ceb9e41a1758aecf43ba5c4e. * Fix original unit tests * Fix paths for windows --------- Co-authored-by: Tim MacDonald --- .../Foundation/Exceptions/Handler.php | 14 ++++-- .../FoundationExceptionsHandlerTest.php | 45 ++++++++++++++++++- .../Foundation/ExceptionHandlerTest.php | 45 +++++++++++++++++++ .../MalformedErrorViews/errors/404.blade.php | 2 + .../MalformedErrorViews/errors/500.blade.php | 2 + 5 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 tests/Integration/Foundation/Fixtures/MalformedErrorViews/errors/404.blade.php create mode 100644 tests/Integration/Foundation/Fixtures/MalformedErrorViews/errors/500.blade.php diff --git a/src/Illuminate/Foundation/Exceptions/Handler.php b/src/Illuminate/Foundation/Exceptions/Handler.php index 4d78a1a00f5d..498be561b7dc 100644 --- a/src/Illuminate/Foundation/Exceptions/Handler.php +++ b/src/Illuminate/Foundation/Exceptions/Handler.php @@ -719,10 +719,16 @@ protected function renderHttpException(HttpExceptionInterface $e) $this->registerErrorViewPaths(); if ($view = $this->getHttpExceptionView($e)) { - return response()->view($view, [ - 'errors' => new ViewErrorBag, - 'exception' => $e, - ], $e->getStatusCode(), $e->getHeaders()); + try { + return response()->view($view, [ + 'errors' => new ViewErrorBag, + 'exception' => $e, + ], $e->getStatusCode(), $e->getHeaders()); + } catch (Throwable $t) { + config('app.debug') && throw $t; + + $this->report($t); + } } return $this->convertExceptionToResponse($e); diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index a804ecab2f10..93c7cda2443a 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -49,6 +49,8 @@ class FoundationExceptionsHandlerTest extends TestCase protected $config; + protected $viewFactory; + protected $container; protected $handler; @@ -59,14 +61,18 @@ protected function setUp(): void { $this->config = m::mock(Config::class); + $this->viewFactory = m::mock(ViewFactory::class); + $this->request = m::mock(stdClass::class); $this->container = Container::setInstance(new Container); $this->container->instance('config', $this->config); + $this->container->instance(ViewFactory::class, $this->viewFactory); + $this->container->instance(ResponseFactoryContract::class, new ResponseFactory( - m::mock(ViewFactory::class), + $this->viewFactory, m::mock(Redirector::class) )); @@ -397,6 +403,43 @@ public function getErrorView($e) $this->assertNull($handler->getErrorView(new HttpException(404))); } + private function executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs($debug) + { + $this->viewFactory->shouldReceive('exists')->once()->with('errors::404')->andReturn(true); + $this->viewFactory->shouldReceive('make')->once()->withAnyArgs()->andThrow(new Exception("Rendering this view throws an exception")); + + $this->config->shouldReceive('get')->with('app.debug', null)->andReturn($debug); + + $handler = new class($this->container) extends Handler + { + protected function registerErrorViewPaths() {} + + public function getErrorView($e) + { + return $this->renderHttpException($e); + } + }; + + $this->assertInstanceOf(SymfonyResponse::class, $handler->getErrorView(new HttpException(404))); + } + + public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugFalse() + { + // When debug is false, the exception thrown while rendering the error view + // should not bubble as this may trigger an infinite loop. + + } + + public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue() + { + // When debug is true, it is OK to bubble the exception thrown while rendering + // the error view as the debug handler should handle this gracefully. + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Rendering this view throws an exception"); + $this->executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(true); + } + public function testAssertExceptionIsThrown() { $this->assertThrows(function () { diff --git a/tests/Integration/Foundation/ExceptionHandlerTest.php b/tests/Integration/Foundation/ExceptionHandlerTest.php index 622b4348d2e8..b6c71b96e321 100644 --- a/tests/Integration/Foundation/ExceptionHandlerTest.php +++ b/tests/Integration/Foundation/ExceptionHandlerTest.php @@ -4,9 +4,12 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\Response; +use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Route; use Orchestra\Testbench\TestCase; use Symfony\Component\Process\PhpProcess; +use Throwable; class ExceptionHandlerTest extends TestCase { @@ -151,4 +154,46 @@ public static function exitCodesProvider() yield 'Throw exception' => [[Fixtures\Providers\ThrowUncaughtExceptionServiceProvider::class], false]; yield 'Do not throw exception' => [[Fixtures\Providers\ThrowExceptionServiceProvider::class], true]; } + + public function test_it_handles_malformed_error_views_in_production() + { + Config::set('view.paths', [__DIR__.'/Fixtures/MalformedErrorViews']); + Config::set('app.debug', false); + $reported = []; + $this->app[ExceptionHandler::class]->reportable(function (Throwable $e) use (&$reported) { + $reported[] = $e; + }); + + try { + $response = $this->get('foo'); + } catch (Throwable) { + $response ??= null; + } + + $this->assertCount(1, $reported); + $this->assertSame('Undefined variable $foo (View: '.__DIR__.DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR.'MalformedErrorViews'.DIRECTORY_SEPARATOR.'errors'.DIRECTORY_SEPARATOR.'404.blade.php)', $reported[0]->getMessage()); + $this->assertNotNull($response); + $response->assertStatus(404); + } + + public function test_it_handles_malformed_error_views_in_development() + { + Config::set('view.paths', [__DIR__.'/Fixtures/MalformedErrorViews']); + Config::set('app.debug', true); + $reported = []; + $this->app[ExceptionHandler::class]->reportable(function (Throwable $e) use (&$reported) { + $reported[] = $e; + }); + + try { + $response = $this->get('foo'); + } catch (Throwable) { + $response ??= null; + } + + $this->assertCount(1, $reported); + $this->assertSame('Undefined variable $foo (View: '.__DIR__.DIRECTORY_SEPARATOR.'Fixtures'.DIRECTORY_SEPARATOR.'MalformedErrorViews'.DIRECTORY_SEPARATOR.'errors'.DIRECTORY_SEPARATOR.'404.blade.php)', $reported[0]->getMessage()); + $this->assertNotNull($response); + $response->assertStatus(500); + } } diff --git a/tests/Integration/Foundation/Fixtures/MalformedErrorViews/errors/404.blade.php b/tests/Integration/Foundation/Fixtures/MalformedErrorViews/errors/404.blade.php new file mode 100644 index 000000000000..26b405f9717d --- /dev/null +++ b/tests/Integration/Foundation/Fixtures/MalformedErrorViews/errors/404.blade.php @@ -0,0 +1,2 @@ +My custom 404 + Date: Wed, 18 Oct 2023 16:31:51 +0000 Subject: [PATCH 42/50] Apply fixes from StyleCI --- tests/Foundation/FoundationExceptionsHandlerTest.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index 93c7cda2443a..ee4d42da088b 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -406,13 +406,15 @@ public function getErrorView($e) private function executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs($debug) { $this->viewFactory->shouldReceive('exists')->once()->with('errors::404')->andReturn(true); - $this->viewFactory->shouldReceive('make')->once()->withAnyArgs()->andThrow(new Exception("Rendering this view throws an exception")); + $this->viewFactory->shouldReceive('make')->once()->withAnyArgs()->andThrow(new Exception('Rendering this view throws an exception')); $this->config->shouldReceive('get')->with('app.debug', null)->andReturn($debug); $handler = new class($this->container) extends Handler { - protected function registerErrorViewPaths() {} + protected function registerErrorViewPaths() + { + } public function getErrorView($e) { @@ -427,7 +429,6 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugFalse() { // When debug is false, the exception thrown while rendering the error view // should not bubble as this may trigger an infinite loop. - } public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue() @@ -436,7 +437,7 @@ public function testItDoesNotCrashIfErrorViewThrowsWhileRenderingAndDebugTrue() // the error view as the debug handler should handle this gracefully. $this->expectException(\Exception::class); - $this->expectExceptionMessage("Rendering this view throws an exception"); + $this->expectExceptionMessage('Rendering this view throws an exception'); $this->executeScenarioWhereErrorViewThrowsWhileRenderingAndDebugIs(true); } From cee91ab69478ab8a23b7ed2467a188bbbdb6f51b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=ABl=20Hagestein?= <6616996+Neol3108@users.noreply.github.com> Date: Fri, 20 Oct 2023 12:10:54 +0200 Subject: [PATCH 43/50] Correct phpdoc for Grammar::setConnection (#48779) --- src/Illuminate/Database/Grammar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Grammar.php b/src/Illuminate/Database/Grammar.php index 0d2b0eff42a7..9ce0ec352595 100755 --- a/src/Illuminate/Database/Grammar.php +++ b/src/Illuminate/Database/Grammar.php @@ -281,7 +281,7 @@ public function setTablePrefix($prefix) /** * Set the grammar's database connection. * - * @param \Illuminate\Database\Connection $prefix + * @param \Illuminate\Database\Connection $connection * @return $this */ public function setConnection($connection) From 61ea0bc68c4f293194e3132b0bae8af01f41aec7 Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Sat, 21 Oct 2023 00:01:55 +1000 Subject: [PATCH 44/50] [10.x] Add `displayName` for queued Artisan commands (#48778) * Add `displayName` for queued Artisan commands * Match handle method --- .../Foundation/Console/QueuedCommand.php | 10 ++++++++++ .../Console/ConsoleApplicationTest.php | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Illuminate/Foundation/Console/QueuedCommand.php b/src/Illuminate/Foundation/Console/QueuedCommand.php index fb3d027b4b0a..43848cc263e3 100644 --- a/src/Illuminate/Foundation/Console/QueuedCommand.php +++ b/src/Illuminate/Foundation/Console/QueuedCommand.php @@ -39,4 +39,14 @@ public function handle(KernelContract $kernel) { $kernel->call(...array_values($this->data)); } + + /** + * Get the display name for the queued job. + * + * @return string + */ + public function displayName() + { + return array_values($this->data)[0]; + } } diff --git a/tests/Integration/Console/ConsoleApplicationTest.php b/tests/Integration/Console/ConsoleApplicationTest.php index 8d6a607aa46f..9daafb57c89a 100644 --- a/tests/Integration/Console/ConsoleApplicationTest.php +++ b/tests/Integration/Console/ConsoleApplicationTest.php @@ -5,6 +5,8 @@ use Illuminate\Console\Command; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Console\QueuedCommand; +use Illuminate\Support\Facades\Queue; use Orchestra\Testbench\TestCase; class ConsoleApplicationTest extends TestCase @@ -65,6 +67,19 @@ public function testArtisanInstantiateScheduleWhenNeed() $this->assertTrue($this->app->resolved(Schedule::class)); } + + public function testArtisanQueue() + { + Queue::fake(); + + $this->app[Kernel::class]->queue('foo:bar', [ + 'id' => 1, + ]); + + Queue::assertPushed(QueuedCommand::class, function ($job) { + return $job->displayName() === 'foo:bar'; + }); + } } class FooCommandStub extends Command From 63501945cd625cc2c809a37a0b56fa307e3fd212 Mon Sep 17 00:00:00 2001 From: Mior Muhammad Zaki Date: Tue, 24 Oct 2023 21:37:30 +0800 Subject: [PATCH 45/50] [10.x] Test Improvements (#48797) * [10.x] Test Improvements Remove deprecated `@test` usage and always prefix test method name with `test`. Signed-off-by: Mior Muhammad Zaki * Apply suggestions from code review --------- Signed-off-by: Mior Muhammad Zaki --- tests/Database/DatabaseEloquentIrregularPluralTest.php | 9 +++------ tests/Foundation/FoundationApplicationTest.php | 2 -- tests/Integration/Auth/ForgotPasswordTest.php | 9 +++------ .../Auth/ForgotPasswordWithoutDefaultRoutesTest.php | 9 +++------ .../Foundation/FoundationServiceProvidersTest.php | 3 +-- tests/Integration/Generators/FactoryMakeCommandTest.php | 1 - tests/Integration/Generators/ResourceMakeCommandTest.php | 6 ++---- tests/Support/SupportStrTest.php | 3 +-- 8 files changed, 13 insertions(+), 29 deletions(-) diff --git a/tests/Database/DatabaseEloquentIrregularPluralTest.php b/tests/Database/DatabaseEloquentIrregularPluralTest.php index 7d2ce9490b43..9ca4cedc4aa0 100644 --- a/tests/Database/DatabaseEloquentIrregularPluralTest.php +++ b/tests/Database/DatabaseEloquentIrregularPluralTest.php @@ -71,16 +71,14 @@ protected function schema() return $connection->getSchemaBuilder(); } - /** @test */ - public function itPluralizesTheTableName() + public function testItPluralizesTheTableName() { $model = new IrregularPluralHuman; $this->assertSame('irregular_plural_humans', $model->getTable()); } - /** @test */ - public function itTouchesTheParentWithAnIrregularPlural() + public function testItTouchesTheParentWithAnIrregularPlural() { Carbon::setTestNow('2018-05-01 12:13:14'); @@ -104,8 +102,7 @@ public function itTouchesTheParentWithAnIrregularPlural() $this->assertSame('2018-05-01 15:16:17', (string) $human->updated_at); } - /** @test */ - public function itPluralizesMorphToManyRelationships() + public function testItPluralizesMorphToManyRelationships() { $human = IrregularPluralHuman::create(['email' => 'bobby@example.com']); diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 8f1ce849d6a8..f23dc801f33d 100755 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -500,7 +500,6 @@ public function testEnvPathsAreAbsoluteInWindows() ); } - /** @test */ public function testMacroable(): void { $app = new Application; @@ -517,7 +516,6 @@ public function testMacroable(): void $this->assertFalse($app->foo()); } - /** @test */ public function testUseConfigPath(): void { $app = new Application; diff --git a/tests/Integration/Auth/ForgotPasswordTest.php b/tests/Integration/Auth/ForgotPasswordTest.php index 94766105ae78..46a682c229d8 100644 --- a/tests/Integration/Auth/ForgotPasswordTest.php +++ b/tests/Integration/Auth/ForgotPasswordTest.php @@ -43,8 +43,7 @@ protected function defineRoutes($router) })->name('custom.password.reset'); } - /** @test */ - public function it_can_send_forgot_password_email() + public function testItCanSendForgotPasswordEmail() { Notification::fake(); @@ -67,8 +66,7 @@ function (ResetPassword $notification, $channels) use ($user) { ); } - /** @test */ - public function it_can_send_forgot_password_email_via_create_url_using() + public function testItCanSendForgotPasswordEmailViaCreateUrlUsing() { Notification::fake(); @@ -95,8 +93,7 @@ function (ResetPassword $notification, $channels) use ($user) { ); } - /** @test */ - public function it_can_send_forgot_password_email_via_to_mail_using() + public function testItCanSendForgotPasswordEmailViaToMailUsing() { Notification::fake(); diff --git a/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php index 221601ac76f6..e34dcd78b82d 100644 --- a/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php +++ b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php @@ -39,8 +39,7 @@ protected function defineRoutes($router) })->name('custom.password.reset'); } - /** @test */ - public function it_cannot_send_forgot_password_email() + public function testItCannotSendForgotPasswordEmail() { $this->expectException('Symfony\Component\Routing\Exception\RouteNotFoundException'); $this->expectExceptionMessage('Route [password.reset] not defined.'); @@ -66,8 +65,7 @@ function (ResetPassword $notification, $channels) use ($user) { ); } - /** @test */ - public function it_can_send_forgot_password_email_via_create_url_using() + public function testItCanSendForgotPasswordEmailViaCreateUrlUsing() { Notification::fake(); @@ -94,8 +92,7 @@ function (ResetPassword $notification, $channels) use ($user) { ); } - /** @test */ - public function it_can_send_forgot_password_email_via_to_mail_using() + public function testItCanSendForgotPasswordEmailViaToMailUsing() { Notification::fake(); diff --git a/tests/Integration/Foundation/FoundationServiceProvidersTest.php b/tests/Integration/Foundation/FoundationServiceProvidersTest.php index bfc39cd44b3c..2a091b169192 100644 --- a/tests/Integration/Foundation/FoundationServiceProvidersTest.php +++ b/tests/Integration/Foundation/FoundationServiceProvidersTest.php @@ -12,8 +12,7 @@ protected function getPackageProviders($app) return [HeadServiceProvider::class]; } - /** @test */ - public function it_can_boot_service_provider_registered_from_another_service_provider() + public function testItCanBootServiceProviderRegisteredFromAnotherServiceProvider() { $this->assertTrue($this->app['tail.registered']); $this->assertTrue($this->app['tail.booted']); diff --git a/tests/Integration/Generators/FactoryMakeCommandTest.php b/tests/Integration/Generators/FactoryMakeCommandTest.php index 02ec76943c99..fccbe1fb0282 100644 --- a/tests/Integration/Generators/FactoryMakeCommandTest.php +++ b/tests/Integration/Generators/FactoryMakeCommandTest.php @@ -8,7 +8,6 @@ class FactoryMakeCommandTest extends TestCase 'database/factories/FooFactory.php', ]; - /** @test */ public function testItCanGenerateFactoryFile() { $this->artisan('make:factory', ['name' => 'FooFactory']) diff --git a/tests/Integration/Generators/ResourceMakeCommandTest.php b/tests/Integration/Generators/ResourceMakeCommandTest.php index b50b40872a85..eb1457a9b650 100644 --- a/tests/Integration/Generators/ResourceMakeCommandTest.php +++ b/tests/Integration/Generators/ResourceMakeCommandTest.php @@ -9,8 +9,7 @@ class ResourceMakeCommandTest extends TestCase 'app/Http/Resources/FooResourceCollection.php', ]; - /** @test */ - public function it_can_generate_resource_file() + public function testItCanGenerateResourceFile() { $this->artisan('make:resource', ['name' => 'FooResource']) ->assertExitCode(0); @@ -22,8 +21,7 @@ public function it_can_generate_resource_file() ], 'app/Http/Resources/FooResource.php'); } - /** @test */ - public function it_can_generate_resource_collection_file() + public function testItCanGenerateResourceCollectionFile() { $this->artisan('make:resource', ['name' => 'FooResourceCollection', '--collection' => true]) ->assertExitCode(0); diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 231e9fa66599..698ba8bba4ab 100755 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -533,8 +533,7 @@ public function testRandom() $this->assertIsString(Str::random()); } - /** @test */ - public function TestWhetherTheNumberOfGeneratedCharactersIsEquallyDistributed() + public function testWhetherTheNumberOfGeneratedCharactersIsEquallyDistributed() { $results = []; // take 6.200.000 samples, because there are 62 different characters From b52de33e9661e3b35c686269b7bdadd8b4484e4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20JEAN?= Date: Tue, 24 Oct 2023 15:38:00 +0200 Subject: [PATCH 46/50] Make inherited relations and virtual attributes appear in model:show command (#48800) --- src/Illuminate/Database/Console/ShowModelCommand.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Illuminate/Database/Console/ShowModelCommand.php b/src/Illuminate/Database/Console/ShowModelCommand.php index 8e9b5ac2177d..3ef912004e8c 100644 --- a/src/Illuminate/Database/Console/ShowModelCommand.php +++ b/src/Illuminate/Database/Console/ShowModelCommand.php @@ -7,6 +7,7 @@ use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Types\DecimalType; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; @@ -158,7 +159,7 @@ protected function getVirtualAttributes($model, $columns) ->reject( fn (ReflectionMethod $method) => $method->isStatic() || $method->isAbstract() - || $method->getDeclaringClass()->getName() !== get_class($model) + || $method->getDeclaringClass()->getName() === Model::class ) ->mapWithKeys(function (ReflectionMethod $method) use ($model) { if (preg_match('/^get(.+)Attribute$/', $method->getName(), $matches) === 1) { @@ -198,7 +199,7 @@ protected function getRelations($model) ->reject( fn (ReflectionMethod $method) => $method->isStatic() || $method->isAbstract() - || $method->getDeclaringClass()->getName() !== get_class($model) + || $method->getDeclaringClass()->getName() === Model::class ) ->filter(function (ReflectionMethod $method) { $file = new SplFileObject($method->getFileName()); From 2d002849a16ad131110a50cbea4d64dbb78515a3 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 24 Oct 2023 08:48:53 -0500 Subject: [PATCH 47/50] wip --- src/Illuminate/Foundation/Application.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 4d536cdb98b5..e87faa82f0dd 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -38,7 +38,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '10.28.0'; + const VERSION = '10.29.0'; /** * The base path for the Laravel installation. From d9003667018a8546a3cffc140415506ce20988fe Mon Sep 17 00:00:00 2001 From: driesvints Date: Tue, 24 Oct 2023 15:31:39 +0000 Subject: [PATCH 48/50] Update CHANGELOG --- CHANGELOG.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdbac3f4e11f..b1c6e8efbd1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,37 @@ # Release Notes for 10.x -## [Unreleased](https://github.com/laravel/framework/compare/v10.28.0...10.x) +## [Unreleased](https://github.com/laravel/framework/compare/v10.29.0...10.x) + +## [v10.29.0](https://github.com/laravel/framework/compare/v10.28.0...v10.29.0) - 2023-10-24 + +- [10.x] Fixes `Str::password()` does not always generate password with numbers by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/framework/pull/48681 +- [10.x] Fixes cache:prune-stale-tags preg_match delimiter no escaped by [@ame1973](https://github.com/ame1973) in https://github.com/laravel/framework/pull/48702 +- [10.x] Allow route:list to expand middleware groups in 'VeryVerbose' mode by [@NickSdot](https://github.com/NickSdot) in https://github.com/laravel/framework/pull/48703 +- [10.x] Fix model:prune command error with non-class php files by [@zlodes](https://github.com/zlodes) in https://github.com/laravel/framework/pull/48708 +- [10.x] Show CliDumper source content on last line by [@CalebDW](https://github.com/CalebDW) in https://github.com/laravel/framework/pull/48707 +- [10.x] Revival of the reverted changes in 10.25.0: `firstOrCreate` `updateOrCreate` improvement through `createOrFirst` + additional query tests by [@mpyw](https://github.com/mpyw) in https://github.com/laravel/framework/pull/48637 +- [10.x] allow resolving view from closure by [@PH7-Jack](https://github.com/PH7-Jack) in https://github.com/laravel/framework/pull/48719 +- [10.x] Allow creation of PSR request with merged data by [@timacdonald](https://github.com/timacdonald) in https://github.com/laravel/framework/pull/48696 +- [10.x] Update DocBlock for `convertCase` Method to Reflect Optional $encoding Parameter by [@salehhashemi1992](https://github.com/salehhashemi1992) in https://github.com/laravel/framework/pull/48729 +- [10.x] Use ValidationException class from Validator Property by [@a-h-abid](https://github.com/a-h-abid) in https://github.com/laravel/framework/pull/48736 +- [10.x] Implement Test Coverage for `Str::convertCase` Method by [@salehhashemi1992](https://github.com/salehhashemi1992) in https://github.com/laravel/framework/pull/48730 +- [10.x] Extend Test Coverage for `Str::take` Function by [@salehhashemi1992](https://github.com/salehhashemi1992) in https://github.com/laravel/framework/pull/48728 +- [10.x] Add `replaceMatches` to Str class by [@hosmelq](https://github.com/hosmelq) in https://github.com/laravel/framework/pull/48727 +- [10.x] Fix duplicate conditions on retrying `SELECT` calls under `createOrFirst()` by [@KentarouTakeda](https://github.com/KentarouTakeda) in https://github.com/laravel/framework/pull/48725 +- [10.x] Uses `stefanzweifel/git-auto-commit-action[@v5](https://github.com/v5)` by [@nunomaduro](https://github.com/nunomaduro) in https://github.com/laravel/framework/pull/48763 +- [10.x] fix typo in comment by [@vintagesucks](https://github.com/vintagesucks) in https://github.com/laravel/framework/pull/48770 +- [10.x] Require DBAL 3 when installing by [@Jubeki](https://github.com/Jubeki) in https://github.com/laravel/framework/pull/48769 +- [10.x] Escape the delimiter when extracting an excerpt from text by [@standaniels](https://github.com/standaniels) in https://github.com/laravel/framework/pull/48765 +- [10.x] Fix `replaceMatches` in Str class by [@hosmelq](https://github.com/hosmelq) in https://github.com/laravel/framework/pull/48760 +- [10.x] Moves logger instance creation to a protected method by [@rodrigopedra](https://github.com/rodrigopedra) in https://github.com/laravel/framework/pull/48759 +- [10.x] Add runningConsoleCommand(...$commands) method by [@trevorgehman](https://github.com/trevorgehman) in https://github.com/laravel/framework/pull/48751 +- [10.x] Update annotations in wrap method to accommodate Collection instances by [@salehhashemi1992](https://github.com/salehhashemi1992) in https://github.com/laravel/framework/pull/48746 +- [10.x] Add Tests for Str::replaceMatches Method by [@salehhashemi1992](https://github.com/salehhashemi1992) in https://github.com/laravel/framework/pull/48771 +- [10.x] Do not bubble exceptions thrown rendering error view when debug is false (prevent infinite loops) by [@simensen](https://github.com/simensen) in https://github.com/laravel/framework/pull/48732 +- [10.x] Correct phpdoc for Grammar::setConnection by [@Neol3108](https://github.com/Neol3108) in https://github.com/laravel/framework/pull/48779 +- [10.x] Add `displayName` for queued Artisan commands by [@jessarcher](https://github.com/jessarcher) in https://github.com/laravel/framework/pull/48778 +- [10.x] Test Improvements by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/framework/pull/48797 +- [10.x] Make inherited relations and virtual attributes appear in model:show command by [@sebj54](https://github.com/sebj54) in https://github.com/laravel/framework/pull/48800 ## [v10.28.0](https://github.com/laravel/framework/compare/v10.27.0...v10.28.0) - 2023-10-10 From 38e71835e2fe26db1ee1fb57308972674f11cf75 Mon Sep 17 00:00:00 2001 From: Mior Muhammad Zaki Date: Wed, 25 Oct 2023 22:29:20 +0800 Subject: [PATCH 49/50] [10.x] Test Improvements (#48815) * Test Improvements Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * Apply fixes from StyleCI * wip Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * Apply fixes from StyleCI * Update CacheMemcachedStoreTest.php * wip Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * wip Signed-off-by: Mior Muhammad Zaki * Apply fixes from StyleCI --------- Signed-off-by: Mior Muhammad Zaki Co-authored-by: StyleCI Bot --- tests/Auth/AuthPasswordBrokerTest.php | 23 ++---- tests/Cache/CacheMemcachedStoreTest.php | 7 +- ...DatabaseConcernsBuildsQueriesTraitTest.php | 6 +- tests/Database/DatabaseConnectionTest.php | 4 +- .../Testing/DatabaseMigrationsTest.php | 76 ++++++++++++++----- .../Testing/RefreshDatabaseTest.php | 76 ++++++++++++++----- .../CanConfigureMigrationCommandsTest.php | 2 +- tests/Queue/QueueDatabaseQueueUnitTest.php | 4 +- tests/Queue/QueueSqsJobTest.php | 23 ++---- 9 files changed, 141 insertions(+), 80 deletions(-) diff --git a/tests/Auth/AuthPasswordBrokerTest.php b/tests/Auth/AuthPasswordBrokerTest.php index f89971b4c7cf..f37b5e82dc40 100755 --- a/tests/Auth/AuthPasswordBrokerTest.php +++ b/tests/Auth/AuthPasswordBrokerTest.php @@ -22,12 +22,8 @@ protected function tearDown(): void public function testIfUserIsNotFoundErrorRedirectIsReturned() { $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class) - ->onlyMethods(['getUser']) - ->addMethods(['makeErrorRedirect']) - ->setConstructorArgs(array_values($mocks)) - ->getMock(); - $broker->expects($this->once())->method('getUser')->willReturn(null); + $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); + $broker->shouldReceive('getUser')->once()->andReturnNull(); $this->assertSame(PasswordBrokerContract::INVALID_USER, $broker->sendResetLink(['credentials'])); } @@ -35,7 +31,7 @@ public function testIfUserIsNotFoundErrorRedirectIsReturned() public function testIfTokenIsRecentlyCreated() { $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(true); $user->shouldReceive('sendPasswordResetNotification')->with('token'); @@ -65,7 +61,7 @@ public function testUserIsRetrievedByCredentials() public function testBrokerCreatesTokenAndRedirectsWithoutError() { $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(false); $mocks['tokens']->shouldReceive('create')->once()->with($user)->andReturn('token'); @@ -99,12 +95,9 @@ public function testRedirectReturnedByRemindWhenRecordDoesntExistInTable() public function testResetRemovesRecordOnReminderTableAndCallsCallback() { unset($_SERVER['__password.reset.test']); - $broker = $this->getMockBuilder(PasswordBroker::class) - ->onlyMethods(['validateReset']) - ->addMethods(['getPassword', 'getToken']) - ->setConstructorArgs(array_values($mocks = $this->getMocks())) - ->getMock(); - $broker->expects($this->once())->method('validateReset')->willReturn($user = m::mock(CanResetPassword::class)); + $mocks = $this->getMocks(); + $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial()->shouldAllowMockingProtectedMethods(); + $broker->shouldReceive('validateReset')->once()->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('delete')->once()->with($user); $callback = function ($user, $password) { $_SERVER['__password.reset.test'] = compact('user', 'password'); @@ -125,7 +118,7 @@ public function testExecutesCallbackInsteadOfSendingNotification() }; $mocks = $this->getMocks(); - $broker = $this->getMockBuilder(PasswordBroker::class)->addMethods(['emailResetLink', 'getUri'])->setConstructorArgs(array_values($mocks))->getMock(); + $broker = m::mock(PasswordBroker::class, array_values($mocks))->makePartial(); $mocks['users']->shouldReceive('retrieveByCredentials')->once()->with(['foo'])->andReturn($user = m::mock(CanResetPassword::class)); $mocks['tokens']->shouldReceive('recentlyCreatedToken')->once()->with($user)->andReturn(false); $mocks['tokens']->shouldReceive('create')->once()->with($user)->andReturn('token'); diff --git a/tests/Cache/CacheMemcachedStoreTest.php b/tests/Cache/CacheMemcachedStoreTest.php index 367a8ca1b655..1f9bf5ae8569 100755 --- a/tests/Cache/CacheMemcachedStoreTest.php +++ b/tests/Cache/CacheMemcachedStoreTest.php @@ -7,7 +7,6 @@ use Memcached; use Mockery as m; use PHPUnit\Framework\TestCase; -use stdClass; /** * @requires extension memcached @@ -23,7 +22,7 @@ protected function tearDown(): void public function testGetReturnsNullWhenNotFound() { - $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['get', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['get', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('get')->with($this->equalTo('foo:bar'))->willReturn(null); $memcache->expects($this->once())->method('getResultCode')->willReturn(1); $store = new MemcachedStore($memcache, 'foo'); @@ -32,7 +31,7 @@ public function testGetReturnsNullWhenNotFound() public function testMemcacheValueIsReturned() { - $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['get', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['get', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('get')->willReturn('bar'); $memcache->expects($this->once())->method('getResultCode')->willReturn(0); $store = new MemcachedStore($memcache); @@ -41,7 +40,7 @@ public function testMemcacheValueIsReturned() public function testMemcacheGetMultiValuesAreReturnedWithCorrectKeys() { - $memcache = $this->getMockBuilder(stdClass::class)->addMethods(['getMulti', 'getResultCode'])->getMock(); + $memcache = $this->getMockBuilder(Memcached::class)->onlyMethods(['getMulti', 'getResultCode'])->getMock(); $memcache->expects($this->once())->method('getMulti')->with( ['foo:foo', 'foo:bar', 'foo:baz'] )->willReturn([ diff --git a/tests/Database/DatabaseConcernsBuildsQueriesTraitTest.php b/tests/Database/DatabaseConcernsBuildsQueriesTraitTest.php index 55d03551a542..730b91855ff1 100644 --- a/tests/Database/DatabaseConcernsBuildsQueriesTraitTest.php +++ b/tests/Database/DatabaseConcernsBuildsQueriesTraitTest.php @@ -9,7 +9,11 @@ class DatabaseConcernsBuildsQueriesTraitTest extends TestCase { public function testTapCallbackInstance() { - $mock = $this->getMockForTrait(BuildsQueries::class); + $mock = new class + { + use BuildsQueries; + }; + $mock->tap(function ($builder) use ($mock) { $this->assertEquals($mock, $builder); }); diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 9ad3819a0057..b91db617a9b5 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -117,11 +117,11 @@ public function testSelectResultsetsReturnsMultipleRowset() $statement->expects($this->once())->method('bindValue')->with(1, 'foo', 2); $statement->expects($this->once())->method('execute'); $statement->expects($this->atLeastOnce())->method('fetchAll')->willReturn(['boom']); - $statement->expects($this->atLeastOnce())->method('nextRowset')->will($this->returnCallback(function () { + $statement->expects($this->atLeastOnce())->method('nextRowset')->willReturnCallback(function () { static $i = 1; return ++$i <= 2; - })); + }); $pdo->expects($this->once())->method('prepare')->with('CALL a_procedure(?)')->willReturn($statement); $mock = $this->getMockConnection(['prepareBindings'], $writePdo); $mock->setReadPdo($pdo); diff --git a/tests/Foundation/Testing/DatabaseMigrationsTest.php b/tests/Foundation/Testing/DatabaseMigrationsTest.php index 133a7f5fdd06..0d14f7909c22 100644 --- a/tests/Foundation/Testing/DatabaseMigrationsTest.php +++ b/tests/Foundation/Testing/DatabaseMigrationsTest.php @@ -2,33 +2,36 @@ namespace Illuminate\Tests\Foundation\Testing; -use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\Concerns\InteractsWithConsole; use Illuminate\Foundation\Testing\DatabaseMigrations; use Illuminate\Foundation\Testing\RefreshDatabaseState; use Mockery as m; +use Orchestra\Testbench\Concerns\Testing; +use Orchestra\Testbench\Foundation\Application as Testbench; use PHPUnit\Framework\TestCase; use ReflectionMethod; +use function Orchestra\Testbench\package_path; + class DatabaseMigrationsTest extends TestCase { protected $traitObject; protected function setUp(): void { - RefreshDatabaseState::$migrated = false; + $this->traitObject = m::mock(DatabaseMigrationsTestMockClass::class)->makePartial(); + $this->traitObject->setUp(); + } - $this->traitObject = $this->getMockForAbstractClass(DatabaseMigrationsTestMockClass::class, [], '', true, true, true, [ - 'artisan', - 'beforeApplicationDestroyed', - ]); + protected function tearDown(): void + { + $this->traitObject->tearDown(); - $kernelObj = m::mock(); - $kernelObj->shouldReceive('setArtisan') - ->with(null); + if ($container = m::getContainer()) { + $this->addToAssertionCount($container->mockery_getExpectationCount()); + } - $this->traitObject->app = [ - Kernel::class => $kernelObj, - ]; + m::close(); } private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) @@ -44,8 +47,8 @@ private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) public function testRefreshTestDatabaseDefault() { $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => false, '--drop-types' => false, @@ -62,8 +65,8 @@ public function testRefreshTestDatabaseWithDropViewsOption() $this->traitObject->dropViews = true; $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => true, '--drop-types' => false, @@ -80,8 +83,8 @@ public function testRefreshTestDatabaseWithDropTypesOption() $this->traitObject->dropTypes = true; $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => false, '--drop-types' => true, @@ -97,10 +100,43 @@ public function testRefreshTestDatabaseWithDropTypesOption() class DatabaseMigrationsTestMockClass { use DatabaseMigrations; - - public $app; + use InteractsWithConsole; + use Testing; public $dropViews = false; public $dropTypes = false; + + public function setUp() + { + RefreshDatabaseState::$migrated = false; + + $this->app = $this->refreshApplication(); + $this->withoutMockingConsoleOutput(); + } + + public function tearDown() + { + RefreshDatabaseState::$migrated = false; + + $this->callBeforeApplicationDestroyedCallbacks(); + $this->app?->flush(); + } + + protected function setUpTraits() + { + return []; + } + + protected function setUpTheTestEnvironmentTraitToBeIgnored(string $use): bool + { + return true; + } + + protected function refreshApplication() + { + return Testbench::create( + basePath: package_path('vendor/orchestra/testbench-core/laravel') + ); + } } diff --git a/tests/Foundation/Testing/RefreshDatabaseTest.php b/tests/Foundation/Testing/RefreshDatabaseTest.php index a5acc8275c85..5a15a5d4958e 100644 --- a/tests/Foundation/Testing/RefreshDatabaseTest.php +++ b/tests/Foundation/Testing/RefreshDatabaseTest.php @@ -2,13 +2,17 @@ namespace Illuminate\Tests\Foundation\Testing; -use Illuminate\Contracts\Console\Kernel; +use Illuminate\Foundation\Testing\Concerns\InteractsWithConsole; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabaseState; use Mockery as m; +use Orchestra\Testbench\Concerns\Testing; +use Orchestra\Testbench\Foundation\Application as Testbench; use PHPUnit\Framework\TestCase; use ReflectionMethod; +use function Orchestra\Testbench\package_path; + class RefreshDatabaseTest extends TestCase { protected $traitObject; @@ -17,18 +21,19 @@ protected function setUp(): void { RefreshDatabaseState::$migrated = false; - $this->traitObject = $this->getMockForAbstractClass(RefreshDatabaseTestMockClass::class, [], '', true, true, true, [ - 'artisan', - 'beginDatabaseTransaction', - ]); + $this->traitObject = m::mock(RefreshDatabaseTestMockClass::class)->makePartial(); + $this->traitObject->setUp(); + } + + protected function tearDown(): void + { + $this->traitObject->tearDown(); - $kernelObj = m::mock(); - $kernelObj->shouldReceive('setArtisan') - ->with(null); + if ($container = m::getContainer()) { + $this->addToAssertionCount($container->mockery_getExpectationCount()); + } - $this->traitObject->app = [ - Kernel::class => $kernelObj, - ]; + m::close(); } private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) @@ -44,8 +49,8 @@ private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) public function testRefreshTestDatabaseDefault() { $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => false, '--drop-types' => false, @@ -62,8 +67,8 @@ public function testRefreshTestDatabaseWithDropViewsOption() $this->traitObject->dropViews = true; $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => true, '--drop-types' => false, @@ -80,8 +85,8 @@ public function testRefreshTestDatabaseWithDropTypesOption() $this->traitObject->dropTypes = true; $this->traitObject - ->expects($this->once()) - ->method('artisan') + ->shouldReceive('artisan') + ->once() ->with('migrate:fresh', [ '--drop-views' => false, '--drop-types' => true, @@ -96,11 +101,44 @@ public function testRefreshTestDatabaseWithDropTypesOption() class RefreshDatabaseTestMockClass { + use InteractsWithConsole; use RefreshDatabase; - - public $app; + use Testing; public $dropViews = false; public $dropTypes = false; + + public function setUp() + { + RefreshDatabaseState::$migrated = false; + + $this->app = $this->refreshApplication(); + $this->withoutMockingConsoleOutput(); + } + + public function tearDown() + { + RefreshDatabaseState::$migrated = false; + + $this->callBeforeApplicationDestroyedCallbacks(); + $this->app?->flush(); + } + + protected function setUpTraits() + { + return []; + } + + protected function setUpTheTestEnvironmentTraitToBeIgnored(string $use): bool + { + return true; + } + + public function refreshApplication() + { + return Testbench::create( + basePath: package_path('vendor/orchestra/testbench-core/laravel') + ); + } } diff --git a/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php index 4a5a49696f1e..58f54a633931 100644 --- a/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php +++ b/tests/Foundation/Testing/Traits/CanConfigureMigrationCommandsTest.php @@ -12,7 +12,7 @@ class CanConfigureMigrationCommandsTest extends TestCase protected function setUp(): void { - $this->traitObject = $this->getMockForAbstractClass(CanConfigureMigrationCommandsTestMockClass::class); + $this->traitObject = new CanConfigureMigrationCommandsTestMockClass(); } private function __reflectAndSetupAccessibleForProtectedTraitMethod($methodName) diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index e4bfe062c018..17087db4e592 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -99,7 +99,7 @@ public function testFailureToCreatePayloadFromObject() $job = new stdClass; $job->invalid = "\xc3\x28"; - $queue = $this->getMockForAbstractClass(Queue::class); + $queue = m::mock(Queue::class)->makePartial(); $class = new ReflectionClass(Queue::class); $createPayload = $class->getMethod('createPayload'); @@ -113,7 +113,7 @@ public function testFailureToCreatePayloadFromArray() { $this->expectException('InvalidArgumentException'); - $queue = $this->getMockForAbstractClass(Queue::class); + $queue = m::mock(Queue::class)->makePartial(); $class = new ReflectionClass(Queue::class); $createPayload = $class->getMethod('createPayload'); diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php index bebe16b8209a..83706d769585 100644 --- a/tests/Queue/QueueSqsJobTest.php +++ b/tests/Queue/QueueSqsJobTest.php @@ -45,10 +45,7 @@ protected function setUp(): void $this->queueUrl = $this->baseUrl.'/'.$this->account.'/'.$this->queueName; // Get a mock of the SqsClient - $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->addMethods(['deleteMessage']) - ->disableOriginalConstructor() - ->getMock(); + $this->mockedSqsClient = m::mock(SqsClient::class)->makePartial(); // Use Mockery to mock the IoC Container $this->mockedContainer = m::mock(Container::class); @@ -83,27 +80,21 @@ public function testFireProperlyCallsTheJobHandler() public function testDeleteRemovesTheJobFromSqs() { - $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->addMethods(['deleteMessage']) - ->disableOriginalConstructor() - ->getMock(); - $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); + $this->mockedSqsClient = m::mock(SqsClient::class)->makePartial(); + $queue = m::mock(SqsQueue::class, [$this->mockedSqsClient, $this->queueName, $this->account])->makePartial(); $queue->setContainer($this->mockedContainer); $job = $this->getJob(); - $job->getSqs()->expects($this->once())->method('deleteMessage')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle]); + $job->getSqs()->shouldReceive('deleteMessage')->once()->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle]); $job->delete(); } public function testReleaseProperlyReleasesTheJobOntoSqs() { - $this->mockedSqsClient = $this->getMockBuilder(SqsClient::class) - ->addMethods(['changeMessageVisibility']) - ->disableOriginalConstructor() - ->getMock(); - $queue = $this->getMockBuilder(SqsQueue::class)->onlyMethods(['getQueue'])->setConstructorArgs([$this->mockedSqsClient, $this->queueName, $this->account])->getMock(); + $this->mockedSqsClient = m::mock(SqsClient::class)->makePartial(); + $queue = m::mock(SqsQueue::class, [$this->mockedSqsClient, $this->queueName, $this->account])->makePartial(); $queue->setContainer($this->mockedContainer); $job = $this->getJob(); - $job->getSqs()->expects($this->once())->method('changeMessageVisibility')->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle, 'VisibilityTimeout' => $this->releaseDelay]); + $job->getSqs()->shouldReceive('changeMessageVisibility')->once()->with(['QueueUrl' => $this->queueUrl, 'ReceiptHandle' => $this->mockedReceiptHandle, 'VisibilityTimeout' => $this->releaseDelay]); $job->release($this->releaseDelay); $this->assertTrue($job->isReleased()); } From 98ffb759b5ac5918aa26dac4676d8fb713c810ea Mon Sep 17 00:00:00 2001 From: Tim MacDonald Date: Thu, 26 Oct 2023 06:32:34 +1100 Subject: [PATCH 50/50] [10.x] Verify hash config (#48814) * Verify hash config * formatting --------- Co-authored-by: Taylor Otwell --- .../Eloquent/Concerns/HasAttributes.php | 15 +- src/Illuminate/Hashing/Argon2IdHasher.php | 13 +- src/Illuminate/Hashing/ArgonHasher.php | 52 +++- src/Illuminate/Hashing/BcryptHasher.php | 44 ++- .../EloquentModelHashedCastingTest.php | 289 ++++++++++++++++-- 5 files changed, 379 insertions(+), 34 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 4ef0f5314062..d4f86c52c2be 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -39,6 +39,7 @@ use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; +use RuntimeException; trait HasAttributes { @@ -1316,7 +1317,19 @@ public static function encryptUsing($encrypter) */ protected function castAttributeAsHashedString($key, $value) { - return $value !== null && ! Hash::isHashed($value) ? Hash::make($value) : $value; + if ($value === null) { + return null; + } + + if (! Hash::isHashed($value)) { + return Hash::make($value); + } + + if (! Hash::verifyConfiguration($value)) { + throw new RuntimeException("Could not verify the hashed value's configuration."); + } + + return $value; } /** diff --git a/src/Illuminate/Hashing/Argon2IdHasher.php b/src/Illuminate/Hashing/Argon2IdHasher.php index 9aca47ac9c71..f12f806e0654 100644 --- a/src/Illuminate/Hashing/Argon2IdHasher.php +++ b/src/Illuminate/Hashing/Argon2IdHasher.php @@ -18,7 +18,7 @@ class Argon2IdHasher extends ArgonHasher */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2id') { + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Argon2id algorithm.'); } @@ -29,6 +29,17 @@ public function check($value, $hashedValue, array $options = []) return password_verify($value, $hashedValue); } + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function isUsingCorrectAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'argon2id'; + } + /** * Get the algorithm that should be used for hashing. * diff --git a/src/Illuminate/Hashing/ArgonHasher.php b/src/Illuminate/Hashing/ArgonHasher.php index b999257f4b52..4cbb38b465ea 100644 --- a/src/Illuminate/Hashing/ArgonHasher.php +++ b/src/Illuminate/Hashing/ArgonHasher.php @@ -95,7 +95,7 @@ protected function algorithm() */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'argon2i') { + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Argon2i algorithm.'); } @@ -118,6 +118,56 @@ public function needsRehash($hashedValue, array $options = []) ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration($value) + { + return $this->isUsingCorrectAlgorithm($value) && $this->isUsingValidOptions($value); + } + + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function isUsingCorrectAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'argon2i'; + } + + /** + * Verify the hashed value's options. + * + * @param string $hashedValue + * @return bool + */ + protected function isUsingValidOptions($hashedValue) + { + ['options' => $options] = $this->info($hashedValue); + + if ( + ! is_int($options['memory_cost'] ?? null) || + ! is_int($options['time_cost'] ?? null) || + ! is_int($options['threads'] ?? null) + ) { + return false; + } + + if ( + $options['memory_cost'] > $this->memory || + $options['time_cost'] > $this->time || + $options['threads'] > $this->threads + ) { + return false; + } + + return true; + } + /** * Set the default password memory factor. * diff --git a/src/Illuminate/Hashing/BcryptHasher.php b/src/Illuminate/Hashing/BcryptHasher.php index f74edab88805..50b3859ed81c 100755 --- a/src/Illuminate/Hashing/BcryptHasher.php +++ b/src/Illuminate/Hashing/BcryptHasher.php @@ -67,7 +67,7 @@ public function make($value, array $options = []) */ public function check($value, $hashedValue, array $options = []) { - if ($this->verifyAlgorithm && $this->info($hashedValue)['algoName'] !== 'bcrypt') { + if ($this->verifyAlgorithm && ! $this->isUsingCorrectAlgorithm($hashedValue)) { throw new RuntimeException('This password does not use the Bcrypt algorithm.'); } @@ -88,6 +88,48 @@ public function needsRehash($hashedValue, array $options = []) ]); } + /** + * Verifies that the configuration is less than or equal to what is configured. + * + * @internal + */ + public function verifyConfiguration($value) + { + return $this->isUsingCorrectAlgorithm($value) && $this->isUsingValidOptions($value); + } + + /** + * Verify the hashed value's algorithm. + * + * @param string $hashedValue + * @return bool + */ + protected function isUsingCorrectAlgorithm($hashedValue) + { + return $this->info($hashedValue)['algoName'] === 'bcrypt'; + } + + /** + * Verify the hashed value's options. + * + * @param string $hashedValue + * @return bool + */ + protected function isUsingValidOptions($hashedValue) + { + ['options' => $options] = $this->info($hashedValue); + + if (! is_int($options['cost'] ?? null)) { + return false; + } + + if ($options['cost'] > $this->rounds) { + return false; + } + + return true; + } + /** * Set the default password work factor. * diff --git a/tests/Integration/Database/EloquentModelHashedCastingTest.php b/tests/Integration/Database/EloquentModelHashedCastingTest.php index df3402f8e46f..754d66b9a399 100644 --- a/tests/Integration/Database/EloquentModelHashedCastingTest.php +++ b/tests/Integration/Database/EloquentModelHashedCastingTest.php @@ -2,24 +2,14 @@ namespace Illuminate\Tests\Integration\Database; -use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Schema; +use RuntimeException; class EloquentModelHashedCastingTest extends DatabaseTestCase { - protected $hasher; - - protected function setUp(): void - { - parent::setUp(); - - $this->hasher = $this->mock(Hasher::class); - Hash::swap($this->hasher); - } - protected function defineDatabaseMigrationsAfterDatabaseRefreshed() { Schema::create('hashed_casts', function (Blueprint $table) { @@ -28,46 +18,150 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() }); } - public function testHashed() + public function testHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => 'password', + ]); + + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('2y', password_get_info($subject->password)['algo']); + $this->assertSame(13, password_get_info($subject->password)['options']['cost']); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => $subject->password, + ]); + } + + public function testNotHashedIfAlreadyHashedWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + + $this->assertSame('$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testNotHashedIfNullWithBrcypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + 'password' => null, + ]); + + $this->assertNull($subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => null, + ]); + } + + public function testPassingHashWithHigherCostThrowsExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 10); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 13 rounds; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingHashWithLowerCostDoesNotThrowExceptionWithBcrypt() + { + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $subject = HashedCast::create([ + // "password"; 7 rounds; bcrypt; + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + + $this->assertSame('$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$2y$07$Ivc2VnUOUFtfdbXFc/Ysu.PgiwAHkDmbZQNR1OpIjKCxTxEfWLP5y', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithBcrypt() { - $this->hasher->expects('isHashed') - ->with('this is a password') - ->andReturnFalse(); + Config::set('hashing.driver', 'bcrypt'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); - $this->hasher->expects('make') - ->with('this is a password') - ->andReturn('hashed-password'); + $subject = HashedCast::create([ + // "password"; argon2id; + 'password' => '$argon2i$v=19$m=1024,t=2,p=2$OENON0I5bXo2WDQyQnM2bg$3ma8cKHITsmAjyIYKDLdSvtkMCiEz/s6qWnLAf+Ehek', + ]); + } + + public function testHashedWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); $subject = HashedCast::create([ - 'password' => 'this is a password', + 'password' => 'password', ]); - $this->assertSame('hashed-password', $subject->password); + $this->assertTrue(password_verify('password', $subject->password)); + $this->assertSame('argon2i', password_get_info($subject->password)['algo']); + $this->assertSame(1234, password_get_info($subject->password)['options']['memory_cost']); + $this->assertSame(2, password_get_info($subject->password)['options']['threads']); + $this->assertSame(7, password_get_info($subject->password)['options']['time_cost']); $this->assertDatabaseHas('hashed_casts', [ 'id' => $subject->id, - 'password' => 'hashed-password', + 'password' => $subject->password, ]); } - public function testNotHashedIfAlreadyHashed() + public function testNotHashedIfAlreadyHashedWithArgon() { - $this->hasher->expects('isHashed') - ->with('already-hashed-password') - ->andReturnTrue(); + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); $subject = HashedCast::create([ - 'password' => 'already-hashed-password', + // "password"; 1234 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', ]); - $this->assertSame('already-hashed-password', $subject->password); + $this->assertSame('$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', $subject->password); $this->assertDatabaseHas('hashed_casts', [ 'id' => $subject->id, - 'password' => 'already-hashed-password', + 'password' => '$argon2i$v=19$m=1234,t=7,p=2$Lm9vSkJuU3M1SllaaTNwZA$5izrDfbWtpkSBH9EczQ8U1yjSOvAkhE4AuYrbBHwi5k', ]); } - public function testNotHashedIfNull() + public function testNotHashedIfNullWithArgon() { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + $subject = HashedCast::create([ 'password' => null, ]); @@ -78,6 +172,141 @@ public function testNotHashedIfNull() 'password' => null, ]); } + + public function testPassingHashWithHigherMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithHigherTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 2 threads; 8 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=8,p=2$LmszcGVHd0t6b3JweUxqTQ$sdY25X0Qe86fezr1cEjYQxAHI2SdN67yVs5x0ovffag', + ]); + } + + public function testPassingHashWithHigherThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 1234); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 1234 memory; 3 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=1234,t=7,p=3$OFludXF6bzFpRmdpSHdwSA$J1P4dCGJde6mYe2RZEOFWaztBbDWfxQAM09ZQRMjsw8', + ]); + } + + public function testPassingHashWithLowerMemoryThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 3456); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerTimeThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 8); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingHashWithLowerThreadsThrowsExceptionWithArgon() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 3); + Config::set('hashing.argon.time', 7); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + + $this->assertSame('$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', $subject->password); + $this->assertDatabaseHas('hashed_casts', [ + 'id' => $subject->id, + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgonAndBcrypt() + { + Config::set('hashing.driver', 'argon'); + Config::set('hashing.bcrypt.rounds', 13); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; bcrypt; + 'password' => '$2y$13$Hdxlvi7OZqK3/fKVNypJs.vJqQcmOo3HnnT6w7fec9FRTRYxAhuCO', + ]); + } + + public function testPassingDifferentHashAlgorithmThrowsExceptionWithArgon2idAndBcrypt() + { + Config::set('hashing.driver', 'argon2id'); + Config::set('hashing.argon.memory', 2345); + Config::set('hashing.argon.threads', 2); + Config::set('hashing.argon.time', 7); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not verify the hashed value's configuration."); + + $subject = HashedCast::create([ + // "password"; 2345 memory; 2 threads; 7 time; argon2i; + 'password' => '$argon2i$v=19$m=2345,t=7,p=2$MWVVZnpiZHl5RkcveHovcA$QECQzuQ2aAKvUpD25cTUJaAyPFxlOIsCRu+5nbDsU3k', + ]); + } } class HashedCast extends Model