Skip to content

Commit 14e8ee4

Browse files
tonysmtaylorotwell
andauthored
[10.x] Adds a createOrFirst method to Eloquent (#47973)
* Adds more services to the docker-compose.yml for ease local testing * Adds createOrFirst method to the query builder * Adds createOrFirst to the BelongsToMany relation * Adds createOrFirst to HasOneOrMany relation * Test createOrFirst using with casts * Test createOrFirst with enum casting * Test createOrFirst with SoftDeletes models * Adds test for the DatabaseElqouentHasManyTest suite * Adds createOrRestore scope to soft-deleting models * Adds tests for the Morph relation * Adds docblocks * Adds more context to comments * Tweaks comments * Move integration tests to the correct namespace * Remove unnecessary imports * Replace inline patterns with private constants * Switch to static properties instead of constants since 8.1 doesnt allow constants on traits * Introduce a new UniqueConstraintViolationException that is a sub-type of QueryException * Use create method instead of newModelInstance+save * Use the createOrFirst inside the firstOrCreate method to avoid race condition in the latter * Fix StyleCI * Fix tests using mocks that throw the QueryException instead of the newly added UniqueConstraintViolationException * Return false by default in the base implementation of unique detection * Tweaks the comment * Use the create method in the createOrFirst one * formatting --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent c658d14 commit 14e8ee4

23 files changed

+727
-7
lines changed

docker-compose.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ services:
2020
ports:
2121
- "3306:3306"
2222
restart: always
23+
# postgres:
24+
# image: postgres:15
25+
# environment:
26+
# POSTGRES_PASSWORD: "secret"
27+
# POSTGRES_DB: "forge"
28+
# ports:
29+
# - "5432:5432"
30+
# restart: always
31+
# mariadb:
32+
# image: mariadb:11
33+
# environment:
34+
# MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes"
35+
# MARIADB_ROOT_PASSWORD: ""
36+
# MARIADB_DATABASE: "forge"
37+
# MARIADB_ROOT_HOST: "%"
38+
# ports:
39+
# - "3306:3306"
40+
# restart: always
41+
# mssql:
42+
# image: mcr.microsoft.com/mssql/server:2019-latest
43+
# environment:
44+
# ACCEPT_EULA: "Y"
45+
# SA_PASSWORD: "Forge123"
46+
# ports:
47+
# - "1433:1433"
48+
# restart: always
2349
redis:
2450
image: redis:7.0-alpine
2551
ports:

src/Illuminate/Database/Connection.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,12 +792,29 @@ protected function runQueryCallback($query, $bindings, Closure $callback)
792792
// message to include the bindings with SQL, which will make this exception a
793793
// lot more helpful to the developer instead of just the database's errors.
794794
catch (Exception $e) {
795+
if ($this->isUniqueConstraintError($e)) {
796+
throw new UniqueConstraintViolationException(
797+
$this->getName(), $query, $this->prepareBindings($bindings), $e
798+
);
799+
}
800+
795801
throw new QueryException(
796802
$this->getName(), $query, $this->prepareBindings($bindings), $e
797803
);
798804
}
799805
}
800806

807+
/**
808+
* Determine if the given database exception was caused by a unique constraint violation.
809+
*
810+
* @param \Exception $exception
811+
* @return bool
812+
*/
813+
protected function isUniqueConstraintError(Exception $exception)
814+
{
815+
return false;
816+
}
817+
801818
/**
802819
* Log a query in the connection's query log.
803820
*

src/Illuminate/Database/Eloquent/Builder.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Illuminate\Database\Eloquent\Relations\Relation;
1515
use Illuminate\Database\Query\Builder as QueryBuilder;
1616
use Illuminate\Database\RecordsNotFoundException;
17+
use Illuminate\Database\UniqueConstraintViolationException;
1718
use Illuminate\Pagination\Paginator;
1819
use Illuminate\Support\Arr;
1920
use Illuminate\Support\Str;
@@ -554,7 +555,7 @@ public function firstOrNew(array $attributes = [], array $values = [])
554555
}
555556

556557
/**
557-
* Get the first record matching the attributes or create it.
558+
* Get the first record matching the attributes. If the record is not found, create it.
558559
*
559560
* @param array $attributes
560561
* @param array $values
@@ -566,9 +567,23 @@ public function firstOrCreate(array $attributes = [], array $values = [])
566567
return $instance;
567568
}
568569

569-
return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) {
570-
$instance->save();
571-
});
570+
return $this->createOrFirst($attributes, $values);
571+
}
572+
573+
/**
574+
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
575+
*
576+
* @param array $attributes
577+
* @param array $values
578+
* @return \Illuminate\Database\Eloquent\Model|static
579+
*/
580+
public function createOrFirst(array $attributes = [], array $values = [])
581+
{
582+
try {
583+
return $this->create(array_merge($attributes, $values));
584+
} catch (UniqueConstraintViolationException $exception) {
585+
return $this->where($attributes)->first();
586+
}
572587
}
573588

