From 09a87808f1403ff4d904ea6ef97c672002505b02 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 16:27:36 -0300 Subject: [PATCH 01/26] Adds more services to the docker-compose.yml for ease local testing --- docker-compose.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 5de7c4c8cf80..0a3b1697128b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,32 @@ services: ports: - "3306:3306" restart: always + # postgres: + # image: postgres:15 + # environment: + # POSTGRES_PASSWORD: "secret" + # POSTGRES_DB: "forge" + # ports: + # - "5432:5432" + # restart: always + # mariadb: + # image: mariadb:11 + # environment: + # MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" + # MARIADB_ROOT_PASSWORD: "" + # MARIADB_DATABASE: "forge" + # MARIADB_ROOT_HOST: "%" + # ports: + # - "3306:3306" + # restart: always + # mssql: + # image: mcr.microsoft.com/mssql/server:2019-latest + # environment: + # ACCEPT_EULA: "Y" + # SA_PASSWORD: "Forge123" + # ports: + # - "1433:1433" + # restart: always redis: image: redis:7.0-alpine ports: From 5404089827570776848f4f7aa105cb2004730be9 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 16:29:20 -0300 Subject: [PATCH 02/26] Adds createOrFirst method to the query builder --- src/Illuminate/Database/Eloquent/Builder.php | 46 +++++++++++++ .../DatabaseEloquentIntegrationTest.php | 48 +++++++++++++ .../DatabaseEloquentMySqlIntegrationTest.php | 68 +++++++++++++++++++ ...atabaseEloquentPostgresIntegrationTest.php | 68 +++++++++++++++++++ ...tabaseEloquentSqlServerIntegrationTest.php | 68 +++++++++++++++++++ 5 files changed, 298 insertions(+) create mode 100644 tests/Database/DatabaseEloquentMySqlIntegrationTest.php create mode 100644 tests/Database/DatabaseEloquentPostgresIntegrationTest.php create mode 100644 tests/Database/DatabaseEloquentSqlServerIntegrationTest.php diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 5bab008e13da..32c5f5976e38 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\QueryException; use Illuminate\Database\RecordsNotFoundException; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; @@ -571,6 +572,21 @@ public function firstOrCreate(array $attributes = [], array $values = []) }); } + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return tap($this->newModelInstance(array_merge($attributes, $values)), function (Model $instance) { + $instance->save(); + }); + } catch (QueryException $exception) { + if (! $this->matchesUniqueConstraintException($exception)) { + throw $exception; + } + + return $this->where($attributes)->first(); + } + } + /** * Create or update a record matching the attributes, and fill it with values. * @@ -1984,6 +2000,36 @@ protected static function registerMixin($mixin, $replace) } } + /** + * Checks if the QueryException was caused by a UNIQUE constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(QueryException $exception) + { + // SQLite3 + if (preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())) { + return true; + } + + // MySQL + if (preg_match('#SQLSTATE\[23000\]: Integrity constraint violation: 1062#i', $exception->getMessage())) { + return true; + } + + // PostgreSQL + if (preg_match('#SQLSTATE\[23505\]#i', $exception->getMessage())) { + return true; + } + + // SQLServer + if (preg_match('#SQLSTATE\[23000\]:.*Cannot insert duplicate key row in object.*#i', $exception->getMessage())) { + return true; + } + + return false; + } + /** * Clone the Eloquent query builder. * diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index a7225b2bb1ed..aa34e8d3b172 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -88,6 +88,14 @@ protected function createSchema() $table->timestamps(); }); + $this->schema($connection)->create('unique_users', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamp('birthday', 6)->nullable(); + $table->timestamps(); + }); + $this->schema($connection)->create('friends', function ($table) { $table->integer('user_id'); $table->integer('friend_id'); @@ -511,6 +519,39 @@ public function testFirstOrCreate() $this->assertSame('Nuno Maduro', $user4->name); } + public function testCreateOrFirst() + { + $user1 = EloquentTestUniqueUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = EloquentTestUniqueUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = EloquentTestUniqueUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } + public function testUpdateOrCreate() { $user1 = EloquentTestUser::create(['email' => 'taylorotwell@gmail.com']); @@ -2243,6 +2284,13 @@ public static function boot() } } +class EloquentTestUniqueUser extends Eloquent +{ + protected $table = 'unique_users'; + protected $casts = ['birthday' => 'datetime']; + protected $guarded = []; +} + class EloquentTestPost extends Eloquent { protected $table = 'posts'; diff --git a/tests/Database/DatabaseEloquentMySqlIntegrationTest.php b/tests/Database/DatabaseEloquentMySqlIntegrationTest.php new file mode 100644 index 000000000000..501bb159b395 --- /dev/null +++ b/tests/Database/DatabaseEloquentMySqlIntegrationTest.php @@ -0,0 +1,68 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_mysql_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentMySqlIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentMySqlIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentMySqlIntegrationUser extends Model +{ + protected $table = 'database_eloquent_mysql_integration_users'; + + protected $guarded = []; +} diff --git a/tests/Database/DatabaseEloquentPostgresIntegrationTest.php b/tests/Database/DatabaseEloquentPostgresIntegrationTest.php new file mode 100644 index 000000000000..6489bcc5c959 --- /dev/null +++ b/tests/Database/DatabaseEloquentPostgresIntegrationTest.php @@ -0,0 +1,68 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_postgres_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentPostgresIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentPostgresIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentPostgresIntegrationUser extends Model +{ + protected $table = 'database_eloquent_postgres_integration_users'; + + protected $guarded = []; +} diff --git a/tests/Database/DatabaseEloquentSqlServerIntegrationTest.php b/tests/Database/DatabaseEloquentSqlServerIntegrationTest.php new file mode 100644 index 000000000000..fed06ecc5082 --- /dev/null +++ b/tests/Database/DatabaseEloquentSqlServerIntegrationTest.php @@ -0,0 +1,68 @@ +id(); + $table->string('name')->nullable(); + $table->string('email')->unique(); + $table->timestamps(); + }); + } + } + + protected function destroyDatabaseMigrations() + { + Schema::drop('database_eloquent_sql_server_integration_users'); + } + + public function testCreateOrFirst() + { + $user1 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst(['email' => 'taylorotwell@gmail.com']); + + $this->assertSame('taylorotwell@gmail.com', $user1->email); + $this->assertNull($user1->name); + + $user2 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['email' => 'taylorotwell@gmail.com'], + ['name' => 'Taylor Otwell'] + ); + + $this->assertEquals($user1->id, $user2->id); + $this->assertSame('taylorotwell@gmail.com', $user2->email); + $this->assertNull($user2->name); + + $user3 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['email' => 'abigailotwell@gmail.com'], + ['name' => 'Abigail Otwell'] + ); + + $this->assertNotEquals($user3->id, $user1->id); + $this->assertSame('abigailotwell@gmail.com', $user3->email); + $this->assertSame('Abigail Otwell', $user3->name); + + $user4 = DatabaseEloquentSqlServerIntegrationUser::createOrFirst( + ['name' => 'Dries Vints'], + ['name' => 'Nuno Maduro', 'email' => 'nuno@laravel.com'] + ); + + $this->assertSame('Nuno Maduro', $user4->name); + } +} + +class DatabaseEloquentSqlServerIntegrationUser extends Model +{ + protected $table = 'database_eloquent_sql_server_integration_users'; + + protected $guarded = []; +} From 61bc4eb28b72c53d21b97d0cf55d36b7c0acffe3 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 17:30:06 -0300 Subject: [PATCH 03/26] Adds createOrFirst to the BelongsToMany relation --- src/Illuminate/Database/Eloquent/Builder.php | 33 +--------- .../Concerns/InteractsWithQueryException.php | 38 +++++++++++ .../Eloquent/Relations/BelongsToMany.php | 44 ++++++++++++- .../Database/EloquentBelongsToManyTest.php | 63 +++++++++++++++++++ 4 files changed, 146 insertions(+), 32 deletions(-) create mode 100644 src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 32c5f5976e38..2f1906b427ee 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -9,6 +9,7 @@ use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; +use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Concerns\QueriesRelationships; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; @@ -31,7 +32,7 @@ */ class Builder implements BuilderContract { - use BuildsQueries, ForwardsCalls, QueriesRelationships { + use BuildsQueries, ForwardsCalls, InteractsWithQueryException, QueriesRelationships { BuildsQueries::sole as baseSole; } @@ -2000,36 +2001,6 @@ protected static function registerMixin($mixin, $replace) } } - /** - * Checks if the QueryException was caused by a UNIQUE constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(QueryException $exception) - { - // SQLite3 - if (preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())) { - return true; - } - - // MySQL - if (preg_match('#SQLSTATE\[23000\]: Integrity constraint violation: 1062#i', $exception->getMessage())) { - return true; - } - - // PostgreSQL - if (preg_match('#SQLSTATE\[23505\]#i', $exception->getMessage())) { - return true; - } - - // SQLServer - if (preg_match('#SQLSTATE\[23000\]:.*Cannot insert duplicate key row in object.*#i', $exception->getMessage())) { - return true; - } - - return false; - } - /** * Clone the Eloquent query builder. * diff --git a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php new file mode 100644 index 000000000000..9062be3eb8c0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php @@ -0,0 +1,38 @@ +getMessage())) { + return true; + } + + // MySQL + if (preg_match('#SQLSTATE\[23000\]: Integrity constraint violation: 1062#i', $exception->getMessage())) { + return true; + } + + // PostgreSQL + if (preg_match('#SQLSTATE\[23505\]#i', $exception->getMessage())) { + return true; + } + + // SQLServer + if (preg_match('#SQLSTATE\[23000\]:.*Cannot insert duplicate key row in object.*#i', $exception->getMessage())) { + return true; + } + + return false; + } +} diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index a6422b6855e6..88e27ebd81df 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -6,17 +6,19 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; +use Illuminate\Database\QueryException; use Illuminate\Support\Str; use InvalidArgumentException; class BelongsToMany extends Relation { - use InteractsWithDictionary, InteractsWithPivotTable; + use InteractsWithDictionary, InteractsWithPivotTable, InteractsWithQueryException; /** * The intermediate table for the relation. @@ -630,6 +632,46 @@ public function firstOrCreate(array $attributes = [], array $values = [], array return $instance; } + /** + * Attempts to create the related record and return the first match if a unique constraint happens. + * + * @param array $attributes + * @param array $values + * @param array $joining + * @param bool $touch + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true) + { + // First, we're goint to attempt to create the related record + // which also attempts to attach it to the current record. + + try { + return $this->create(array_merge($attributes, $values), $joining, $touch); + } catch (QueryException $exception) { + if (! $this->matchesUniqueConstraintException($exception)) { + throw $exception; + } + } + + // Then, we'll assume the related record already exists, so we + // we only attempt to attach it to the current record. + + try { + $instance = $this->related->where($attributes)->firstOrFail(); + + $this->attach($instance, $joining, $touch); + + return $instance; + } catch (QueryException $exception) { + if (! $this->matchesUniqueConstraintException($exception)) { + throw $exception; + } + + return (clone $this)->where($attributes)->first(); + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 156ce67bab64..ca4b15894613 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -45,6 +45,13 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->timestamps(); }); + Schema::create('unique_tags', function (Blueprint $table) { + $table->increments('id'); + $table->string('name')->unique(); + $table->string('type')->nullable(); + $table->timestamps(); + }); + Schema::create('users_posts', function (Blueprint $table) { $table->increments('id'); $table->string('user_uuid'); @@ -60,6 +67,14 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->string('flag')->default('')->nullable(); $table->timestamps(); }); + + Schema::create('posts_unique_tags', function (Blueprint $table) { + $table->integer('post_id'); + $table->integer('tag_id')->default(0); + $table->string('tag_name')->default('')->nullable(); + $table->string('flag')->default('')->nullable(); + $table->timestamps(); + }); } public function testBasicCreateAndRetrieve() @@ -586,6 +601,34 @@ public function testFirstOrCreateUnrelatedExisting() $this->assertTrue($tag->is($post->tags()->first())); } + public function testCreateOrFirst() + { + $post = Post::create(['title' => Str::random()]); + + $tag = UniqueTag::create(['name' => Str::random()]); + + $post->tagsUnique()->attach(UniqueTag::all()); + + $this->assertEquals($tag->id, $post->tagsUnique()->createOrFirst(['name' => $tag->name])->id); + + $new = $post->tagsUnique()->createOrFirst(['name' => 'wavez']); + $this->assertSame('wavez', $new->name); + $this->assertNotNull($new->id); + } + + public function testCreateOrFirstUnrelatedExisting() + { + $post = Post::create(['title' => Str::random()]); + + $name = Str::random(); + $tag = UniqueTag::create(['name' => $name]); + + $postTag = $post->tagsUnique()->createOrFirst(['name' => $name]); + $this->assertTrue($postTag->exists); + $this->assertTrue($postTag->is($tag)); + $this->assertTrue($tag->is($post->tagsUnique()->first())); + } + public function testFirstOrNewMethodWithValues() { $post = Post::create(['title' => Str::random()]); @@ -1330,6 +1373,14 @@ public function tags() ->wherePivot('flag', '<>', 'exclude'); } + public function tagsUnique() + { + return $this->belongsToMany(UniqueTag::class, 'posts_unique_tags', 'post_id', 'tag_id') + ->withPivot('flag') + ->withTimestamps() + ->wherePivot('flag', '<>', 'exclude'); + } + public function tagsWithExtraPivot() { return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') @@ -1393,6 +1444,18 @@ public function posts() } } +class UniqueTag extends Model +{ + public $table = 'unique_tags'; + public $timestamps = true; + protected $fillable = ['name', 'type']; + + public function posts() + { + return $this->belongsToMany(Post::class, 'posts_unique_tags', 'tag_id', 'post_id'); + } +} + class TouchingTag extends Model { public $table = 'tags'; From 698e3a6f8ecf132eb997957e9f2868a92dfea4dd Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 17:40:21 -0300 Subject: [PATCH 04/26] Adds createOrFirst to HasOneOrMany relation --- .../Eloquent/Relations/HasOneOrMany.php | 24 ++++++++++++++- .../Database/EloquentHasManyTest.php | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 488d966ef112..caba69c5798b 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -4,12 +4,14 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\QueryException; abstract class HasOneOrMany extends Relation { - use InteractsWithDictionary; + use InteractsWithDictionary, InteractsWithQueryException; /** * The foreign key of the parent model. @@ -241,6 +243,26 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } + /** + * Attempt to create the record or find the first one matching the attributes if a unique constraint violation happens. + * + * @param array $attributes + * @param array $values + * @return \Illuminate\Database\Eloquent\Model + */ + public function createOrFirst(array $attributes = [], array $values = []) + { + try { + return $this->create(array_merge($attributes, $values)); + } catch (QueryException $exception) { + if (! $this->matchesUniqueConstraintException($exception)) { + throw $exception; + } + + return $this->where($attributes)->first(); + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * diff --git a/tests/Integration/Database/EloquentHasManyTest.php b/tests/Integration/Database/EloquentHasManyTest.php index e0592e02d0d6..ac8e9b21446e 100644 --- a/tests/Integration/Database/EloquentHasManyTest.php +++ b/tests/Integration/Database/EloquentHasManyTest.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class EloquentHasManyTest extends DatabaseTestCase { @@ -15,6 +16,13 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->id(); }); + Schema::create('eloquent_has_many_test_posts', function ($table) { + $table->id(); + $table->foreignId('eloquent_has_many_test_user_id'); + $table->string('title')->unique(); + $table->timestamps(); + }); + Schema::create('eloquent_has_many_test_logins', function ($table) { $table->id(); $table->foreignId('eloquent_has_many_test_user_id'); @@ -51,6 +59,17 @@ public function testHasOneRelationshipFromHasMany() $this->assertEquals($oldestLogin->id, $user->oldestLogin->id); $this->assertEquals($latestLogin->id, $user->latestLogin->id); } + + public function testCreateOrFirst() + { + $user = EloquentHasManyTestUser::create(); + + $post1 = $user->posts()->createOrFirst(['title' => Str::random()]); + $post2 = $user->posts()->createOrFirst(['title' => $post1->title]); + + $this->assertTrue($post1->is($post2)); + $this->assertCount(1, $user->posts()->get()); + } } class EloquentHasManyTestUser extends Model @@ -72,6 +91,11 @@ public function oldestLogin(): HasOne { return $this->logins()->one()->oldestOfMany('login_time'); } + + public function posts(): HasMany + { + return $this->hasMany(EloquentHasManyTestPost::class); + } } class EloquentHasManyTestLogin extends Model @@ -79,3 +103,8 @@ class EloquentHasManyTestLogin extends Model protected $guarded = []; public $timestamps = false; } + +class EloquentHasManyTestPost extends Model +{ + protected $guarded = []; +} From 53580c0788f141bef10adf816f06f70ffa13ccd5 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 17:44:02 -0300 Subject: [PATCH 05/26] Test createOrFirst using with casts --- .../DatabaseEloquentWithCastsTest.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index ecc6ccb419a2..53fe7d449d70 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -32,6 +32,12 @@ protected function createSchema() $table->time('time'); $table->timestamps(); }); + + $this->schema()->create('unique_times', function ($table) { + $table->increments('id'); + $table->time('time')->unique(); + $table->timestamps(); + }); } public function testWithFirstOrNew() @@ -59,6 +65,17 @@ public function testWithFirstOrCreate() $this->assertSame($time1->id, $time2->id); } + public function testWithCreateOrFirst() + { + $time1 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $time2 = UniqueTime::query()->withCasts(['time' => 'string']) + ->createOrFirst(['time' => '07:30']); + + $this->assertSame($time1->id, $time2->id); + } + /** * Get a database connection instance. * @@ -88,3 +105,12 @@ class Time extends Eloquent 'time' => 'datetime', ]; } + +class UniqueTime extends Eloquent +{ + protected $guarded = []; + + protected $casts = [ + 'time' => 'datetime', + ]; +} From 8c84693beae69adae94254576e39d17f14b38a14 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 17:55:05 -0300 Subject: [PATCH 06/26] Test createOrFirst with enum casting --- .../Database/EloquentModelEnumCastingTest.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Integration/Database/EloquentModelEnumCastingTest.php b/tests/Integration/Database/EloquentModelEnumCastingTest.php index c0a47fbf8ce8..b835aeb05d56 100644 --- a/tests/Integration/Database/EloquentModelEnumCastingTest.php +++ b/tests/Integration/Database/EloquentModelEnumCastingTest.php @@ -25,6 +25,11 @@ protected function defineDatabaseMigrationsAfterDatabaseRefreshed() $table->json('integer_status_array')->nullable(); $table->string('arrayable_status')->nullable(); }); + + Schema::create('unique_enum_casts', function (Blueprint $table) { + $table->increments('id'); + $table->string('string_status', 100)->unique(); + }); } public function testEnumsAreCastable() @@ -264,6 +269,26 @@ public function testFirstOrCreate() $this->assertEquals(StringStatus::pending, $model->string_status); $this->assertEquals(StringStatus::done, $model2->string_status); } + + public function testCreateOrFirst() + { + $model1 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model2 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::pending, + ]); + + $model3 = EloquentModelEnumCastingUniqueTestModel::createOrFirst([ + 'string_status' => StringStatus::done, + ]); + + $this->assertEquals(StringStatus::pending, $model1->string_status); + $this->assertEquals(StringStatus::pending, $model2->string_status); + $this->assertTrue($model1->is($model2)); + $this->assertEquals(StringStatus::done, $model3->string_status); + } } class EloquentModelEnumCastingTestModel extends Model @@ -282,3 +307,14 @@ class EloquentModelEnumCastingTestModel extends Model 'arrayable_status' => ArrayableStatus::class, ]; } + +class EloquentModelEnumCastingUniqueTestModel extends Model +{ + public $timestamps = false; + protected $guarded = []; + protected $table = 'unique_enum_casts'; + + public $casts = [ + 'string_status' => StringStatus::class, + ]; +} From 5a40c599cca2409aed36c0d826806e1ff251737e Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 17:58:12 -0300 Subject: [PATCH 07/26] Test createOrFirst with SoftDeletes models --- .../DatabaseEloquentSoftDeletesIntegrationTest.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php index 47c67d78c2bf..8af1eeaf5aed 100644 --- a/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php +++ b/tests/Database/DatabaseEloquentSoftDeletesIntegrationTest.php @@ -320,6 +320,20 @@ public function testFirstOrCreate() $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); } + public function testCreateOrFirst() + { + $this->createUsers(); + + $result = SoftDeletesTestUser::withTrashed()->createOrFirst(['email' => 'taylorotwell@gmail.com']); + $this->assertSame('taylorotwell@gmail.com', $result->email); + $this->assertCount(1, SoftDeletesTestUser::all()); + + $result = SoftDeletesTestUser::createOrFirst(['email' => 'foo@bar.com']); + $this->assertSame('foo@bar.com', $result->email); + $this->assertCount(2, SoftDeletesTestUser::all()); + $this->assertCount(3, SoftDeletesTestUser::withTrashed()->get()); + } + /** * @throws \Exception */ From ae2d3bcd77b2c40a9f4bc6a4f5fc8f5fb4af3785 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 18:29:46 -0300 Subject: [PATCH 08/26] Adds test for the DatabaseElqouentHasManyTest suite --- .../Database/DatabaseEloquentHasManyTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index 24c302584381..29b0dec89e7d 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -2,10 +2,12 @@ namespace Illuminate\Tests\Database; +use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\QueryException; use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; @@ -168,6 +170,45 @@ public function testFirstOrCreateMethodWithValuesCreatesNewModelWithForeignKeySe $this->assertEquals($model, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); } + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getRelation(); + + $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn(m::mock(Model::class, function ($model) { + $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); + $model->shouldReceive('save')->once()->andThrow( + new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + })); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(stdClass::class)); + + $this->assertInstanceOf(stdClass::class, $found = $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + $this->assertSame($model, $found); + } + + public function testCreateOrFirstMethodCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo']); + + $this->assertEquals($model, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewModelWithForeignKeySet() + { + $relation = $this->getRelation(); + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + $model = $this->expectCreatedModel($relation, ['foo' => 'bar', 'baz' => 'qux']); + + $this->assertEquals($model, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() { $relation = $this->getRelation(); From e7ef541028128f1f95c6a56cd415385fd0ce04ab Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 18:31:58 -0300 Subject: [PATCH 09/26] Adds createOrRestore scope to soft-deleting models --- .../Database/Eloquent/SoftDeletingScope.php | 19 +++++++++++++++- .../DatabaseSoftDeletingScopeTest.php | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php index e6d91d91786b..f0b0bd417925 100644 --- a/src/Illuminate/Database/Eloquent/SoftDeletingScope.php +++ b/src/Illuminate/Database/Eloquent/SoftDeletingScope.php @@ -9,7 +9,7 @@ class SoftDeletingScope implements Scope * * @var string[] */ - protected $extensions = ['Restore', 'RestoreOrCreate', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; + protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; /** * Apply the scope to a given Eloquent query builder. @@ -91,6 +91,23 @@ protected function addRestoreOrCreate(Builder $builder) }); } + /** + * Add the create-or-restore extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return void + */ + protected function addCreateOrRestore(Builder $builder) + { + $builder->macro('createOrRestore', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + /** * Add the with-trashed extension to the builder. * diff --git a/tests/Database/DatabaseSoftDeletingScopeTest.php b/tests/Database/DatabaseSoftDeletingScopeTest.php index d7563c402b0b..adf03ac6dc3c 100644 --- a/tests/Database/DatabaseSoftDeletingScopeTest.php +++ b/tests/Database/DatabaseSoftDeletingScopeTest.php @@ -72,6 +72,28 @@ public function testRestoreOrCreateExtension() $this->assertEquals($model, $result); } + public function testCreateOrRestoreExtension() + { + $builder = new EloquentBuilder(new BaseBuilder( + m::mock(ConnectionInterface::class), + m::mock(Grammar::class), + m::mock(Processor::class) + )); + + $scope = new SoftDeletingScope; + $scope->extend($builder); + $callback = $builder->getMacro('createOrRestore'); + $givenBuilder = m::mock(EloquentBuilder::class); + $givenBuilder->shouldReceive('withTrashed')->once(); + $attributes = ['name' => 'foo']; + $values = ['email' => 'bar']; + $givenBuilder->shouldReceive('createOrFirst')->once()->with($attributes, $values)->andReturn($model = m::mock(Model::class)); + $model->shouldReceive('restore')->once()->andReturn(true); + $result = $callback($givenBuilder, $attributes, $values); + + $this->assertEquals($model, $result); + } + public function testWithTrashedExtension() { $builder = new EloquentBuilder(new BaseBuilder( From 5d3fb1eaa91351ae0cfd19add18f3976c75cc32f Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 18:41:37 -0300 Subject: [PATCH 10/26] Adds tests for the Morph relation --- tests/Database/DatabaseEloquentMorphTest.php | 66 ++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index cf7c8352a4de..ca69f2cb49a5 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -2,12 +2,14 @@ namespace Illuminate\Tests\Database; +use Exception; use Foo\Bar\EloquentModelNamespacedStub; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\QueryException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -214,6 +216,70 @@ public function testFirstOrCreateMethodWithValuesCreatesNewMorphModel() $this->assertInstanceOf(Model::class, $relation->firstOrCreate(['foo' => 'bar'], ['baz' => 'qux'])); } + public function testCreateOrFirstMethodFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $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())); + $model->shouldReceive('save')->once()->andThrow( + new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesFindsFirstModel() + { + $relation = $this->getOneRelation(); + + $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())); + $model->shouldReceive('save')->once()->andThrow( + new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + ); + + $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); + $relation->getQuery()->shouldReceive('first')->once()->with()->andReturn($model = m::mock(Model::class)); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + + public function testCreateOrFirstMethodCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $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())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo'])); + } + + public function testCreateOrFirstMethodWithValuesCreatesNewMorphModel() + { + $relation = $this->getOneRelation(); + + $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())); + $model->shouldReceive('save')->once()->andReturn(true); + + $relation->getQuery()->shouldReceive('where')->never(); + $relation->getQuery()->shouldReceive('first')->never(); + + $this->assertInstanceOf(Model::class, $relation->createOrFirst(['foo' => 'bar'], ['baz' => 'qux'])); + } + public function testUpdateOrCreateMethodFindsFirstModelAndUpdates() { $relation = $this->getOneRelation(); From e88fb0bc42bb4edf1c423290fa996b7c84fac54c Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 19:10:38 -0300 Subject: [PATCH 11/26] Adds docblocks --- src/Illuminate/Database/Eloquent/Builder.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 2f1906b427ee..911f9874b67f 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -573,6 +573,13 @@ public function firstOrCreate(array $attributes = [], array $values = []) }); } + /** + * Attempts to create the record and switches to finding it if we run into a UNIQUE constraint violation. + * + * @param array $attributes + * @param array $values + * @return \Illuminate\Database\Eloquent\Model|static + */ public function createOrFirst(array $attributes = [], array $values = []) { try { From 3c12cc89e8db729ac8aa57a771ffd3068e959d4d Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 19:17:59 -0300 Subject: [PATCH 12/26] Adds more context to comments --- .../Concerns/InteractsWithQueryException.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php index 9062be3eb8c0..8b9b5f254e1a 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php +++ b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php @@ -13,13 +13,17 @@ trait InteractsWithQueryException */ protected function matchesUniqueConstraintException(QueryException $exception) { - // SQLite3 + // SQLite 3.8.2 and above will return the newly formatted error message: + // "UNIQUE constraint failed: *table_name*.*column_name*", however in + // older versions, it returns "column *column_name* is not unique". if (preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())) { return true; } - // MySQL - if (preg_match('#SQLSTATE\[23000\]: Integrity constraint violation: 1062#i', $exception->getMessage())) { + // We'll match against the message instead of the exception code because + // the exception code returns "23000" instead of "1062", which is the + // error code MySQL should return in UNIQUE constraints violations. + if (preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())) { return true; } @@ -29,7 +33,7 @@ protected function matchesUniqueConstraintException(QueryException $exception) } // SQLServer - if (preg_match('#SQLSTATE\[23000\]:.*Cannot insert duplicate key row in object.*#i', $exception->getMessage())) { + if (preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())) { return true; } From 7f84116153a763bd8478d7cd9d0f9254a8b13ec7 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 19:41:46 -0300 Subject: [PATCH 13/26] Tweaks comments --- .../Database/Eloquent/Relations/BelongsToMany.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 88e27ebd81df..8d2d0fc67a77 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -643,8 +643,9 @@ public function firstOrCreate(array $attributes = [], array $values = [], array */ public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true) { - // First, we're goint to attempt to create the related record - // which also attempts to attach it to the current record. + // First, we'll attempt to create the related record. If it works, + // this means the related record didn't exist before, so it was + // successfully created and it's also automatically attached. try { return $this->create(array_merge($attributes, $values), $joining, $touch); @@ -654,8 +655,9 @@ public function createOrFirst(array $attributes = [], array $values = [], array } } - // Then, we'll assume the related record already exists, so we - // we only attempt to attach it to the current record. + // If we run into a UNIQUE constraint violation, we'll assume it came from the related model's + // table. Then, we'll find it (or fail) and attempt to attach it to the current model. When + // that works, we return the attached instance. If attaching also fails, we'll do a find. try { $instance = $this->related->where($attributes)->firstOrFail(); From d87444eb66c91489f1a49d0c29d632b8687826b5 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 20:05:00 -0300 Subject: [PATCH 14/26] Move integration tests to the correct namespace --- .../Database/MySql}/DatabaseEloquentMySqlIntegrationTest.php | 2 +- .../Postgres}/DatabaseEloquentPostgresIntegrationTest.php | 2 +- .../SqlServer}/DatabaseEloquentSqlServerIntegrationTest.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename tests/{Database => Integration/Database/MySql}/DatabaseEloquentMySqlIntegrationTest.php (97%) rename tests/{Database => Integration/Database/Postgres}/DatabaseEloquentPostgresIntegrationTest.php (97%) rename tests/{Database => Integration/Database/SqlServer}/DatabaseEloquentSqlServerIntegrationTest.php (97%) diff --git a/tests/Database/DatabaseEloquentMySqlIntegrationTest.php b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php similarity index 97% rename from tests/Database/DatabaseEloquentMySqlIntegrationTest.php rename to tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php index 501bb159b395..5a1653b73a53 100644 --- a/tests/Database/DatabaseEloquentMySqlIntegrationTest.php +++ b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php @@ -1,6 +1,6 @@ Date: Sat, 5 Aug 2023 20:07:20 -0300 Subject: [PATCH 15/26] Remove unnecessary imports --- .../Database/MySql/DatabaseEloquentMySqlIntegrationTest.php | 1 - .../Postgres/DatabaseEloquentPostgresIntegrationTest.php | 1 - .../SqlServer/DatabaseEloquentSqlServerIntegrationTest.php | 1 - 3 files changed, 3 deletions(-) diff --git a/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php index 5a1653b73a53..bad64969d83f 100644 --- a/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php +++ b/tests/Integration/Database/MySql/DatabaseEloquentMySqlIntegrationTest.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\MySql\MySqlTestCase; class DatabaseEloquentMySqlIntegrationTest extends MySqlTestCase { diff --git a/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php index 2557ae4b8e78..7bc71f3fdc57 100644 --- a/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php +++ b/tests/Integration/Database/Postgres/DatabaseEloquentPostgresIntegrationTest.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\Postgres\PostgresTestCase; class DatabaseEloquentPostgresIntegrationTest extends PostgresTestCase { diff --git a/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php index e7d0ba2d1ddd..d7502edcfe44 100644 --- a/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php +++ b/tests/Integration/Database/SqlServer/DatabaseEloquentSqlServerIntegrationTest.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -use Illuminate\Tests\Integration\Database\SqlServer\SqlServerTestCase; class DatabaseEloquentSqlServerIntegrationTest extends SqlServerTestCase { From a203c7371b8b71d0101f59bc0491bd734b7b72a2 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 20:29:11 -0300 Subject: [PATCH 16/26] Replace inline patterns with private constants --- .../Concerns/InteractsWithQueryException.php | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php index 8b9b5f254e1a..0dd0fd1ba11d 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php +++ b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php @@ -6,6 +6,38 @@ trait InteractsWithQueryException { + /** + * We'll match against the message instead of the exception code because + * the exception code returns "23000" instead of "1062", which is the + * error code MySQL should return in UNIQUE constraints violations. + * + * @var string + */ + private const UNIQUE_CONSTRAINT_MYSQL_PATTERN = '#Integrity constraint violation: 1062#i'; + + /** + * SQLite 3.8.2 and above will return the newly formatted error message: + * "UNIQUE constraint failed: *table_name*.*column_name*", however in + * older versions, it returns "column *column_name* is not unique". + * + * @var string + */ + private const UNIQUE_CONSTRAINT_SQLITE_PATTERN = '#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i'; + + /** + * The error code PostgreSQL returns when we run into a UNIQUE constraint violation. + * + * @var string + */ + private const UNIQUE_CONSTRAINT_POSTGRES_CODE = '23505'; + + /** + * The error message regex pattern for when SQL Server runs into a UNIQUE constraint violation. + * + * @var string + */ + private const UNIQUE_CONSTRAINT_SQLSERVER_PATTERN = '#Cannot insert duplicate key row in object#i'; + /** * Checks if the QueryException was caused by a UNIQUE constraint violation. * @@ -13,27 +45,19 @@ trait InteractsWithQueryException */ protected function matchesUniqueConstraintException(QueryException $exception) { - // SQLite 3.8.2 and above will return the newly formatted error message: - // "UNIQUE constraint failed: *table_name*.*column_name*", however in - // older versions, it returns "column *column_name* is not unique". - if (preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())) { + if (preg_match(self::UNIQUE_CONSTRAINT_SQLITE_PATTERN, $exception->getMessage())) { return true; } - // We'll match against the message instead of the exception code because - // the exception code returns "23000" instead of "1062", which is the - // error code MySQL should return in UNIQUE constraints violations. - if (preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())) { + if (preg_match(self::UNIQUE_CONSTRAINT_MYSQL_PATTERN, $exception->getMessage())) { return true; } - // PostgreSQL - if (preg_match('#SQLSTATE\[23505\]#i', $exception->getMessage())) { + if (preg_match(self::UNIQUE_CONSTRAINT_SQLSERVER_PATTERN, $exception->getMessage())) { return true; } - // SQLServer - if (preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())) { + if (self::UNIQUE_CONSTRAINT_POSTGRES_CODE === $exception->getCode()) { return true; } From 7785a10b17bfdced34420ff72f9c0027552712ba Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Sat, 5 Aug 2023 20:33:07 -0300 Subject: [PATCH 17/26] Switch to static properties instead of constants since 8.1 doesnt allow constants on traits --- .../Concerns/InteractsWithQueryException.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php index 0dd0fd1ba11d..f1b1e076b973 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php +++ b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php @@ -13,7 +13,7 @@ trait InteractsWithQueryException * * @var string */ - private const UNIQUE_CONSTRAINT_MYSQL_PATTERN = '#Integrity constraint violation: 1062#i'; + protected static $UNIQUE_CONSTRAINT_MYSQL_PATTERN = '#Integrity constraint violation: 1062#i'; /** * SQLite 3.8.2 and above will return the newly formatted error message: @@ -22,21 +22,21 @@ trait InteractsWithQueryException * * @var string */ - private const UNIQUE_CONSTRAINT_SQLITE_PATTERN = '#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i'; + protected static $UNIQUE_CONSTRAINT_SQLITE_PATTERN = '#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i'; /** * The error code PostgreSQL returns when we run into a UNIQUE constraint violation. * * @var string */ - private const UNIQUE_CONSTRAINT_POSTGRES_CODE = '23505'; + protected static $UNIQUE_CONSTRAINT_POSTGRES_CODE = '23505'; /** * The error message regex pattern for when SQL Server runs into a UNIQUE constraint violation. * * @var string */ - private const UNIQUE_CONSTRAINT_SQLSERVER_PATTERN = '#Cannot insert duplicate key row in object#i'; + protected static $UNIQUE_CONSTRAINT_SQLSERVER_PATTERN = '#Cannot insert duplicate key row in object#i'; /** * Checks if the QueryException was caused by a UNIQUE constraint violation. @@ -45,19 +45,19 @@ trait InteractsWithQueryException */ protected function matchesUniqueConstraintException(QueryException $exception) { - if (preg_match(self::UNIQUE_CONSTRAINT_SQLITE_PATTERN, $exception->getMessage())) { + if (preg_match(self::$UNIQUE_CONSTRAINT_SQLITE_PATTERN, $exception->getMessage())) { return true; } - if (preg_match(self::UNIQUE_CONSTRAINT_MYSQL_PATTERN, $exception->getMessage())) { + if (preg_match(self::$UNIQUE_CONSTRAINT_MYSQL_PATTERN, $exception->getMessage())) { return true; } - if (preg_match(self::UNIQUE_CONSTRAINT_SQLSERVER_PATTERN, $exception->getMessage())) { + if (preg_match(self::$UNIQUE_CONSTRAINT_SQLSERVER_PATTERN, $exception->getMessage())) { return true; } - if (self::UNIQUE_CONSTRAINT_POSTGRES_CODE === $exception->getCode()) { + if (self::$UNIQUE_CONSTRAINT_POSTGRES_CODE === $exception->getCode()) { return true; } From d24a7aef6b4f3dfdd5ff1da380df7de23a89d07f Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 8 Aug 2023 23:34:53 -0300 Subject: [PATCH 18/26] Introduce a new UniqueConstraintViolationException that is a sub-type of QueryException --- src/Illuminate/Database/Connection.php | 16 +++++ src/Illuminate/Database/Eloquent/Builder.php | 11 +--- .../Concerns/InteractsWithQueryException.php | 66 ------------------- .../Eloquent/Relations/BelongsToMany.php | 19 ++---- .../Eloquent/Relations/HasOneOrMany.php | 11 +--- src/Illuminate/Database/MySqlConnection.php | 15 +++++ .../Database/PostgresConnection.php | 13 ++++ src/Illuminate/Database/SQLiteConnection.php | 15 +++++ .../Database/SqlServerConnection.php | 11 ++++ .../UniqueConstraintViolationException.php | 7 ++ 10 files changed, 89 insertions(+), 95 deletions(-) delete mode 100644 src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php create mode 100644 src/Illuminate/Database/UniqueConstraintViolationException.php diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 4f20472e005d..be50b603c8a0 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -792,6 +792,12 @@ protected function runQueryCallback($query, $bindings, Closure $callback) // message to include the bindings with SQL, which will make this exception a // lot more helpful to the developer instead of just the database's errors. catch (Exception $e) { + if ($this->matchesUniqueConstraintException($e)) { + throw new UniqueConstraintViolationException( + $this->getName(), $query, $this->prepareBindings($bindings), $e + ); + } + throw new QueryException( $this->getName(), $query, $this->prepareBindings($bindings), $e ); @@ -817,6 +823,16 @@ public function logQuery($query, $bindings, $time = null) } } + /** + * Detects if the database exception was caused by a unique constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(Exception $exception) + { + throw new Exception("Missing implementation of matchesUniqueConstraintException in the Connection class."); + } + /** * Get the elapsed time since a given starting point. * diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 911f9874b67f..08f5e635470c 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -9,13 +9,12 @@ use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Concerns\BuildsQueries; -use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Concerns\QueriesRelationships; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Database\QueryException; use Illuminate\Database\RecordsNotFoundException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Pagination\Paginator; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -32,7 +31,7 @@ */ class Builder implements BuilderContract { - use BuildsQueries, ForwardsCalls, InteractsWithQueryException, QueriesRelationships { + use BuildsQueries, ForwardsCalls, QueriesRelationships { BuildsQueries::sole as baseSole; } @@ -586,11 +585,7 @@ public function createOrFirst(array $attributes = [], array $values = []) return tap($this->newModelInstance(array_merge($attributes, $values)), function (Model $instance) { $instance->save(); }); - } catch (QueryException $exception) { - if (! $this->matchesUniqueConstraintException($exception)) { - throw $exception; - } - + } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first(); } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php b/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php deleted file mode 100644 index f1b1e076b973..000000000000 --- a/src/Illuminate/Database/Eloquent/Concerns/InteractsWithQueryException.php +++ /dev/null @@ -1,66 +0,0 @@ -getMessage())) { - return true; - } - - if (preg_match(self::$UNIQUE_CONSTRAINT_MYSQL_PATTERN, $exception->getMessage())) { - return true; - } - - if (preg_match(self::$UNIQUE_CONSTRAINT_SQLSERVER_PATTERN, $exception->getMessage())) { - return true; - } - - if (self::$UNIQUE_CONSTRAINT_POSTGRES_CODE === $exception->getCode()) { - return true; - } - - return false; - } -} diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 8d2d0fc67a77..948a11f0886d 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -6,19 +6,18 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Str; use InvalidArgumentException; class BelongsToMany extends Relation { - use InteractsWithDictionary, InteractsWithPivotTable, InteractsWithQueryException; + use InteractsWithDictionary, InteractsWithPivotTable; /** * The intermediate table for the relation. @@ -649,10 +648,8 @@ public function createOrFirst(array $attributes = [], array $values = [], array try { return $this->create(array_merge($attributes, $values), $joining, $touch); - } catch (QueryException $exception) { - if (! $this->matchesUniqueConstraintException($exception)) { - throw $exception; - } + } catch (UniqueConstraintViolationException $exception) { + // Do nothing... } // If we run into a UNIQUE constraint violation, we'll assume it came from the related model's @@ -660,16 +657,12 @@ public function createOrFirst(array $attributes = [], array $values = [], array // that works, we return the attached instance. If attaching also fails, we'll do a find. try { - $instance = $this->related->where($attributes)->firstOrFail(); + $instance = $this->related->where($attributes)->first(); $this->attach($instance, $joining, $touch); return $instance; - } catch (QueryException $exception) { - if (! $this->matchesUniqueConstraintException($exception)) { - throw $exception; - } - + } catch (UniqueConstraintViolationException $exception) { return (clone $this)->where($attributes)->first(); } } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index caba69c5798b..9b402f2414a2 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -4,14 +4,13 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Concerns\InteractsWithQueryException; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; abstract class HasOneOrMany extends Relation { - use InteractsWithDictionary, InteractsWithQueryException; + use InteractsWithDictionary; /** * The foreign key of the parent model. @@ -254,11 +253,7 @@ public function createOrFirst(array $attributes = [], array $values = []) { try { return $this->create(array_merge($attributes, $values)); - } catch (QueryException $exception) { - if (! $this->matchesUniqueConstraintException($exception)) { - throw $exception; - } - + } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first(); } } diff --git a/src/Illuminate/Database/MySqlConnection.php b/src/Illuminate/Database/MySqlConnection.php index 2f87b16f5afe..2f1cf939cfd6 100755 --- a/src/Illuminate/Database/MySqlConnection.php +++ b/src/Illuminate/Database/MySqlConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\MySqlDriver; use Illuminate\Database\Query\Grammars\MySqlGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\MySqlProcessor; @@ -105,4 +106,18 @@ protected function getDoctrineDriver() { return new MySqlDriver; } + + /** + * Detects if the database exception was caused by a unique constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(Exception $exception) + { + // We'll match against the message instead of the exception code because + // the exception code returns "23000" instead of "1062", which is the + // error code MySQL should return in UNIQUE constraints violations. + + return boolval(preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())); + } } diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index a03b29e3bec2..02cbb36c068b 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\PostgresDriver; use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\PostgresProcessor; @@ -105,4 +106,16 @@ protected function getDoctrineDriver() { return new PostgresDriver; } + + /** + * Detects if the database exception was caused by a unique constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(Exception $exception) + { + // The error code PostgreSQL returns when we run into a UNIQUE constraint violation. + + return '23505' === $exception->getCode(); + } } diff --git a/src/Illuminate/Database/SQLiteConnection.php b/src/Illuminate/Database/SQLiteConnection.php index 6e9df07e97ba..5331f1e41714 100755 --- a/src/Illuminate/Database/SQLiteConnection.php +++ b/src/Illuminate/Database/SQLiteConnection.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Exception; use Illuminate\Database\PDO\SQLiteDriver; use Illuminate\Database\Query\Grammars\SQLiteGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SQLiteProcessor; @@ -129,4 +130,18 @@ protected function getForeignKeyConstraintsConfigurationValue() { return $this->getConfig('foreign_key_constraints'); } + + /** + * Detects if the database exception was caused by a unique constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(Exception $exception) + { + // SQLite 3.8.2 and above will return the newly formatted error message: + // "UNIQUE constraint failed: *table_name*.*column_name*", however in + // older versions, it returns "column *column_name* is not unique". + + return boolval(preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())); + } } diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 57d2b20402e0..08493780e559 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -3,6 +3,7 @@ namespace Illuminate\Database; use Closure; +use Exception; use Illuminate\Database\PDO\SqlServerDriver; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SqlServerProcessor; @@ -137,4 +138,14 @@ protected function getDoctrineDriver() { return new SqlServerDriver; } + + /** + * Detects if the database exception was caused by a unique constraint violation. + * + * @return bool + */ + protected function matchesUniqueConstraintException(Exception $exception) + { + return boolval(preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())); + } } diff --git a/src/Illuminate/Database/UniqueConstraintViolationException.php b/src/Illuminate/Database/UniqueConstraintViolationException.php new file mode 100644 index 000000000000..13b705b77c3b --- /dev/null +++ b/src/Illuminate/Database/UniqueConstraintViolationException.php @@ -0,0 +1,7 @@ + Date: Tue, 8 Aug 2023 23:36:38 -0300 Subject: [PATCH 19/26] Use create method instead of newModelInstance+save --- src/Illuminate/Database/Eloquent/Builder.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 08f5e635470c..b9801a7d0312 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -567,9 +567,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $instance; } - return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) { - $instance->save(); - }); + return $this->create(array_merge($attributes, $values)); } /** From 467212c3fd41204212eb5b594d56ddaca3cf1694 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 8 Aug 2023 23:37:45 -0300 Subject: [PATCH 20/26] Use the createOrFirst inside the firstOrCreate method to avoid race condition in the latter --- src/Illuminate/Database/Eloquent/Builder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index b9801a7d0312..f19f2ca98af0 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); } /** From 22517de5a5b06d7f127b042f62e31e118984e5e5 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 8 Aug 2023 23:39:41 -0300 Subject: [PATCH 21/26] Fix StyleCI --- src/Illuminate/Database/Connection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index be50b603c8a0..9d8f35a9d224 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -830,7 +830,7 @@ public function logQuery($query, $bindings, $time = null) */ protected function matchesUniqueConstraintException(Exception $exception) { - throw new Exception("Missing implementation of matchesUniqueConstraintException in the Connection class."); + throw new Exception('Missing implementation of matchesUniqueConstraintException in the Connection class.'); } /** From e71bb4d85af28c452662e25601ad106e6a57abe0 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 8 Aug 2023 23:45:10 -0300 Subject: [PATCH 22/26] Fix tests using mocks that throw the QueryException instead of the newly added UniqueConstraintViolationException --- tests/Database/DatabaseEloquentHasManyTest.php | 4 ++-- tests/Database/DatabaseEloquentMorphTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Database/DatabaseEloquentHasManyTest.php b/tests/Database/DatabaseEloquentHasManyTest.php index 29b0dec89e7d..8fcfa74a6b17 100755 --- a/tests/Database/DatabaseEloquentHasManyTest.php +++ b/tests/Database/DatabaseEloquentHasManyTest.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Mockery as m; use PHPUnit\Framework\TestCase; use stdClass; @@ -177,7 +177,7 @@ public function testCreateOrFirstMethodWithValuesFindsFirstModel() $relation->getRelated()->shouldReceive('newInstance')->once()->with(['foo' => 'bar', 'baz' => 'qux'])->andReturn(m::mock(Model::class, function ($model) { $model->shouldReceive('setAttribute')->once()->with('foreign_key', 1); $model->shouldReceive('save')->once()->andThrow( - new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), ); })); diff --git a/tests/Database/DatabaseEloquentMorphTest.php b/tests/Database/DatabaseEloquentMorphTest.php index ca69f2cb49a5..d30eee15d7cd 100755 --- a/tests/Database/DatabaseEloquentMorphTest.php +++ b/tests/Database/DatabaseEloquentMorphTest.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -224,7 +224,7 @@ public function testCreateOrFirstMethodFindsFirstModel() $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); $model->shouldReceive('save')->once()->andThrow( - new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), ); $relation->getQuery()->shouldReceive('where')->once()->with(['foo'])->andReturn($relation->getQuery()); @@ -241,7 +241,7 @@ public function testCreateOrFirstMethodWithValuesFindsFirstModel() $model->shouldReceive('setAttribute')->once()->with('morph_id', 1); $model->shouldReceive('setAttribute')->once()->with('morph_type', get_class($relation->getParent())); $model->shouldReceive('save')->once()->andThrow( - new QueryException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), + new UniqueConstraintViolationException('mysql', 'example mysql', [], new Exception('SQLSTATE[23000]: Integrity constraint violation: 1062')), ); $relation->getQuery()->shouldReceive('where')->once()->with(['foo' => 'bar'])->andReturn($relation->getQuery()); From 641c88759068c43051c1ce653b6f1b6441c27767 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Tue, 8 Aug 2023 23:59:26 -0300 Subject: [PATCH 23/26] Return false by default in the base implementation of unique detection --- src/Illuminate/Database/Connection.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 9d8f35a9d224..00e4ad5ebb16 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -830,7 +830,8 @@ public function logQuery($query, $bindings, $time = null) */ protected function matchesUniqueConstraintException(Exception $exception) { - throw new Exception('Missing implementation of matchesUniqueConstraintException in the Connection class.'); + // It's up to each child class of Connection to detect a unique constraint violation. + return false; } /** From 812626ee5759ef918f599663fce3e4593bbe2d72 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Wed, 9 Aug 2023 00:08:49 -0300 Subject: [PATCH 24/26] Tweaks the comment --- src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index 948a11f0886d..e92969c943c3 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -649,7 +649,7 @@ public function createOrFirst(array $attributes = [], array $values = [], array try { return $this->create(array_merge($attributes, $values), $joining, $touch); } catch (UniqueConstraintViolationException $exception) { - // Do nothing... + // ... } // If we run into a UNIQUE constraint violation, we'll assume it came from the related model's From a1e40e48e2bb8c0365c866cfe8406b1da5653ce5 Mon Sep 17 00:00:00 2001 From: Tony Messias Date: Wed, 9 Aug 2023 00:16:12 -0300 Subject: [PATCH 25/26] Use the create method in the createOrFirst one --- src/Illuminate/Database/Eloquent/Builder.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index f19f2ca98af0..6982a0d4a681 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -580,9 +580,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) public function createOrFirst(array $attributes = [], array $values = []) { try { - return tap($this->newModelInstance(array_merge($attributes, $values)), function (Model $instance) { - $instance->save(); - }); + return $this->create(array_merge($attributes, $values)); } catch (UniqueConstraintViolationException $exception) { return $this->where($attributes)->first(); } From 11399a5a751359f9a114fe4e770658c7e0365361 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Tue, 15 Aug 2023 13:21:43 -0500 Subject: [PATCH 26/26] formatting --- src/Illuminate/Database/Connection.php | 24 +++++++++--------- src/Illuminate/Database/Eloquent/Builder.php | 4 +-- .../Eloquent/Relations/BelongsToMany.php | 20 ++++----------- .../Eloquent/Relations/HasOneOrMany.php | 4 +-- src/Illuminate/Database/MySqlConnection.php | 25 ++++++++----------- .../Database/PostgresConnection.php | 23 ++++++++--------- src/Illuminate/Database/SQLiteConnection.php | 25 ++++++++----------- .../Database/SqlServerConnection.php | 21 ++++++++-------- 8 files changed, 65 insertions(+), 81 deletions(-) diff --git a/src/Illuminate/Database/Connection.php b/src/Illuminate/Database/Connection.php index 00e4ad5ebb16..71faf0a47d14 100755 --- a/src/Illuminate/Database/Connection.php +++ b/src/Illuminate/Database/Connection.php @@ -792,7 +792,7 @@ protected function runQueryCallback($query, $bindings, Closure $callback) // message to include the bindings with SQL, which will make this exception a // lot more helpful to the developer instead of just the database's errors. catch (Exception $e) { - if ($this->matchesUniqueConstraintException($e)) { + if ($this->isUniqueConstraintError($e)) { throw new UniqueConstraintViolationException( $this->getName(), $query, $this->prepareBindings($bindings), $e ); @@ -804,6 +804,17 @@ protected function runQueryCallback($query, $bindings, Closure $callback) } } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return false; + } + /** * Log a query in the connection's query log. * @@ -823,17 +834,6 @@ public function logQuery($query, $bindings, $time = null) } } - /** - * Detects if the database exception was caused by a unique constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(Exception $exception) - { - // It's up to each child class of Connection to detect a unique constraint violation. - return false; - } - /** * Get the elapsed time since a given starting point. * diff --git a/src/Illuminate/Database/Eloquent/Builder.php b/src/Illuminate/Database/Eloquent/Builder.php index 6982a0d4a681..3e222ab6183c 100755 --- a/src/Illuminate/Database/Eloquent/Builder.php +++ b/src/Illuminate/Database/Eloquent/Builder.php @@ -555,7 +555,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -571,7 +571,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) } /** - * Attempts to create the record and switches to finding it if we run into a UNIQUE constraint violation. + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * * @param array $attributes * @param array $values diff --git a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php index e92969c943c3..a4cc76fb0308 100755 --- a/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -610,7 +610,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -632,7 +632,7 @@ public function firstOrCreate(array $attributes = [], array $values = [], array } /** - * Attempts to create the related record and return the first match if a unique constraint happens. + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * * @param array $attributes * @param array $values @@ -642,26 +642,16 @@ public function firstOrCreate(array $attributes = [], array $values = [], array */ public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true) { - // First, we'll attempt to create the related record. If it works, - // this means the related record didn't exist before, so it was - // successfully created and it's also automatically attached. - try { return $this->create(array_merge($attributes, $values), $joining, $touch); } catch (UniqueConstraintViolationException $exception) { // ... } - // If we run into a UNIQUE constraint violation, we'll assume it came from the related model's - // table. Then, we'll find it (or fail) and attempt to attach it to the current model. When - // that works, we return the attached instance. If attaching also fails, we'll do a find. - try { - $instance = $this->related->where($attributes)->first(); - - $this->attach($instance, $joining, $touch); - - return $instance; + return tap($this->related->where($attributes)->first(), function ($instance) use ($joining, $touch) { + $this->attach($instance, $joining, $touch); + }); } catch (UniqueConstraintViolationException $exception) { return (clone $this)->where($attributes)->first(); } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index 9b402f2414a2..482e5208a946 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -227,7 +227,7 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes * @param array $values @@ -243,7 +243,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) } /** - * Attempt to create the record or find the first one matching the attributes if a unique constraint violation happens. + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * * @param array $attributes * @param array $values diff --git a/src/Illuminate/Database/MySqlConnection.php b/src/Illuminate/Database/MySqlConnection.php index 2f1cf939cfd6..460a4fd375c1 100755 --- a/src/Illuminate/Database/MySqlConnection.php +++ b/src/Illuminate/Database/MySqlConnection.php @@ -27,6 +27,17 @@ protected function escapeBinary($value) return "x'{$hex}'"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())); + } + /** * Determine if the connected database is a MariaDB database. * @@ -106,18 +117,4 @@ protected function getDoctrineDriver() { return new MySqlDriver; } - - /** - * Detects if the database exception was caused by a unique constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(Exception $exception) - { - // We'll match against the message instead of the exception code because - // the exception code returns "23000" instead of "1062", which is the - // error code MySQL should return in UNIQUE constraints violations. - - return boolval(preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage())); - } } diff --git a/src/Illuminate/Database/PostgresConnection.php b/src/Illuminate/Database/PostgresConnection.php index 02cbb36c068b..c3e22a928801 100755 --- a/src/Illuminate/Database/PostgresConnection.php +++ b/src/Illuminate/Database/PostgresConnection.php @@ -37,6 +37,17 @@ protected function escapeBool($value) return $value ? 'true' : 'false'; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return '23505' === $exception->getCode(); + } + /** * Get the default query grammar instance. * @@ -106,16 +117,4 @@ protected function getDoctrineDriver() { return new PostgresDriver; } - - /** - * Detects if the database exception was caused by a unique constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(Exception $exception) - { - // The error code PostgreSQL returns when we run into a UNIQUE constraint violation. - - return '23505' === $exception->getCode(); - } } diff --git a/src/Illuminate/Database/SQLiteConnection.php b/src/Illuminate/Database/SQLiteConnection.php index 5331f1e41714..ad7c1486d2d2 100755 --- a/src/Illuminate/Database/SQLiteConnection.php +++ b/src/Illuminate/Database/SQLiteConnection.php @@ -50,6 +50,17 @@ protected function escapeBinary($value) return "x'{$hex}'"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())); + } + /** * Get the default query grammar instance. * @@ -130,18 +141,4 @@ protected function getForeignKeyConstraintsConfigurationValue() { return $this->getConfig('foreign_key_constraints'); } - - /** - * Detects if the database exception was caused by a unique constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(Exception $exception) - { - // SQLite 3.8.2 and above will return the newly formatted error message: - // "UNIQUE constraint failed: *table_name*.*column_name*", however in - // older versions, it returns "column *column_name* is not unique". - - return boolval(preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage())); - } } diff --git a/src/Illuminate/Database/SqlServerConnection.php b/src/Illuminate/Database/SqlServerConnection.php index 08493780e559..e376e6fa6c38 100755 --- a/src/Illuminate/Database/SqlServerConnection.php +++ b/src/Illuminate/Database/SqlServerConnection.php @@ -68,6 +68,17 @@ protected function escapeBinary($value) return "0x{$hex}"; } + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return boolval(preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())); + } + /** * Get the default query grammar instance. * @@ -138,14 +149,4 @@ protected function getDoctrineDriver() { return new SqlServerDriver; } - - /** - * Detects if the database exception was caused by a unique constraint violation. - * - * @return bool - */ - protected function matchesUniqueConstraintException(Exception $exception) - { - return boolval(preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage())); - } }