Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[10.x] Adds a createOrFirst method to Eloquent #47973

Merged
merged 26 commits into from
Aug 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
09a8780
Adds more services to the docker-compose.yml for ease local testing
tonysm Aug 5, 2023
5404089
Adds createOrFirst method to the query builder
tonysm Aug 5, 2023
61bc4eb
Adds createOrFirst to the BelongsToMany relation
tonysm Aug 5, 2023
698e3a6
Adds createOrFirst to HasOneOrMany relation
tonysm Aug 5, 2023
53580c0
Test createOrFirst using with casts
tonysm Aug 5, 2023
8c84693
Test createOrFirst with enum casting
tonysm Aug 5, 2023
5a40c59
Test createOrFirst with SoftDeletes models
tonysm Aug 5, 2023
ae2d3bc
Adds test for the DatabaseElqouentHasManyTest suite
tonysm Aug 5, 2023
e7ef541
Adds createOrRestore scope to soft-deleting models
tonysm Aug 5, 2023
5d3fb1e
Adds tests for the Morph relation
tonysm Aug 5, 2023
e88fb0b
Adds docblocks
tonysm Aug 5, 2023
3c12cc8
Adds more context to comments
tonysm Aug 5, 2023
7f84116
Tweaks comments
tonysm Aug 5, 2023
d87444e
Move integration tests to the correct namespace
tonysm Aug 5, 2023
b9cc565
Remove unnecessary imports
tonysm Aug 5, 2023
a203c73
Replace inline patterns with private constants
tonysm Aug 5, 2023
7785a10
Switch to static properties instead of constants since 8.1 doesnt all…
tonysm Aug 5, 2023
d24a7ae
Introduce a new UniqueConstraintViolationException that is a sub-type…
tonysm Aug 9, 2023
0b7dfdb
Use create method instead of newModelInstance+save
tonysm Aug 9, 2023
467212c
Use the createOrFirst inside the firstOrCreate method to avoid race c…
tonysm Aug 9, 2023
22517de
Fix StyleCI
tonysm Aug 9, 2023
e71bb4d
Fix tests using mocks that throw the QueryException instead of the ne…
tonysm Aug 9, 2023
641c887
Return false by default in the base implementation of unique detection
tonysm Aug 9, 2023
812626e
Tweaks the comment
tonysm Aug 9, 2023
a1e40e4
Use the create method in the createOrFirst one
tonysm Aug 9, 2023
11399a5
formatting
taylorotwell Aug 15, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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