diff --git a/src/Illuminate/Database/Eloquent/Concerns/DeferRouteBinding.php b/src/Illuminate/Database/Eloquent/Concerns/DeferRouteBinding.php new file mode 100644 index 000000000000..83caaf723cb7 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/DeferRouteBinding.php @@ -0,0 +1,240 @@ +defer($value, $field, false); + + return $this; + } + + public function resolveSoftDeletableRouteBinding($value, $field = null) + { + $this->defer($value, $field, true); + + return $this; + } + + public function resolveChildRouteBinding($childType, $value, $field) + { + return $this->deferScoped($childType, $value, $field, false); + } + + public function resolveSoftDeletableChildRouteBinding($childType, $value, $field) + { + return $this->deferScoped($childType, $value, $field, true); + } + + public function __invoke(): static + { + if ($this->deferredInit !== null) { + $deferredInit = $this->deferredInit; + $this->deferredInit = null; + parent::__construct(); + $model = $deferredInit(); + $this->exists = true; + $this->setRawAttributes((array) $model->attributes, true); + $this->setConnection($model->connection); + $this->fireModelEvent('retrieved', false); + $this->deferredInitResolved = true; + } + + return $this; + } + + public function __get($key) + { + $this(); + + return parent::__get($key); + } + + public function __set($key, $value) + { + $this(); + + parent::__set($key, $value); + } + + public function __call($method, $parameters) + { + $this(); + + return parent::__call($method, $parameters); + } + + public function __isset($key) + { + $this(); + + return parent::__isset($key); + } + + public function __unset($key) + { + $this(); + + parent::__unset($key); + } + + public function __toString() + { + $this(); + + return parent::__toString(); + } + + public function toArray() + { + $this(); + + return parent::toArray(); + } + + public function toJson($options = 0) + { + $this(); + + return parent::toJson($options); + } + + public function jsonSerialize(): mixed + { + $this(); + + return parent::jsonSerialize(); + } + + public function update(array $attributes = [], array $options = []) + { + $this(); + + return parent::update($attributes, $options); + } + + public function updateOrFail(array $attributes = [], array $options = []) + { + $this(); + + return parent::updateOrFail($attributes, $options); + } + + public function updateQuietly(array $attributes = [], array $options = []) + { + $this(); + + return parent::updateQuietly($attributes, $options); + } + + public function delete() + { + $this(); + + return parent::delete(); + } + + public function deleteOrFail() + { + $this(); + + return parent::deleteOrFail(); + } + + public function deleteQuietly() + { + $this(); + + return parent::deleteQuietly(); + } + + public function save(array $options = []) + { + $this(); + + return parent::save($options); + } + + public function saveOrFail(array $options = []) + { + $this(); + + return parent::saveOrFail($options); + } + + public function saveQuietly(array $options = []) + { + $this(); + + return parent::saveQuietly($options); + } + + public function deferred(object $deferredInit): void + { + $this->deferredInitResolved = false; + $this->deferredInit = $deferredInit; + } + + protected function defer(mixed $value, ?string $field, bool $withTrashed): void + { + $this->deferredInitResolved = false; + + $closure = $withTrashed + ? fn () => $this->resolveRouteBindingQuery($this, $value, $field)->withTrashed()->firstOrFail() + : fn () => $this->resolveRouteBindingQuery($this, $value, $field)->firstOrFail(); + + $this->deferredInit = new class('resolveRouteBindingQuery', $value, $field, $closure) + { + public function __construct(public string $method, public mixed $value, public ?string $field, public Closure $closure) + { + } + + public function __invoke() + { + return ($this->closure)(); + } + }; + } + + protected function deferScoped(string $childType, mixed $value, ?string $field, bool $withTrashed): Model + { + /** @var Relation $relationship */ + $relationship = $this->{$this->childRouteBindingRelationshipName($childType)}(); + + $child = $relationship->getModel(); + + if (! isset(\trait_uses_recursive($child)[DeferRouteBinding::class])) { + return parent::resolveChildRouteBindingQuery($childType, $value, $field)->first(); + } + + $closure = ! $withTrashed + ? fn () => $this->resolveChildRouteBindingQuery($childType, $value, $field)->firstOrFail() + : fn () => $this->resolveChildRouteBindingQuery($childType, $value, $field)->withTrashed()->firstOrFail(); + + $deferredInit = new class('resolveChildRouteBindingQuery', $value, $field, $withTrashed, $closure) + { + public function __construct(public string $method, public mixed $value, public ?string $field, public bool $withTrashed, public Closure $closure) + { + } + + public function __invoke() + { + return ($this->closure)(); + } + }; + + $child->deferred($deferredInit); + + return $child; + } +} diff --git a/tests/Database/DatabaseConcernsDeferRouteBindingTest.php b/tests/Database/DatabaseConcernsDeferRouteBindingTest.php new file mode 100644 index 000000000000..2bfe7835b160 --- /dev/null +++ b/tests/Database/DatabaseConcernsDeferRouteBindingTest.php @@ -0,0 +1,403 @@ +singleton(Generator::class, function ($app, $parameters) { + return \Faker\Factory::create('en_US'); + }); + + $db = new DB; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema('default')->create('users', function ($table) { + $table->id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + parent::tearDown(); + + foreach (['default'] as $connection) { + $this->schema($connection)->drop('users'); + } + + Relation::morphMap([], false); + Eloquent::unsetConnectionResolver(); + + \Illuminate\Support\Carbon::setTestNow(null); + } + + public function testExplicitlyResolvesTheModel() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $lazy(); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'name' => $user->name, + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => $user->getRawOriginal('updated_at'), + 'deleted_at' => null, + 'id' => 1, + ], $this->getProtectedProperty($lazy, 'attributes')); + } + + public function testImplicitlyResolvesTheModelOnPropertyAccess() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $lazy->name; + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => $user->name, + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => $user->getRawOriginal('updated_at'), + 'deleted_at' => null, + ], $this->getProtectedProperty($lazy, 'attributes')); + } + + public function testImplicitlyResolvesTheModelOnPropertyWrite() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $lazy->name = 'Test User Changed'; + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => 'Test User Changed', + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => $user->getRawOriginal('updated_at'), + 'deleted_at' => null, + ], $this->getProtectedProperty($lazy, 'attributes')); + } + + public function testImplicitlyResolvesTheModelOnToArray() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $array = $lazy->toArray(); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => $user->name, + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->created_at->format('Y-m-d\TH:i:s.u\Z'), + 'updated_at' => $user->updated_at->format('Y-m-d\TH:i:s.u\Z'), + 'deleted_at' => null, + ], $array); + } + + public function testImplicitlyResolvesTheModelOnToJson() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $json = $lazy->toJson(); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals(json_encode([ + 'id' => $user->internal_id, + 'name' => $user->name, + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + 'deleted_at' => null, + ]), $json); + } + + public function testImplicitlyResolvesTheModelOnJsonEncode() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $json = json_encode($lazy); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals(json_encode([ + 'id' => $user->internal_id, + 'name' => $user->name, + 'email' => $user->email, + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + 'deleted_at' => null, + ]), $json); + } + + #[DataProvider('updateMethodsProvider')] + public function testImplicitlyResolvesTheModelOnUpdate(string $method) + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + Carbon::setTestNow(now()->addSecond()); + + $lazy->{$method}([ + 'name' => 'Test User Updated', + 'email' => 'davey@php.net', + ]); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => 'Test User Updated', + 'email' => 'davey@php.net', + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => now()->format('Y-m-d H:i:s'), + 'deleted_at' => null, + ], $this->getProtectedProperty($lazy, 'attributes')); + } + + public static function updateMethodsProvider(): array + { + return [ + ['update'], + ['updateQuietly'], + ['updateOrFail'], + ]; + } + + #[DataProvider('deleteMethodsProvider')] + public function testImplicitlyResolvesTheModelOnDelete(string $method) + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + $lazy->{$method}(); + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => $user->getRawOriginal('name'), + 'email' => $user->getRawOriginal('email'), + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => now()->format('Y-m-d H:i:s'), + 'deleted_at' => now()->format('Y-m-d H:i:s'), + ], $this->getProtectedProperty($lazy, 'attributes')); + $this->assertEquals(0, LazilyResolved::count()); + } + + public static function deleteMethodsProvider(): array + { + return [ + ['delete'], + ['deleteQuietly'], + ['deleteOrFail'], + ]; + } + + public function testImplicitlyResolvesTheModelOnToString() + { + $user = UserFactory::new()->create(); + + $model = new LazilyResolved; + $lazy = $model->resolveRouteBinding($user->internal_id); + + $this->assertInstanceOf(LazilyResolved::class, $lazy); + $this->assertTrue((new ReflectionClass($this->getProtectedProperty($lazy, 'deferredInit')))->isAnonymous()); + $this->assertFalse($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertCount(0, $this->getProtectedProperty($lazy, 'attributes')); + + (string) $lazy; + + $this->assertNull($this->getProtectedProperty($lazy, 'deferredInit')); + $this->assertTrue($this->getProtectedProperty($lazy, 'deferredInitResolved')); + $this->assertEquals([ + 'id' => $user->internal_id, + 'name' => $user->getRawOriginal('name'), + 'email' => $user->getRawOriginal('email'), + 'email_verified_at' => $user->getRawOriginal('email_verified_at'), + 'password' => $user->getRawOriginal('password'), + 'remember_token' => $user->getRawOriginal('remember_token'), + 'created_at' => $user->getRawOriginal('created_at'), + 'updated_at' => now()->format('Y-m-d H:i:s'), + 'deleted_at' => null, + ], $this->getProtectedProperty($lazy, 'attributes')); + } + + /** + * Helpers... + */ + + /** + * Access a protected property of an object. + * + * @return mixed + */ + protected function getProtectedProperty(object $object, string $property) + { + $closure = function () use ($property) { + return $this->{$property}; + }; + + return $closure->bindTo($object, $object)(); + } + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} diff --git a/tests/Database/Fixtures/Factories/UserFactory.php b/tests/Database/Fixtures/Factories/UserFactory.php new file mode 100644 index 000000000000..45355a84e754 --- /dev/null +++ b/tests/Database/Fixtures/Factories/UserFactory.php @@ -0,0 +1,20 @@ + $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'password' => $this->faker->password(), + ]; + } +} diff --git a/tests/Database/Fixtures/Models/LazilyResolved.php b/tests/Database/Fixtures/Models/LazilyResolved.php new file mode 100644 index 000000000000..5367f503872c --- /dev/null +++ b/tests/Database/Fixtures/Models/LazilyResolved.php @@ -0,0 +1,23 @@ +