574589
/**

src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot;
1212
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
1313
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable;
14+
use Illuminate\Database\UniqueConstraintViolationException;
1415
use Illuminate\Support\Str;
1516
use InvalidArgumentException;
1617

@@ -609,7 +610,7 @@ public function firstOrNew(array $attributes = [], array $values = [])
609610
}
610611

611612
/**
612-
* Get the first related record matching the attributes or create it.
613+
* Get the first record matching the attributes. If the record is not found, create it.
613614
*
614615
* @param array $attributes
615616
* @param array $values
@@ -630,6 +631,32 @@ public function firstOrCreate(array $attributes = [], array $values = [], array
630631
return $instance;
631632
}
632633

634+
/**
635+
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
636+
*
637+
* @param array $attributes
638+
* @param array $values
639+
* @param array $joining
640+
* @param bool $touch
641+
* @return \Illuminate\Database\Eloquent\Model
642+
*/
643+
public function createOrFirst(array $attributes = [], array $values = [], array $joining = [], $touch = true)
644+
{
645+
try {
646+
return $this->create(array_merge($attributes, $values), $joining, $touch);
647+
} catch (UniqueConstraintViolationException $exception) {
648+
// ...
649+
}
650+
651+
try {
652+
return tap($this->related->where($attributes)->first(), function ($instance) use ($joining, $touch) {
653+
$this->attach($instance, $joining, $touch);
654+
});
655+
} catch (UniqueConstraintViolationException $exception) {
656+
return (clone $this)->where($attributes)->first();
657+
}
658+
}
659+
633660
/**
634661
* Create or update a related record matching the attributes, and fill it with values.
635662
*

src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Database\Eloquent\Collection;
77
use Illuminate\Database\Eloquent\Model;
88
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
9+
use Illuminate\Database\UniqueConstraintViolationException;
910

1011
abstract class HasOneOrMany extends Relation
1112
{
@@ -226,7 +227,7 @@ public function firstOrNew(array $attributes = [], array $values = [])
226227
}
227228

228229
/**
229-
* Get the first related record matching the attributes or create it.
230+
* Get the first record matching the attributes. If the record is not found, create it.
230231
*
231232
* @param array $attributes
232233
* @param array $values
@@ -241,6 +242,22 @@ public function firstOrCreate(array $attributes = [], array $values = [])
241242
return $instance;
242243
}
243244

245+
/**
246+
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
247+
*
248+
* @param array $attributes
249+
* @param array $values
250+
* @return \Illuminate\Database\Eloquent\Model
251+
*/
252+
public function createOrFirst(array $attributes = [], array $values = [])
253+
{
254+
try {
255+
return $this->create(array_merge($attributes, $values));
256+
} catch (UniqueConstraintViolationException $exception) {
257+
return $this->where($attributes)->first();
258+
}
259+
}
260+
244261
/**
245262
* Create or update a related record matching the attributes, and fill it with values.
246263
*

src/Illuminate/Database/Eloquent/SoftDeletingScope.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class SoftDeletingScope implements Scope
99
*
1010
* @var string[]
1111
*/
12-
protected $extensions = ['Restore', 'RestoreOrCreate', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed'];
12+
protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed'];
1313

1414
/**
1515
* Apply the scope to a given Eloquent query builder.
@@ -91,6 +91,23 @@ protected function addRestoreOrCreate(Builder $builder)
9191
});
9292
}
9393

94+
/**
95+
* Add the create-or-restore extension to the builder.
96+
*
97+
* @param \Illuminate\Database\Eloquent\Builder $builder
98+
* @return void
99+
*/
100+
protected function addCreateOrRestore(Builder $builder)
101+
{
102+
$builder->macro('createOrRestore', function (Builder $builder, array $attributes = [], array $values = []) {
103+
$builder->withTrashed();
104+
105+
return tap($builder->createOrFirst($attributes, $values), function ($instance) {
106+
$instance->restore();
107+
});
108+
});
109+
}
110+
94111
/**
95112
* Add the with-trashed extension to the builder.
96113
*

src/Illuminate/Database/MySqlConnection.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Database;
44

5+
use Exception;
56
use Illuminate\Database\PDO\MySqlDriver;
67
use Illuminate\Database\Query\Grammars\MySqlGrammar as QueryGrammar;
78
use Illuminate\Database\Query\Processors\MySqlProcessor;
@@ -26,6 +27,17 @@ protected function escapeBinary($value)
2627
return "x'{$hex}'";
2728
}
2829

30+
/**
31+
* Determine if the given database exception was caused by a unique constraint violation.
32+
*
33+
* @param \Exception $exception
34+
* @return bool
35+
*/
36+
protected function isUniqueConstraintError(Exception $exception)
37+
{
38+
return boolval(preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage()));
39+
}
40+
2941
/**
3042
* Determine if the connected database is a MariaDB database.
3143
*

src/Illuminate/Database/PostgresConnection.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Database;
44

5+
use Exception;
56
use Illuminate\Database\PDO\PostgresDriver;
67
use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar;
78
use Illuminate\Database\Query\Processors\PostgresProcessor;
@@ -36,6 +37,17 @@ protected function escapeBool($value)
3637
return $value ? 'true' : 'false';
3738
}
3839

40+
/**
41+
* Determine if the given database exception was caused by a unique constraint violation.
42+
*
43+
* @param \Exception $exception
44+
* @return bool
45+
*/
46+
protected function isUniqueConstraintError(Exception $exception)
47+
{
48+
return '23505' === $exception->getCode();
49+
}
50+
3951
/**
4052
* Get the default query grammar instance.
4153
*

src/Illuminate/Database/SQLiteConnection.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Illuminate\Database;
44

5+
use Exception;
56
use Illuminate\Database\PDO\SQLiteDriver;
67
use Illuminate\Database\Query\Grammars\SQLiteGrammar as QueryGrammar;
78
use Illuminate\Database\Query\Processors\SQLiteProcessor;
@@ -49,6 +50,17 @@ protected function escapeBinary($value)
4950
return "x'{$hex}'";
5051
}
5152

53+
/**
54+
* Determine if the given database exception was caused by a unique constraint violation.
55+
*
56+
* @param \Exception $exception
57+
* @return bool
58+
*/
59+
protected function isUniqueConstraintError(Exception $exception)
60+
{
61+
return boolval(preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage()));
62+
}
63+
5264
/**
5365
* Get the default query grammar instance.
5466
*

src/Illuminate/Database/SqlServerConnection.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Illuminate\Database;
44

55
use Closure;
6+
use Exception;
67
use Illuminate\Database\PDO\SqlServerDriver;
78
use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar;
89
use Illuminate\Database\Query\Processors\SqlServerProcessor;
@@ -67,6 +68,17 @@ protected function escapeBinary($value)
6768
return "0x{$hex}";
6869
}
6970

71+
/**
72+
* Determine if the given database exception was caused by a unique constraint violation.
73+
*
74+
* @param \Exception $exception
75+
* @return bool
76+
*/
77+
protected function isUniqueConstraintError(Exception $exception)
78+
{
79+
return boolval(preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage()));
80+
}
81+
7082
/**
7183
* Get the default query grammar instance.
7284
*

0 commit comments

Comments
 (0)