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'])); }