Skip to content

Commit

Permalink
[10.x] Adds a createOrFirst method to Eloquent (#47973)
Browse files Browse the repository at this point in the history
* 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 <taylor@laravel.com>
  • Loading branch information
tonysm and taylorotwell authored Aug 15, 2023
1 parent c658d14 commit 14e8ee4
Show file tree
Hide file tree
Showing 23 changed files with 727 additions and 7 deletions.
26 changes: 26 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/Illuminate/Database/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -792,12 +792,29 @@ 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->isUniqueConstraintError($e)) {
throw new UniqueConstraintViolationException(
$this->getName(), $query, $this->prepareBindings($bindings), $e
);
}

throw new QueryException(
$this->getName(), $query, $this->prepareBindings($bindings), $e
);
}
}

/**
* 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.
*
Expand Down
23 changes: 19 additions & 4 deletions src/Illuminate/Database/Eloquent/Builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\RecordsNotFoundException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
Expand Down Expand Up @@ -554,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
Expand All @@ -566,9 +567,23 @@ public function firstOrCreate(array $attributes = [], array $values = [])
return $instance;
}

return tap($this->newModelInstance(array_merge($attributes, $values)), function ($instance) {
$instance->save();
});
return $this->createOrFirst($attributes, $values);
}

/**
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
*
* @param array $attributes
* @param array $values
* @return \Illuminate\Database\Eloquent\Model|static
*/
public function createOrFirst(array $attributes = [], array $values = [])
{
try {
return $this->create(array_merge($attributes, $values));
} catch (UniqueConstraintViolationException $exception) {
return $this->where($attributes)->first();
}
}

/**
Expand Down
29 changes: 28 additions & 1 deletion src/Illuminate/Database/Eloquent/Relations/BelongsToMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot;
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Str;
use InvalidArgumentException;

Expand Down Expand Up @@ -609,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
Expand All @@ -630,6 +631,32 @@ public function firstOrCreate(array $attributes = [], array $values = [], array
return $instance;
}

/**
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
*
* @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)
{
try {
return $this->create(array_merge($attributes, $values), $joining, $touch);
} catch (UniqueConstraintViolationException $exception) {
// ...
}

try {
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();
}
}

/**
* Create or update a related record matching the attributes, and fill it with values.
*
Expand Down
19 changes: 18 additions & 1 deletion src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
use Illuminate\Database\UniqueConstraintViolationException;

abstract class HasOneOrMany extends Relation
{
Expand Down Expand Up @@ -226,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
Expand All @@ -241,6 +242,22 @@ public function firstOrCreate(array $attributes = [], array $values = [])
return $instance;
}

/**
* Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record.
*
* @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 (UniqueConstraintViolationException $exception) {
return $this->where($attributes)->first();
}
}

/**
* Create or update a related record matching the attributes, and fill it with values.
*
Expand Down
19 changes: 18 additions & 1 deletion src/Illuminate/Database/Eloquent/SoftDeletingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Database/MySqlConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Database/PostgresConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Database/SQLiteConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,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.
*
Expand Down
12 changes: 12 additions & 0 deletions src/Illuminate/Database/SqlServerConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?php

namespace Illuminate\Database;

class UniqueConstraintViolationException extends QueryException
{
}
Loading

0 comments on commit 14e8ee4

Please sign in to comment.