diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000..7e54581c2a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/.github export-ignore
+.gitattributes export-ignore
diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml
new file mode 100644
index 0000000000..6cbfcf0671
--- /dev/null
+++ b/.github/workflows/close-pull-request.yml
@@ -0,0 +1,13 @@
+name: Close Pull Request
+
+on:
+ pull_request_target:
+ types: [opened]
+
+jobs:
+ run:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: superbrothers/close-pull-request@v3
+ with:
+ comment: "Thank you for your pull request. However, you have submitted this PR on the Illuminate organization which is a read-only sub split of `laravel/framework`. Please submit your PR on the https://github.com/laravel/framework repository.
Thanks!"
diff --git a/Capsule/Manager.php b/Capsule/Manager.php
index b82a792ce7..ddcc85dcf7 100755
--- a/Capsule/Manager.php
+++ b/Capsule/Manager.php
@@ -2,13 +2,13 @@
namespace Illuminate\Database\Capsule;
-use PDO;
use Illuminate\Container\Container;
-use Illuminate\Database\DatabaseManager;
use Illuminate\Contracts\Events\Dispatcher;
-use Illuminate\Support\Traits\CapsuleManagerTrait;
-use Illuminate\Database\Eloquent\Model as Eloquent;
use Illuminate\Database\Connectors\ConnectionFactory;
+use Illuminate\Database\DatabaseManager;
+use Illuminate\Database\Eloquent\Model as Eloquent;
+use Illuminate\Support\Traits\CapsuleManagerTrait;
+use PDO;
class Manager
{
@@ -25,9 +25,8 @@ class Manager
* Create a new database capsule manager.
*
* @param \Illuminate\Container\Container|null $container
- * @return void
*/
- public function __construct(Container $container = null)
+ public function __construct(?Container $container = null)
{
$this->setupContainer($container ?: new Container);
@@ -66,7 +65,7 @@ protected function setupManager()
/**
* Get a connection instance from the global manager.
*
- * @param string $connection
+ * @param string|null $connection
* @return \Illuminate\Database\Connection
*/
public static function connection($connection = null)
@@ -77,19 +76,20 @@ public static function connection($connection = null)
/**
* Get a fluent query builder instance.
*
- * @param string $table
- * @param string $connection
+ * @param \Closure|\Illuminate\Database\Query\Builder|string $table
+ * @param string|null $as
+ * @param string|null $connection
* @return \Illuminate\Database\Query\Builder
*/
- public static function table($table, $connection = null)
+ public static function table($table, $as = null, $connection = null)
{
- return static::$instance->connection($connection)->table($table);
+ return static::$instance->connection($connection)->table($table, $as);
}
/**
* Get a schema builder instance.
*
- * @param string $connection
+ * @param string|null $connection
* @return \Illuminate\Database\Schema\Builder
*/
public static function schema($connection = null)
@@ -100,7 +100,7 @@ public static function schema($connection = null)
/**
* Get a registered connection instance.
*
- * @param string $name
+ * @param string|null $name
* @return \Illuminate\Database\Connection
*/
public function getConnection($name = null)
@@ -111,7 +111,7 @@ public function getConnection($name = null)
/**
* Register a connection with the manager.
*
- * @param array $config
+ * @param array $config
* @param string $name
* @return void
*/
@@ -191,7 +191,7 @@ public function setEventDispatcher(Dispatcher $dispatcher)
* Dynamically pass methods to the default connection.
*
* @param string $method
- * @param array $parameters
+ * @param array $parameters
* @return mixed
*/
public static function __callStatic($method, $parameters)
diff --git a/ClassMorphViolationException.php b/ClassMorphViolationException.php
new file mode 100644
index 0000000000..6594d2d902
--- /dev/null
+++ b/ClassMorphViolationException.php
@@ -0,0 +1,29 @@
+model = $class;
+ }
+}
diff --git a/Concerns/BuildsQueries.php b/Concerns/BuildsQueries.php
index 54b18ed859..537594e085 100644
--- a/Concerns/BuildsQueries.php
+++ b/Concerns/BuildsQueries.php
@@ -3,29 +3,193 @@
namespace Illuminate\Database\Concerns;
use Illuminate\Container\Container;
-use Illuminate\Pagination\Paginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\MultipleRecordsFoundException;
+use Illuminate\Database\Query\Expression;
+use Illuminate\Database\RecordNotFoundException;
+use Illuminate\Database\RecordsNotFoundException;
+use Illuminate\Pagination\Cursor;
+use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
+use Illuminate\Pagination\Paginator;
+use Illuminate\Support\Collection;
+use Illuminate\Support\LazyCollection;
+use Illuminate\Support\Str;
+use Illuminate\Support\Traits\Conditionable;
+use InvalidArgumentException;
+use RuntimeException;
+/**
+ * @template TValue
+ *
+ * @mixin \Illuminate\Database\Query\Builder
+ */
trait BuildsQueries
{
+ use Conditionable;
+
/**
* Chunk the results of the query.
*
* @param int $count
- * @param callable $callback
+ * @param callable(\Illuminate\Support\Collection, int): mixed $callback
* @return bool
*/
public function chunk($count, callable $callback)
{
$this->enforceOrderBy();
+ $skip = $this->getOffset();
+ $remaining = $this->getLimit();
+
$page = 1;
do {
+ $offset = (($page - 1) * $count) + (int) $skip;
+
+ $limit = is_null($remaining) ? $count : min($count, $remaining);
+
+ if ($limit == 0) {
+ break;
+ }
+
+ $results = $this->offset($offset)->limit($limit)->get();
+
+ $countResults = $results->count();
+
+ if ($countResults == 0) {
+ break;
+ }
+
+ if (! is_null($remaining)) {
+ $remaining = max($remaining - $countResults, 0);
+ }
+
+ if ($callback($results, $page) === false) {
+ return false;
+ }
+
+ unset($results);
+
+ $page++;
+ } while ($countResults == $count);
+
+ return true;
+ }
+
+ /**
+ * Run a map over each item while chunking.
+ *
+ * @template TReturn
+ *
+ * @param callable(TValue): TReturn $callback
+ * @param int $count
+ * @return \Illuminate\Support\Collection
+ */
+ public function chunkMap(callable $callback, $count = 1000)
+ {
+ $collection = new Collection;
+
+ $this->chunk($count, function ($items) use ($collection, $callback) {
+ $items->each(function ($item) use ($collection, $callback) {
+ $collection->push($callback($item));
+ });
+ });
+
+ return $collection;
+ }
+
+ /**
+ * Execute a callback over each item while chunking.
+ *
+ * @param callable(TValue, int): mixed $callback
+ * @param int $count
+ * @return bool
+ *
+ * @throws \RuntimeException
+ */
+ public function each(callable $callback, $count = 1000)
+ {
+ return $this->chunk($count, function ($results) use ($callback) {
+ foreach ($results as $key => $value) {
+ if ($callback($value, $key) === false) {
+ return false;
+ }
+ }
+ });
+ }
+
+ /**
+ * Chunk the results of a query by comparing IDs.
+ *
+ * @param int $count
+ * @param callable(\Illuminate\Support\Collection, int): mixed $callback
+ * @param string|null $column
+ * @param string|null $alias
+ * @return bool
+ */
+ public function chunkById($count, callable $callback, $column = null, $alias = null)
+ {
+ return $this->orderedChunkById($count, $callback, $column, $alias);
+ }
+
+ /**
+ * Chunk the results of a query by comparing IDs in descending order.
+ *
+ * @param int $count
+ * @param callable(\Illuminate\Support\Collection, int): mixed $callback
+ * @param string|null $column
+ * @param string|null $alias
+ * @return bool
+ */
+ public function chunkByIdDesc($count, callable $callback, $column = null, $alias = null)
+ {
+ return $this->orderedChunkById($count, $callback, $column, $alias, descending: true);
+ }
+
+ /**
+ * Chunk the results of a query by comparing IDs in a given order.
+ *
+ * @param int $count
+ * @param callable(\Illuminate\Support\Collection, int): mixed $callback
+ * @param string|null $column
+ * @param string|null $alias
+ * @param bool $descending
+ * @return bool
+ *
+ * @throws \RuntimeException
+ */
+ public function orderedChunkById($count, callable $callback, $column = null, $alias = null, $descending = false)
+ {
+ $column ??= $this->defaultKeyName();
+ $alias ??= $column;
+ $lastId = null;
+ $skip = $this->getOffset();
+ $remaining = $this->getLimit();
+
+ $page = 1;
+
+ do {
+ $clone = clone $this;
+
+ if ($skip && $page > 1) {
+ $clone->offset(0);
+ }
+
+ $limit = is_null($remaining) ? $count : min($count, $remaining);
+
+ if ($limit == 0) {
+ break;
+ }
+
// We'll execute the query for the given page and get the results. If there are
// no results we can just break and return from here. When there are results
// we will call the callback with the current chunk of these results here.
- $results = $this->forPage($page, $count)->get();
+ if ($descending) {
+ $results = $clone->forPageBeforeId($limit, $lastId, $column)->get();
+ } else {
+ $results = $clone->forPageAfterId($limit, $lastId, $column)->get();
+ }
$countResults = $results->count();
@@ -33,6 +197,10 @@ public function chunk($count, callable $callback)
break;
}
+ if (! is_null($remaining)) {
+ $remaining = max($remaining - $countResults, 0);
+ }
+
// On each chunk result set, we will pass them to the callback and then let the
// developer take care of everything within the callback, which allows us to
// keep the memory low for spinning through large result sets for working.
@@ -40,6 +208,12 @@ public function chunk($count, callable $callback)
return false;
}
+ $lastId = data_get($results->last(), $alias);
+
+ if ($lastId === null) {
+ throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
+ }
+
unset($results);
$page++;
@@ -49,81 +223,319 @@ public function chunk($count, callable $callback)
}
/**
- * Execute a callback over each item while chunking.
+ * Execute a callback over each item while chunking by ID.
*
- * @param callable $callback
+ * @param callable(TValue, int): mixed $callback
* @param int $count
+ * @param string|null $column
+ * @param string|null $alias
* @return bool
*/
- public function each(callable $callback, $count = 1000)
+ public function eachById(callable $callback, $count = 1000, $column = null, $alias = null)
{
- return $this->chunk($count, function ($results) use ($callback) {
+ return $this->chunkById($count, function ($results, $page) use ($callback, $count) {
foreach ($results as $key => $value) {
- if ($callback($value, $key) === false) {
+ if ($callback($value, (($page - 1) * $count) + $key) === false) {
return false;
}
}
+ }, $column, $alias);
+ }
+
+ /**
+ * Query lazily, by chunks of the given size.
+ *
+ * @param int $chunkSize
+ * @return \Illuminate\Support\LazyCollection
+ *
+ * @throws \InvalidArgumentException
+ */
+ public function lazy($chunkSize = 1000)
+ {
+ if ($chunkSize < 1) {
+ throw new InvalidArgumentException('The chunk size should be at least 1');
+ }
+
+ $this->enforceOrderBy();
+
+ return new LazyCollection(function () use ($chunkSize) {
+ $page = 1;
+
+ while (true) {
+ $results = $this->forPage($page++, $chunkSize)->get();
+
+ foreach ($results as $result) {
+ yield $result;
+ }
+
+ if ($results->count() < $chunkSize) {
+ return;
+ }
+ }
+ });
+ }
+
+ /**
+ * Query lazily, by chunking the results of a query by comparing IDs.
+ *
+ * @param int $chunkSize
+ * @param string|null $column
+ * @param string|null $alias
+ * @return \Illuminate\Support\LazyCollection
+ *
+ * @throws \InvalidArgumentException
+ */
+ public function lazyById($chunkSize = 1000, $column = null, $alias = null)
+ {
+ return $this->orderedLazyById($chunkSize, $column, $alias);
+ }
+
+ /**
+ * Query lazily, by chunking the results of a query by comparing IDs in descending order.
+ *
+ * @param int $chunkSize
+ * @param string|null $column
+ * @param string|null $alias
+ * @return \Illuminate\Support\LazyCollection
+ *
+ * @throws \InvalidArgumentException
+ */
+ public function lazyByIdDesc($chunkSize = 1000, $column = null, $alias = null)
+ {
+ return $this->orderedLazyById($chunkSize, $column, $alias, true);
+ }
+
+ /**
+ * Query lazily, by chunking the results of a query by comparing IDs in a given order.
+ *
+ * @param int $chunkSize
+ * @param string|null $column
+ * @param string|null $alias
+ * @param bool $descending
+ * @return \Illuminate\Support\LazyCollection
+ *
+ * @throws \InvalidArgumentException
+ * @throws \RuntimeException
+ */
+ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = null, $descending = false)
+ {
+ if ($chunkSize < 1) {
+ throw new InvalidArgumentException('The chunk size should be at least 1');
+ }
+
+ $column ??= $this->defaultKeyName();
+
+ $alias ??= $column;
+
+ return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending) {
+ $lastId = null;
+
+ while (true) {
+ $clone = clone $this;
+
+ if ($descending) {
+ $results = $clone->forPageBeforeId($chunkSize, $lastId, $column)->get();
+ } else {
+ $results = $clone->forPageAfterId($chunkSize, $lastId, $column)->get();
+ }
+
+ foreach ($results as $result) {
+ yield $result;
+ }
+
+ if ($results->count() < $chunkSize) {
+ return;
+ }
+
+ $lastId = $results->last()->{$alias};
+
+ if ($lastId === null) {
+ throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result.");
+ }
+ }
});
}
/**
* Execute the query and get the first result.
*
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model|object|static|null
+ * @param array|string $columns
+ * @return TValue|null
*/
public function first($columns = ['*'])
{
- return $this->take(1)->get($columns)->first();
+ return $this->limit(1)->get($columns)->first();
}
/**
- * Apply the callback's query changes if the given "value" is true.
+ * Execute the query and get the first result or throw an exception.
+ *
+ * @param array|string $columns
+ * @param string|null $message
+ * @return TValue
*
- * @param mixed $value
- * @param callable $callback
- * @param callable $default
- * @return mixed|$this
+ * @throws \Illuminate\Database\RecordNotFoundException
*/
- public function when($value, $callback, $default = null)
+ public function firstOrFail($columns = ['*'], $message = null)
{
- if ($value) {
- return $callback($this, $value) ?: $this;
- } elseif ($default) {
- return $default($this, $value) ?: $this;
+ if (! is_null($result = $this->first($columns))) {
+ return $result;
}
- return $this;
+ throw new RecordNotFoundException($message ?: 'No record found for the given query.');
}
/**
- * Pass the query to a given callback.
+ * Execute the query and get the first result if it's the sole matching record.
+ *
+ * @param array|string $columns
+ * @return TValue
*
- * @param callable $callback
- * @return \Illuminate\Database\Query\Builder
+ * @throws \Illuminate\Database\RecordsNotFoundException
+ * @throws \Illuminate\Database\MultipleRecordsFoundException
*/
- public function tap($callback)
+ public function sole($columns = ['*'])
{
- return $this->when(true, $callback);
+ $result = $this->limit(2)->get($columns);
+
+ $count = $result->count();
+
+ if ($count === 0) {
+ throw new RecordsNotFoundException;
+ }
+
+ if ($count > 1) {
+ throw new MultipleRecordsFoundException($count);
+ }
+
+ return $result->first();
}
/**
- * Apply the callback's query changes if the given "value" is false.
+ * Paginate the given query using a cursor paginator.
*
- * @param mixed $value
- * @param callable $callback
- * @param callable $default
- * @return mixed|$this
+ * @param int $perPage
+ * @param array|string $columns
+ * @param string $cursorName
+ * @param \Illuminate\Pagination\Cursor|string|null $cursor
+ * @return \Illuminate\Contracts\Pagination\CursorPaginator
*/
- public function unless($value, $callback, $default = null)
+ protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
{
- if (! $value) {
- return $callback($this, $value) ?: $this;
- } elseif ($default) {
- return $default($this, $value) ?: $this;
+ if (! $cursor instanceof Cursor) {
+ $cursor = is_string($cursor)
+ ? Cursor::fromEncoded($cursor)
+ : CursorPaginator::resolveCurrentCursor($cursorName, $cursor);
}
- return $this;
+ $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems());
+
+ if (! is_null($cursor)) {
+ // Reset the union bindings so we can add the cursor where in the correct position...
+ $this->setBindings([], 'union');
+
+ $addCursorConditions = function (self $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) {
+ $unionBuilders = $builder->getUnionBuilders();
+
+ if (! is_null($previousColumn)) {
+ $originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn);
+
+ $builder->where(
+ Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
+ '=',
+ $cursor->parameter($previousColumn)
+ );
+
+ $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) {
+ $unionBuilder->where(
+ $this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn),
+ '=',
+ $cursor->parameter($previousColumn)
+ );
+
+ $this->addBinding($unionBuilder->getRawBindings()['where'], 'union');
+ });
+ }
+
+ $builder->where(function (self $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) {
+ ['column' => $column, 'direction' => $direction] = $orders[$i];
+
+ $originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column);
+
+ $secondBuilder->where(
+ Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
+ $direction === 'asc' ? '>' : '<',
+ $cursor->parameter($column)
+ );
+
+ if ($i < $orders->count() - 1) {
+ $secondBuilder->orWhere(function (self $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
+ $addCursorConditions($thirdBuilder, $column, $originalColumn, $i + 1);
+ });
+ }
+
+ $unionBuilders->each(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions) {
+ $unionWheres = $unionBuilder->getRawBindings()['where'];
+
+ $originalColumn = $this->getOriginalColumnNameForCursorPagination($unionBuilder, $column);
+ $unionBuilder->where(function ($unionBuilder) use ($column, $direction, $cursor, $i, $orders, $addCursorConditions, $originalColumn, $unionWheres) {
+ $unionBuilder->where(
+ $originalColumn,
+ $direction === 'asc' ? '>' : '<',
+ $cursor->parameter($column)
+ );
+
+ if ($i < $orders->count() - 1) {
+ $unionBuilder->orWhere(function (self $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
+ $addCursorConditions($fourthBuilder, $column, $originalColumn, $i + 1);
+ });
+ }
+
+ $this->addBinding($unionWheres, 'union');
+ $this->addBinding($unionBuilder->getRawBindings()['where'], 'union');
+ });
+ });
+ });
+ };
+
+ $addCursorConditions($this, null, null, 0);
+ }
+
+ $this->limit($perPage + 1);
+
+ return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [
+ 'path' => Paginator::resolveCurrentPath(),
+ 'cursorName' => $cursorName,
+ 'parameters' => $orders->pluck('column')->toArray(),
+ ]);
+ }
+
+ /**
+ * Get the original column name of the given column, without any aliasing.
+ *
+ * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $builder
+ * @param string $parameter
+ * @return string
+ */
+ protected function getOriginalColumnNameForCursorPagination($builder, string $parameter)
+ {
+ $columns = $builder instanceof Builder ? $builder->getQuery()->getColumns() : $builder->getColumns();
+
+ if (! is_null($columns)) {
+ foreach ($columns as $column) {
+ if (($position = strripos($column, ' as ')) !== false) {
+ $original = substr($column, 0, $position);
+
+ $alias = substr($column, $position + 4);
+
+ if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) {
+ return $original;
+ }
+ }
+ }
+ }
+
+ return $parameter;
}
/**
@@ -147,8 +559,8 @@ protected function paginator($items, $total, $perPage, $currentPage, $options)
* Create a new simple paginator instance.
*
* @param \Illuminate\Support\Collection $items
- * @param int $perPage
- * @param int $currentPage
+ * @param int $perPage
+ * @param int $currentPage
* @param array $options
* @return \Illuminate\Pagination\Paginator
*/
@@ -158,4 +570,46 @@ protected function simplePaginator($items, $perPage, $currentPage, $options)
'items', 'perPage', 'currentPage', 'options'
));
}
+
+ /**
+ * Create a new cursor paginator instance.
+ *
+ * @param \Illuminate\Support\Collection $items
+ * @param int $perPage
+ * @param \Illuminate\Pagination\Cursor $cursor
+ * @param array $options
+ * @return \Illuminate\Pagination\CursorPaginator
+ */
+ protected function cursorPaginator($items, $perPage, $cursor, $options)
+ {
+ return Container::getInstance()->makeWith(CursorPaginator::class, compact(
+ 'items', 'perPage', 'cursor', 'options'
+ ));
+ }
+
+ /**
+ * Pass the query to a given callback and then return it.
+ *
+ * @param callable($this): mixed $callback
+ * @return $this
+ */
+ public function tap($callback)
+ {
+ $callback($this);
+
+ return $this;
+ }
+
+ /**
+ * Pass the query to a given callback and return the result.
+ *
+ * @template TReturn
+ *
+ * @param (callable($this): TReturn) $callback
+ * @return (TReturn is null|void ? $this : TReturn)
+ */
+ public function pipe($callback)
+ {
+ return $callback($this) ?? $this;
+ }
}
diff --git a/Concerns/BuildsWhereDateClauses.php b/Concerns/BuildsWhereDateClauses.php
new file mode 100644
index 0000000000..06da844273
--- /dev/null
+++ b/Concerns/BuildsWhereDateClauses.php
@@ -0,0 +1,249 @@
+wherePastOrFuture($columns, '<', 'and');
+ }
+
+ /**
+ * Add a where clause to determine if a "date" column is in the past or now to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereNowOrPast($columns)
+ {
+ return $this->wherePastOrFuture($columns, '<=', 'and');
+ }
+
+ /**
+ * Add an "or where" clause to determine if a "date" column is in the past to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWherePast($columns)
+ {
+ return $this->wherePastOrFuture($columns, '<', 'or');
+ }
+
+ /**
+ * Add a where clause to determine if a "date" column is in the past or now to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereNowOrPast($columns)
+ {
+ return $this->wherePastOrFuture($columns, '<=', 'or');
+ }
+
+ /**
+ * Add a where clause to determine if a "date" column is in the future to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereFuture($columns)
+ {
+ return $this->wherePastOrFuture($columns, '>', 'and');
+ }
+
+ /**
+ * Add a where clause to determine if a "date" column is in the future or now to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereNowOrFuture($columns)
+ {
+ return $this->wherePastOrFuture($columns, '>=', 'and');
+ }
+
+ /**
+ * Add an "or where" clause to determine if a "date" column is in the future to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereFuture($columns)
+ {
+ return $this->wherePastOrFuture($columns, '>', 'or');
+ }
+
+ /**
+ * Add an "or where" clause to determine if a "date" column is in the future or now to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereNowOrFuture($columns)
+ {
+ return $this->wherePastOrFuture($columns, '>=', 'or');
+ }
+
+ /**
+ * Add an "where" clause to determine if a "date" column is in the past or future.
+ *
+ * @param array|string $columns
+ * @param string $operator
+ * @param string $boolean
+ * @return $this
+ */
+ protected function wherePastOrFuture($columns, $operator, $boolean)
+ {
+ $type = 'Basic';
+ $value = Carbon::now();
+
+ foreach (Arr::wrap($columns) as $column) {
+ $this->wheres[] = compact('type', 'column', 'boolean', 'operator', 'value');
+
+ $this->addBinding($value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is today to the query.
+ *
+ * @param array|string $columns
+ * @param string $boolean
+ * @return $this
+ */
+ public function whereToday($columns, $boolean = 'and')
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '=', $boolean);
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is before today.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereBeforeToday($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '<', 'and');
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is today or before to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereTodayOrBefore($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '<=', 'and');
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is after today.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereAfterToday($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '>', 'and');
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is today or after to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function whereTodayOrAfter($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '>=', 'and');
+ }
+
+ /**
+ * Add an "or where date" clause to determine if a "date" column is today to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereToday($columns)
+ {
+ return $this->whereToday($columns, 'or');
+ }
+
+ /**
+ * Add an "or where date" clause to determine if a "date" column is before today.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereBeforeToday($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '<', 'or');
+ }
+
+ /**
+ * Add an "or where date" clause to determine if a "date" column is today or before to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereTodayOrBefore($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '<=', 'or');
+ }
+
+ /**
+ * Add an "or where date" clause to determine if a "date" column is after today.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereAfterToday($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '>', 'or');
+ }
+
+ /**
+ * Add an "or where date" clause to determine if a "date" column is today or after to the query.
+ *
+ * @param array|string $columns
+ * @return $this
+ */
+ public function orWhereTodayOrAfter($columns)
+ {
+ return $this->whereTodayBeforeOrAfter($columns, '>=', 'or');
+ }
+
+ /**
+ * Add a "where date" clause to determine if a "date" column is today or after to the query.
+ *
+ * @param array|string $columns
+ * @param string $operator
+ * @param string $boolean
+ * @return $this
+ */
+ protected function whereTodayBeforeOrAfter($columns, $operator, $boolean)
+ {
+ $value = Carbon::today()->format('Y-m-d');
+
+ foreach (Arr::wrap($columns) as $column) {
+ $this->addDateBasedWhere('Date', $column, $operator, $value, $boolean);
+ }
+
+ return $this;
+ }
+}
diff --git a/Concerns/CompilesJsonPaths.php b/Concerns/CompilesJsonPaths.php
new file mode 100644
index 0000000000..fb62914374
--- /dev/null
+++ b/Concerns/CompilesJsonPaths.php
@@ -0,0 +1,65 @@
+', $column, 2);
+
+ $field = $this->wrap($parts[0]);
+
+ $path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : '';
+
+ return [$field, $path];
+ }
+
+ /**
+ * Wrap the given JSON path.
+ *
+ * @param string $value
+ * @param string $delimiter
+ * @return string
+ */
+ protected function wrapJsonPath($value, $delimiter = '->')
+ {
+ $value = preg_replace("/([\\\\]+)?\\'/", "''", $value);
+
+ $jsonPath = (new Collection(explode($delimiter, $value)))
+ ->map(fn ($segment) => $this->wrapJsonPathSegment($segment))
+ ->join('.');
+
+ return "'$".(str_starts_with($jsonPath, '[') ? '' : '.').$jsonPath."'";
+ }
+
+ /**
+ * Wrap the given JSON path segment.
+ *
+ * @param string $segment
+ * @return string
+ */
+ protected function wrapJsonPathSegment($segment)
+ {
+ if (preg_match('/(\[[^\]]+\])+$/', $segment, $parts)) {
+ $key = Str::beforeLast($segment, $parts[0]);
+
+ if (! empty($key)) {
+ return '"'.$key.'"'.$parts[0];
+ }
+
+ return $parts[0];
+ }
+
+ return '"'.$segment.'"';
+ }
+}
diff --git a/Concerns/ExplainsQueries.php b/Concerns/ExplainsQueries.php
new file mode 100644
index 0000000000..7168de1e55
--- /dev/null
+++ b/Concerns/ExplainsQueries.php
@@ -0,0 +1,24 @@
+toSql();
+
+ $bindings = $this->getBindings();
+
+ $explanation = $this->getConnection()->select('EXPLAIN '.$sql, $bindings);
+
+ return new Collection($explanation);
+ }
+}
diff --git a/Concerns/ManagesTransactions.php b/Concerns/ManagesTransactions.php
index f8a3d66124..9874727d26 100644
--- a/Concerns/ManagesTransactions.php
+++ b/Concerns/ManagesTransactions.php
@@ -3,19 +3,25 @@
namespace Illuminate\Database\Concerns;
use Closure;
-use Exception;
+use Illuminate\Database\DeadlockException;
+use RuntimeException;
use Throwable;
+/**
+ * @mixin \Illuminate\Database\Connection
+ */
trait ManagesTransactions
{
/**
+ * @template TReturn of mixed
+ *
* Execute a Closure within a transaction.
*
- * @param \Closure $callback
+ * @param (\Closure(static): TReturn) $callback
* @param int $attempts
- * @return mixed
+ * @return TReturn
*
- * @throws \Exception|\Throwable
+ * @throws \Throwable
*/
public function transaction(Closure $callback, $attempts = 1)
{
@@ -26,46 +32,73 @@ public function transaction(Closure $callback, $attempts = 1)
// catch any exception we can rollback this transaction so that none of this
// gets actually persisted to a database or stored in a permanent fashion.
try {
- return tap($callback($this), function () {
- $this->commit();
- });
+ $callbackResult = $callback($this);
}
// If we catch an exception we'll rollback this transaction and try again if we
// are not out of attempts. If we are out of attempts we will just throw the
- // exception back out and let the developer handle an uncaught exceptions.
- catch (Exception $e) {
+ // exception back out, and let the developer handle an uncaught exception.
+ catch (Throwable $e) {
$this->handleTransactionException(
$e, $currentAttempt, $attempts
);
+
+ continue;
+ }
+
+ $levelBeingCommitted = $this->transactions;
+
+ try {
+ if ($this->transactions == 1) {
+ $this->fireConnectionEvent('committing');
+ $this->getPdo()->commit();
+ }
+
+ $this->transactions = max(0, $this->transactions - 1);
} catch (Throwable $e) {
- $this->rollBack();
+ $this->handleCommitTransactionException(
+ $e, $currentAttempt, $attempts
+ );
- throw $e;
+ continue;
}
+
+ $this->transactionsManager?->commit(
+ $this->getName(),
+ $levelBeingCommitted,
+ $this->transactions
+ );
+
+ $this->fireConnectionEvent('committed');
+
+ return $callbackResult;
}
}
/**
* Handle an exception encountered when running a transacted statement.
*
- * @param \Exception $e
+ * @param \Throwable $e
* @param int $currentAttempt
* @param int $maxAttempts
* @return void
*
- * @throws \Exception
+ * @throws \Throwable
*/
- protected function handleTransactionException($e, $currentAttempt, $maxAttempts)
+ protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts)
{
// On a deadlock, MySQL rolls back the entire transaction so we can't just
// retry the query. We have to throw this exception all the way out and
// let the developer handle it in another way. We will decrement too.
- if ($this->causedByDeadlock($e) &&
+ if ($this->causedByConcurrencyError($e) &&
$this->transactions > 1) {
$this->transactions--;
- throw $e;
+ $this->transactionsManager?->rollback(
+ $this->getName(), $this->transactions
+ );
+
+ throw new DeadlockException($e->getMessage(), is_int($e->getCode()) ? $e->getCode() : 0, $e);
}
// If there was an exception we will rollback this transaction and then we
@@ -73,7 +106,7 @@ protected function handleTransactionException($e, $currentAttempt, $maxAttempts)
// if we haven't we will return and try this query again in our loop.
$this->rollBack();
- if ($this->causedByDeadlock($e) &&
+ if ($this->causedByConcurrencyError($e) &&
$currentAttempt < $maxAttempts) {
return;
}
@@ -86,14 +119,22 @@ protected function handleTransactionException($e, $currentAttempt, $maxAttempts)
*
* @return void
*
- * @throws \Exception
+ * @throws \Throwable
*/
public function beginTransaction()
{
+ foreach ($this->beforeStartingTransaction as $callback) {
+ $callback($this);
+ }
+
$this->createTransaction();
$this->transactions++;
+ $this->transactionsManager?->begin(
+ $this->getName(), $this->transactions
+ );
+
$this->fireConnectionEvent('beganTransaction');
}
@@ -101,13 +142,17 @@ public function beginTransaction()
* Create a transaction within the database.
*
* @return void
+ *
+ * @throws \Throwable
*/
protected function createTransaction()
{
if ($this->transactions == 0) {
+ $this->reconnectIfMissingConnection();
+
try {
- $this->getPdo()->beginTransaction();
- } catch (Exception $e) {
+ $this->executeBeginTransactionStatement();
+ } catch (Throwable $e) {
$this->handleBeginTransactionException($e);
}
} elseif ($this->transactions >= 1 && $this->queryGrammar->supportsSavepoints()) {
@@ -119,6 +164,8 @@ protected function createTransaction()
* Create a save point within the database.
*
* @return void
+ *
+ * @throws \Throwable
*/
protected function createSavepoint()
{
@@ -133,14 +180,14 @@ protected function createSavepoint()
* @param \Throwable $e
* @return void
*
- * @throws \Exception
+ * @throws \Throwable
*/
- protected function handleBeginTransactionException($e)
+ protected function handleBeginTransactionException(Throwable $e)
{
if ($this->causedByLostConnection($e)) {
$this->reconnect();
- $this->pdo->beginTransaction();
+ $this->executeBeginTransactionStatement();
} else {
throw $e;
}
@@ -150,25 +197,60 @@ protected function handleBeginTransactionException($e)
* Commit the active database transaction.
*
* @return void
+ *
+ * @throws \Throwable
*/
public function commit()
{
- if ($this->transactions == 1) {
+ if ($this->transactionLevel() == 1) {
+ $this->fireConnectionEvent('committing');
$this->getPdo()->commit();
}
- $this->transactions = max(0, $this->transactions - 1);
+ [$levelBeingCommitted, $this->transactions] = [
+ $this->transactions,
+ max(0, $this->transactions - 1),
+ ];
+
+ $this->transactionsManager?->commit(
+ $this->getName(), $levelBeingCommitted, $this->transactions
+ );
$this->fireConnectionEvent('committed');
}
+ /**
+ * Handle an exception encountered when committing a transaction.
+ *
+ * @param \Throwable $e
+ * @param int $currentAttempt
+ * @param int $maxAttempts
+ * @return void
+ *
+ * @throws \Throwable
+ */
+ protected function handleCommitTransactionException(Throwable $e, $currentAttempt, $maxAttempts)
+ {
+ $this->transactions = max(0, $this->transactions - 1);
+
+ if ($this->causedByConcurrencyError($e) && $currentAttempt < $maxAttempts) {
+ return;
+ }
+
+ if ($this->causedByLostConnection($e)) {
+ $this->transactions = 0;
+ }
+
+ throw $e;
+ }
+
/**
* Rollback the active database transaction.
*
* @param int|null $toLevel
* @return void
*
- * @throws \Exception
+ * @throws \Throwable
*/
public function rollBack($toLevel = null)
{
@@ -176,8 +258,8 @@ public function rollBack($toLevel = null)
// that this given transaction level is valid before attempting to rollback to
// that level. If it's not we will just return out and not attempt anything.
$toLevel = is_null($toLevel)
- ? $this->transactions - 1
- : $toLevel;
+ ? $this->transactions - 1
+ : $toLevel;
if ($toLevel < 0 || $toLevel >= $this->transactions) {
return;
@@ -188,12 +270,16 @@ public function rollBack($toLevel = null)
// level that was passed into this method so it will be right from here out.
try {
$this->performRollBack($toLevel);
- } catch (Exception $e) {
+ } catch (Throwable $e) {
$this->handleRollBackException($e);
}
$this->transactions = $toLevel;
+ $this->transactionsManager?->rollback(
+ $this->getName(), $this->transactions
+ );
+
$this->fireConnectionEvent('rollingBack');
}
@@ -202,11 +288,17 @@ public function rollBack($toLevel = null)
*
* @param int $toLevel
* @return void
+ *
+ * @throws \Throwable
*/
protected function performRollBack($toLevel)
{
if ($toLevel == 0) {
- $this->getPdo()->rollBack();
+ $pdo = $this->getPdo();
+
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
} elseif ($this->queryGrammar->supportsSavepoints()) {
$this->getPdo()->exec(
$this->queryGrammar->compileSavepointRollBack('trans'.($toLevel + 1))
@@ -217,14 +309,19 @@ protected function performRollBack($toLevel)
/**
* Handle an exception from a rollback.
*
- * @param \Exception $e
+ * @param \Throwable $e
+ * @return void
*
- * @throws \Exception
+ * @throws \Throwable
*/
- protected function handleRollBackException($e)
+ protected function handleRollBackException(Throwable $e)
{
if ($this->causedByLostConnection($e)) {
$this->transactions = 0;
+
+ $this->transactionsManager?->rollback(
+ $this->getName(), $this->transactions
+ );
}
throw $e;
@@ -239,4 +336,38 @@ public function transactionLevel()
{
return $this->transactions;
}
+
+ /**
+ * Execute the callback after a transaction commits.
+ *
+ * @param callable $callback
+ * @return void
+ *
+ * @throws \RuntimeException
+ */
+ public function afterCommit($callback)
+ {
+ if ($this->transactionsManager) {
+ return $this->transactionsManager->addCallback($callback);
+ }
+
+ throw new RuntimeException('Transactions Manager has not been set.');
+ }
+
+ /**
+ * Execute the callback after a transaction rolls back.
+ *
+ * @param callable $callback
+ * @return void
+ *
+ * @throws \RuntimeException
+ */
+ public function afterRollBack($callback)
+ {
+ if ($this->transactionsManager) {
+ return $this->transactionsManager->addCallbackForRollback($callback);
+ }
+
+ throw new RuntimeException('Transactions Manager has not been set.');
+ }
}
diff --git a/Concerns/ParsesSearchPath.php b/Concerns/ParsesSearchPath.php
new file mode 100644
index 0000000000..e822c722b7
--- /dev/null
+++ b/Concerns/ParsesSearchPath.php
@@ -0,0 +1,25 @@
+getCode() === 40001 || $e->getCode() === '40001')) {
+ return true;
+ }
+
$message = $e->getMessage();
return Str::contains($message, [
@@ -27,6 +33,7 @@ protected function causedByDeadlock(Exception $e)
'has been chosen as the deadlock victim',
'Lock wait timeout exceeded; try restarting transaction',
'WSREP detected deadlock/conflict and aborted the transaction. Try restarting the transaction',
+ 'Record has changed since last read in table',
]);
}
}
diff --git a/ConfigurationUrlParser.php b/ConfigurationUrlParser.php
new file mode 100644
index 0000000000..bc7c624a28
--- /dev/null
+++ b/ConfigurationUrlParser.php
@@ -0,0 +1,10 @@
+): mixed)}[]
+ */
+ protected $queryDurationHandlers = [];
+
/**
* Indicates if the connection is in a "dry run".
*
@@ -137,27 +190,40 @@ class Connection implements ConnectionInterface
protected $pretending = false;
/**
- * The instance of Doctrine connection.
+ * All of the callbacks that should be invoked before a transaction is started.
+ *
+ * @var \Closure[]
+ */
+ protected $beforeStartingTransaction = [];
+
+ /**
+ * All of the callbacks that should be invoked before a query is executed.
*
- * @var \Doctrine\DBAL\Connection
+ * @var (\Closure(string, array, \Illuminate\Database\Connection): mixed)[]
*/
- protected $doctrineConnection;
+ protected $beforeExecutingCallbacks = [];
/**
* The connection resolvers.
*
- * @var array
+ * @var \Closure[]
*/
protected static $resolvers = [];
+ /**
+ * The last retrieved PDO read / write type.
+ *
+ * @var null|'read'|'write'
+ */
+ protected $latestPdoTypeRetrieved = null;
+
/**
* Create a new database connection instance.
*
- * @param \PDO|\Closure $pdo
- * @param string $database
- * @param string $tablePrefix
- * @param array $config
- * @return void
+ * @param \PDO|(\Closure(): \PDO) $pdo
+ * @param string $database
+ * @param string $tablePrefix
+ * @param array $config
*/
public function __construct($pdo, $database = '', $tablePrefix = '', array $config = [])
{
@@ -197,7 +263,7 @@ public function useDefaultQueryGrammar()
*/
protected function getDefaultQueryGrammar()
{
- return new QueryGrammar;
+ return new QueryGrammar($this);
}
/**
@@ -213,7 +279,7 @@ public function useDefaultSchemaGrammar()
/**
* Get the default schema grammar instance.
*
- * @return \Illuminate\Database\Schema\Grammars\Grammar
+ * @return \Illuminate\Database\Schema\Grammars\Grammar|null
*/
protected function getDefaultSchemaGrammar()
{
@@ -257,12 +323,13 @@ public function getSchemaBuilder()
/**
* Begin a fluent query against a database table.
*
- * @param string $table
+ * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|\UnitEnum|string $table
+ * @param string|null $as
* @return \Illuminate\Database\Query\Builder
*/
- public function table($table)
+ public function table($table, $as = null)
{
- return $this->query()->from($table);
+ return $this->query()->from(enum_value($table), $as);
}
/**
@@ -281,7 +348,7 @@ public function query()
* Run a select statement and return a single result.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @param bool $useReadPdo
* @return mixed
*/
@@ -292,11 +359,38 @@ public function selectOne($query, $bindings = [], $useReadPdo = true)
return array_shift($records);
}
+ /**
+ * Run a select statement and return the first column of the first row.
+ *
+ * @param string $query
+ * @param array $bindings
+ * @param bool $useReadPdo
+ * @return mixed
+ *
+ * @throws \Illuminate\Database\MultipleColumnsSelectedException
+ */
+ public function scalar($query, $bindings = [], $useReadPdo = true)
+ {
+ $record = $this->selectOne($query, $bindings, $useReadPdo);
+
+ if (is_null($record)) {
+ return null;
+ }
+
+ $record = (array) $record;
+
+ if (count($record) > 1) {
+ throw new MultipleColumnsSelectedException;
+ }
+
+ return array_first($record);
+ }
+
/**
* Run a select statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return array
*/
public function selectFromWriteConnection($query, $bindings = [])
@@ -310,11 +404,12 @@ public function selectFromWriteConnection($query, $bindings = [])
* @param string $query
* @param array $bindings
* @param bool $useReadPdo
+ * @param array $fetchUsing
* @return array
*/
- public function select($query, $bindings = [], $useReadPdo = true)
+ public function select($query, $bindings = [], $useReadPdo = true, array $fetchUsing = [])
{
- return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
+ return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) {
if ($this->pretending()) {
return [];
}
@@ -322,14 +417,49 @@ public function select($query, $bindings = [], $useReadPdo = true)
// For select statements, we'll simply execute the query and return an array
// of the database result set. Each element in the array will be a single
// row from the database table, and will either be an array or objects.
- $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
- ->prepare($query));
+ $statement = $this->prepared(
+ $this->getPdoForSelect($useReadPdo)->prepare($query)
+ );
+
+ $this->bindValues($statement, $this->prepareBindings($bindings));
+
+ $statement->execute();
+
+ return $statement->fetchAll(...$fetchUsing);
+ });
+ }
+
+ /**
+ * Run a select statement against the database and returns all of the result sets.
+ *
+ * @param string $query
+ * @param array $bindings
+ * @param bool $useReadPdo
+ * @param array $fetchUsing
+ * @return array
+ */
+ public function selectResultSets($query, $bindings = [], $useReadPdo = true, array $fetchUsing = [])
+ {
+ return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo, $fetchUsing) {
+ if ($this->pretending()) {
+ return [];
+ }
+
+ $statement = $this->prepared(
+ $this->getPdoForSelect($useReadPdo)->prepare($query)
+ );
$this->bindValues($statement, $this->prepareBindings($bindings));
$statement->execute();
- return $statement->fetchAll();
+ $sets = [];
+
+ do {
+ $sets[] = $statement->fetchAll(...$fetchUsing);
+ } while ($statement->nextRowset());
+
+ return $sets;
});
}
@@ -339,9 +469,10 @@ public function select($query, $bindings = [], $useReadPdo = true)
* @param string $query
* @param array $bindings
* @param bool $useReadPdo
- * @return \Generator
+ * @param array $fetchUsing
+ * @return \Generator
*/
- public function cursor($query, $bindings = [], $useReadPdo = true)
+ public function cursor($query, $bindings = [], $useReadPdo = true, array $fetchUsing = [])
{
$statement = $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
if ($this->pretending()) {
@@ -352,7 +483,7 @@ public function cursor($query, $bindings = [], $useReadPdo = true)
// mode and prepare the bindings for the query. Once that's done we will be
// ready to execute the query against the database and return the cursor.
$statement = $this->prepared($this->getPdoForSelect($useReadPdo)
- ->prepare($query));
+ ->prepare($query));
$this->bindValues(
$statement, $this->prepareBindings($bindings)
@@ -366,7 +497,7 @@ public function cursor($query, $bindings = [], $useReadPdo = true)
return $statement;
});
- while ($record = $statement->fetch()) {
+ while ($record = $statement->fetch(...$fetchUsing)) {
yield $record;
}
}
@@ -381,9 +512,7 @@ protected function prepared(PDOStatement $statement)
{
$statement->setFetchMode($this->fetchMode);
- $this->event(new Events\StatementPrepared(
- $this, $statement
- ));
+ $this->event(new StatementPrepared($this, $statement));
return $statement;
}
@@ -403,7 +532,7 @@ protected function getPdoForSelect($useReadPdo = true)
* Run an insert statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return bool
*/
public function insert($query, $bindings = [])
@@ -415,7 +544,7 @@ public function insert($query, $bindings = [])
* Run an update statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function update($query, $bindings = [])
@@ -427,7 +556,7 @@ public function update($query, $bindings = [])
* Run a delete statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function delete($query, $bindings = [])
@@ -439,7 +568,7 @@ public function delete($query, $bindings = [])
* Execute an SQL statement and return the boolean result.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return bool
*/
public function statement($query, $bindings = [])
@@ -463,7 +592,7 @@ public function statement($query, $bindings = [])
* Run an SQL statement and get the number of rows affected.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function affectingStatement($query, $bindings = [])
@@ -511,33 +640,68 @@ public function unprepared($query)
});
}
+ /**
+ * Get the number of open connections for the database.
+ *
+ * @return int|null
+ */
+ public function threadCount()
+ {
+ $query = $this->getQueryGrammar()->compileThreadCount();
+
+ return $query ? $this->scalar($query) : null;
+ }
+
/**
* Execute the given callback in "dry run" mode.
*
- * @param \Closure $callback
- * @return array
+ * @param (\Closure(\Illuminate\Database\Connection): mixed) $callback
+ * @return array{query: string, bindings: array, time: float|null}[]
*/
public function pretend(Closure $callback)
{
return $this->withFreshQueryLog(function () use ($callback) {
$this->pretending = true;
- // Basically to make the database connection "pretend", we will just return
- // the default values for all the query methods, then we will return an
- // array of queries that were "executed" within the Closure callback.
- $callback($this);
+ try {
+ // Basically to make the database connection "pretend", we will just return
+ // the default values for all the query methods, then we will return an
+ // array of queries that were "executed" within the Closure callback.
+ $callback($this);
- $this->pretending = false;
-
- return $this->queryLog;
+ return $this->queryLog;
+ } finally {
+ $this->pretending = false;
+ }
});
}
/**
- * Execute the given callback in "dry run" mode.
+ * Execute the given callback without "pretending".
*
* @param \Closure $callback
- * @return array
+ * @return mixed
+ */
+ public function withoutPretending(Closure $callback)
+ {
+ if (! $this->pretending) {
+ return $callback();
+ }
+
+ $this->pretending = false;
+
+ try {
+ return $callback();
+ } finally {
+ $this->pretending = true;
+ }
+ }
+
+ /**
+ * Execute the given callback in "dry run" mode.
+ *
+ * @param (\Closure(): array{query: string, bindings: array, time: float|null}[]) $callback
+ * @return array{query: string, bindings: array, time: float|null}[]
*/
protected function withFreshQueryLog($callback)
{
@@ -563,7 +727,7 @@ protected function withFreshQueryLog($callback)
/**
* Bind values to their parameters in the given statement.
*
- * @param \PDOStatement $statement
+ * @param \PDOStatement $statement
* @param array $bindings
* @return void
*/
@@ -571,8 +735,13 @@ public function bindValues($statement, $bindings)
{
foreach ($bindings as $key => $value) {
$statement->bindValue(
- is_string($key) ? $key : $key + 1, $value,
- is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR
+ is_string($key) ? $key : $key + 1,
+ $value,
+ match (true) {
+ is_int($value) => PDO::PARAM_INT,
+ is_resource($value) => PDO::PARAM_LOB,
+ default => PDO::PARAM_STR
+ },
);
}
}
@@ -604,8 +773,8 @@ public function prepareBindings(array $bindings)
/**
* Run a SQL statement and log its execution context.
*
- * @param string $query
- * @param array $bindings
+ * @param string $query
+ * @param array $bindings
* @param \Closure $callback
* @return mixed
*
@@ -613,6 +782,10 @@ public function prepareBindings(array $bindings)
*/
protected function run($query, $bindings, Closure $callback)
{
+ foreach ($this->beforeExecutingCallbacks as $beforeExecutingCallback) {
+ $beforeExecutingCallback($query, $bindings, $this);
+ }
+
$this->reconnectIfMissingConnection();
$start = microtime(true);
@@ -641,8 +814,8 @@ protected function run($query, $bindings, Closure $callback)
/**
* Run a SQL statement.
*
- * @param string $query
- * @param array $bindings
+ * @param string $query
+ * @param array $bindings
* @param \Closure $callback
* @return mixed
*
@@ -654,42 +827,68 @@ protected function runQueryCallback($query, $bindings, Closure $callback)
// run the SQL against the PDO connection. Then we can calculate the time it
// took to execute and log the query SQL, bindings and time in our memory.
try {
- $result = $callback($query, $bindings);
+ return $callback($query, $bindings);
}
// If an exception occurs when attempting to run a query, we'll format the error
// 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) {
- throw new QueryException(
- $query, $this->prepareBindings($bindings), $e
+ $exceptionType = $this->isUniqueConstraintError($e)
+ ? UniqueConstraintViolationException::class
+ : QueryException::class;
+
+ throw new $exceptionType(
+ $this->getNameWithReadWriteType(),
+ $query,
+ $this->prepareBindings($bindings),
+ $e,
+ $this->getConnectionDetails(),
+ $this->latestReadWriteTypeUsed(),
);
}
+ }
- return $result;
+ /**
+ * 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.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @param float|null $time
* @return void
*/
public function logQuery($query, $bindings, $time = null)
{
- $this->event(new QueryExecuted($query, $bindings, $time, $this));
+ $this->totalQueryDuration += $time ?? 0.0;
+
+ $readWriteType = $this->latestReadWriteTypeUsed();
+
+ $this->event(new QueryExecuted($query, $bindings, $time, $this, $readWriteType));
+
+ $query = $this->pretending === true
+ ? $this->queryGrammar?->substituteBindingsIntoRawSql($query, $bindings) ?? $query
+ : $query;
if ($this->loggingQueries) {
- $this->queryLog[] = compact('query', 'bindings', 'time');
+ $this->queryLog[] = compact('query', 'bindings', 'time', 'readWriteType');
}
}
/**
- * Get the elapsed time since a given starting point.
+ * Get the elapsed time in milliseconds since a given starting point.
*
- * @param int $start
+ * @param float $start
* @return float
*/
protected function getElapsedTime($start)
@@ -697,18 +896,83 @@ protected function getElapsedTime($start)
return round((microtime(true) - $start) * 1000, 2);
}
+ /**
+ * Register a callback to be invoked when the connection queries for longer than a given amount of time.
+ *
+ * @param \DateTimeInterface|\Carbon\CarbonInterval|float|int $threshold
+ * @param (callable(\Illuminate\Database\Connection, \Illuminate\Database\Events\QueryExecuted): mixed) $handler
+ * @return void
+ */
+ public function whenQueryingForLongerThan($threshold, $handler)
+ {
+ $threshold = $threshold instanceof DateTimeInterface
+ ? $this->secondsUntil($threshold) * 1000
+ : $threshold;
+
+ $threshold = $threshold instanceof CarbonInterval
+ ? $threshold->totalMilliseconds
+ : $threshold;
+
+ $this->queryDurationHandlers[] = [
+ 'has_run' => false,
+ 'handler' => $handler,
+ ];
+
+ $key = count($this->queryDurationHandlers) - 1;
+
+ $this->listen(function ($event) use ($threshold, $handler, $key) {
+ if (! $this->queryDurationHandlers[$key]['has_run'] && $this->totalQueryDuration() > $threshold) {
+ $handler($this, $event);
+
+ $this->queryDurationHandlers[$key]['has_run'] = true;
+ }
+ });
+ }
+
+ /**
+ * Allow all the query duration handlers to run again, even if they have already run.
+ *
+ * @return void
+ */
+ public function allowQueryDurationHandlersToRunAgain()
+ {
+ foreach ($this->queryDurationHandlers as $key => $queryDurationHandler) {
+ $this->queryDurationHandlers[$key]['has_run'] = false;
+ }
+ }
+
+ /**
+ * Get the duration of all run queries in milliseconds.
+ *
+ * @return float
+ */
+ public function totalQueryDuration()
+ {
+ return $this->totalQueryDuration;
+ }
+
+ /**
+ * Reset the duration of all run queries.
+ *
+ * @return void
+ */
+ public function resetTotalQueryDuration()
+ {
+ $this->totalQueryDuration = 0.0;
+ }
+
/**
* Handle a query exception.
*
- * @param \Exception $e
+ * @param \Illuminate\Database\QueryException $e
* @param string $query
* @param array $bindings
* @param \Closure $callback
* @return mixed
*
- * @throws \Exception
+ * @throws \Illuminate\Database\QueryException
*/
- protected function handleQueryException($e, $query, $bindings, Closure $callback)
+ protected function handleQueryException(QueryException $e, $query, $bindings, Closure $callback)
{
if ($this->transactions >= 1) {
throw $e;
@@ -723,8 +987,8 @@ protected function handleQueryException($e, $query, $bindings, Closure $callback
* Handle a query exception that occurred during query execution.
*
* @param \Illuminate\Database\QueryException $e
- * @param string $query
- * @param array $bindings
+ * @param string $query
+ * @param array $bindings
* @param \Closure $callback
* @return mixed
*
@@ -744,19 +1008,17 @@ protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $
/**
* Reconnect to the database.
*
- * @return void
+ * @return mixed|false
*
- * @throws \LogicException
+ * @throws \Illuminate\Database\LostConnectionException
*/
public function reconnect()
{
if (is_callable($this->reconnector)) {
- $this->doctrineConnection = null;
-
return call_user_func($this->reconnector, $this);
}
- throw new LogicException('Lost connection and no reconnector available.');
+ throw new LostConnectionException('Lost connection and no reconnector available.');
}
/**
@@ -764,7 +1026,7 @@ public function reconnect()
*
* @return void
*/
- protected function reconnectIfMissingConnection()
+ public function reconnectIfMissingConnection()
{
if (is_null($this->pdo)) {
$this->reconnect();
@@ -782,16 +1044,40 @@ public function disconnect()
}
/**
- * Register a database query listener with the connection.
+ * Register a hook to be run just before a database transaction is started.
+ *
+ * @param \Closure $callback
+ * @return $this
+ */
+ public function beforeStartingTransaction(Closure $callback)
+ {
+ $this->beforeStartingTransaction[] = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Register a hook to be run just before a database query is executed.
*
* @param \Closure $callback
+ * @return $this
+ */
+ public function beforeExecuting(Closure $callback)
+ {
+ $this->beforeExecutingCallbacks[] = $callback;
+
+ return $this;
+ }
+
+ /**
+ * Register a database query listener with the connection.
+ *
+ * @param \Closure(\Illuminate\Database\Events\QueryExecuted) $callback
* @return void
*/
public function listen(Closure $callback)
{
- if (isset($this->events)) {
- $this->events->listen(Events\QueryExecuted::class, $callback);
- }
+ $this->events?->listen(Events\QueryExecuted::class, $callback);
}
/**
@@ -802,18 +1088,13 @@ public function listen(Closure $callback)
*/
protected function fireConnectionEvent($event)
{
- if (! isset($this->events)) {
- return;
- }
-
- switch ($event) {
- case 'beganTransaction':
- return $this->events->dispatch(new Events\TransactionBeginning($this));
- case 'committed':
- return $this->events->dispatch(new Events\TransactionCommitted($this));
- case 'rollingBack':
- return $this->events->dispatch(new Events\TransactionRolledBack($this));
- }
+ return $this->events?->dispatch(match ($event) {
+ 'beganTransaction' => new TransactionBeginning($this),
+ 'committed' => new TransactionCommitted($this),
+ 'committing' => new TransactionCommitting($this),
+ 'rollingBack' => new TransactionRolledBack($this),
+ default => null,
+ });
}
/**
@@ -824,16 +1105,14 @@ protected function fireConnectionEvent($event)
*/
protected function event($event)
{
- if (isset($this->events)) {
- $this->events->dispatch($event);
- }
+ $this->events?->dispatch($event);
}
/**
* Get a new raw query expression.
*
* @param mixed $value
- * @return \Illuminate\Database\Query\Expression
+ * @return \Illuminate\Contracts\Database\Query\Expression
*/
public function raw($value)
{
@@ -841,70 +1120,131 @@ public function raw($value)
}
/**
- * Indicate if any records have been modified.
+ * Escape a value for safe SQL embedding.
+ *
+ * @param string|float|int|bool|null $value
+ * @param bool $binary
+ * @return string
+ *
+ * @throws \RuntimeException
+ */
+ public function escape($value, $binary = false)
+ {
+ if ($value === null) {
+ return 'null';
+ } elseif ($binary) {
+ return $this->escapeBinary($value);
+ } elseif (is_int($value) || is_float($value)) {
+ return (string) $value;
+ } elseif (is_bool($value)) {
+ return $this->escapeBool($value);
+ } elseif (is_array($value)) {
+ throw new RuntimeException('The database connection does not support escaping arrays.');
+ } else {
+ if (str_contains($value, "\00")) {
+ throw new RuntimeException('Strings with null bytes cannot be escaped. Use the binary escape option.');
+ }
+
+ if (preg_match('//u', $value) === false) {
+ throw new RuntimeException('Strings with invalid UTF-8 byte sequences cannot be escaped.');
+ }
+
+ return $this->escapeString($value);
+ }
+ }
+
+ /**
+ * Escape a string value for safe SQL embedding.
+ *
+ * @param string $value
+ * @return string
+ */
+ protected function escapeString($value)
+ {
+ return $this->getReadPdo()->quote($value);
+ }
+
+ /**
+ * Escape a boolean value for safe SQL embedding.
*
* @param bool $value
- * @return void
+ * @return string
*/
- public function recordsHaveBeenModified($value = true)
+ protected function escapeBool($value)
{
- if (! $this->recordsModified) {
- $this->recordsModified = $value;
- }
+ return $value ? '1' : '0';
}
/**
- * Is Doctrine available?
+ * Escape a binary value for safe SQL embedding.
*
- * @return bool
+ * @param string $value
+ * @return string
+ *
+ * @throws \RuntimeException
*/
- public function isDoctrineAvailable()
+ protected function escapeBinary($value)
{
- return class_exists('Doctrine\DBAL\Connection');
+ throw new RuntimeException('The database connection does not support escaping binary values.');
}
/**
- * Get a Doctrine Schema Column instance.
+ * Determine if the database connection has modified any database records.
*
- * @param string $table
- * @param string $column
- * @return \Doctrine\DBAL\Schema\Column
+ * @return bool
*/
- public function getDoctrineColumn($table, $column)
+ public function hasModifiedRecords()
{
- $schema = $this->getDoctrineSchemaManager();
+ return $this->recordsModified;
+ }
- return $schema->listTableDetails($table)->getColumn($column);
+ /**
+ * Indicate if any records have been modified.
+ *
+ * @param bool $value
+ * @return void
+ */
+ public function recordsHaveBeenModified($value = true)
+ {
+ if (! $this->recordsModified) {
+ $this->recordsModified = $value;
+ }
}
/**
- * Get the Doctrine DBAL schema manager for the connection.
+ * Set the record modification state.
*
- * @return \Doctrine\DBAL\Schema\AbstractSchemaManager
+ * @param bool $value
+ * @return $this
*/
- public function getDoctrineSchemaManager()
+ public function setRecordModificationState(bool $value)
{
- return $this->getDoctrineDriver()->getSchemaManager($this->getDoctrineConnection());
+ $this->recordsModified = $value;
+
+ return $this;
}
/**
- * Get the Doctrine DBAL database connection instance.
+ * Reset the record modification state.
*
- * @return \Doctrine\DBAL\Connection
+ * @return void
*/
- public function getDoctrineConnection()
+ public function forgetRecordModificationState()
{
- if (is_null($this->doctrineConnection)) {
- $driver = $this->getDoctrineDriver();
+ $this->recordsModified = false;
+ }
- $this->doctrineConnection = new DoctrineConnection([
- 'pdo' => $this->getPdo(),
- 'dbname' => $this->getConfig('database'),
- 'driver' => $driver->getName(),
- ], $driver);
- }
+ /**
+ * Indicate that the connection should use the write PDO connection for reads.
+ *
+ * @param bool $value
+ * @return $this
+ */
+ public function useWriteConnectionWhenReading($value = true)
+ {
+ $this->readOnWriteConnection = $value;
- return $this->doctrineConnection;
+ return $this;
}
/**
@@ -914,6 +1254,8 @@ public function getDoctrineConnection()
*/
public function getPdo()
{
+ $this->latestPdoTypeRetrieved = 'write';
+
if ($this->pdo instanceof Closure) {
return $this->pdo = call_user_func($this->pdo);
}
@@ -921,6 +1263,16 @@ public function getPdo()
return $this->pdo;
}
+ /**
+ * Get the current PDO connection parameter without executing any reconnect logic.
+ *
+ * @return \PDO|\Closure|null
+ */
+ public function getRawPdo()
+ {
+ return $this->pdo;
+ }
+
/**
* Get the current PDO connection used for reading.
*
@@ -932,10 +1284,13 @@ public function getReadPdo()
return $this->getPdo();
}
- if ($this->recordsModified && $this->getConfig('sticky')) {
+ if ($this->readOnWriteConnection ||
+ ($this->recordsModified && $this->getConfig('sticky'))) {
return $this->getPdo();
}
+ $this->latestPdoTypeRetrieved = 'read';
+
if ($this->readPdo instanceof Closure) {
return $this->readPdo = call_user_func($this->readPdo);
}
@@ -943,6 +1298,16 @@ public function getReadPdo()
return $this->readPdo ?: $this->getPdo();
}
+ /**
+ * Get the current read PDO connection parameter without executing any reconnect logic.
+ *
+ * @return \PDO|\Closure|null
+ */
+ public function getRawReadPdo()
+ {
+ return $this->readPdo;
+ }
+
/**
* Set the PDO connection.
*
@@ -971,10 +1336,23 @@ public function setReadPdo($pdo)
return $this;
}
+ /**
+ * Set the read PDO connection configuration.
+ *
+ * @param array $config
+ * @return $this
+ */
+ public function setReadPdoConfig(array $config)
+ {
+ $this->readPdoConfig = $config;
+
+ return $this;
+ }
+
/**
* Set the reconnect instance on the connection.
*
- * @param callable $reconnector
+ * @param (callable(\Illuminate\Database\Connection): mixed) $reconnector
* @return $this
*/
public function setReconnector(callable $reconnector)
@@ -994,6 +1372,18 @@ public function getName()
return $this->getConfig('name');
}
+ /**
+ * Get the database connection with its read / write type.
+ *
+ * @return string|null
+ */
+ public function getNameWithReadWriteType()
+ {
+ $name = $this->getName().($this->readWriteType ? '::'.$this->readWriteType : '');
+
+ return empty($name) ? null : $name;
+ }
+
/**
* Get an option from the configuration options.
*
@@ -1005,6 +1395,27 @@ public function getConfig($option = null)
return Arr::get($this->config, $option);
}
+ /**
+ * Get the basic connection information as an array for debugging.
+ *
+ * @return array
+ */
+ protected function getConnectionDetails()
+ {
+ $config = $this->latestReadWriteTypeUsed() === 'read'
+ ? $this->readPdoConfig
+ : $this->config;
+
+ return [
+ 'driver' => $this->getDriverName(),
+ 'name' => $this->getNameWithReadWriteType(),
+ 'host' => $config['host'] ?? null,
+ 'port' => $config['port'] ?? null,
+ 'database' => $config['database'] ?? null,
+ 'unix_socket' => $config['unix_socket'] ?? null,
+ ];
+ }
+
/**
* Get the PDO driver name.
*
@@ -1015,6 +1426,16 @@ public function getDriverName()
return $this->getConfig('driver');
}
+ /**
+ * Get a human-readable name for the given connection driver.
+ *
+ * @return string
+ */
+ public function getDriverTitle()
+ {
+ return $this->getDriverName();
+ }
+
/**
* Get the query grammar used by the connection.
*
@@ -1087,7 +1508,7 @@ public function setPostProcessor(Processor $processor)
/**
* Get the event dispatcher used by the connection.
*
- * @return \Illuminate\Contracts\Events\Dispatcher
+ * @return \Illuminate\Contracts\Events\Dispatcher|null
*/
public function getEventDispatcher()
{
@@ -1118,7 +1539,40 @@ public function unsetEventDispatcher()
}
/**
- * Determine if the connection in a "dry run".
+ * Run the statement to start a new transaction.
+ *
+ * @return void
+ */
+ protected function executeBeginTransactionStatement()
+ {
+ $this->getPdo()->beginTransaction();
+ }
+
+ /**
+ * Set the transaction manager instance on the connection.
+ *
+ * @param \Illuminate\Database\DatabaseTransactionsManager $manager
+ * @return $this
+ */
+ public function setTransactionManager($manager)
+ {
+ $this->transactionsManager = $manager;
+
+ return $this;
+ }
+
+ /**
+ * Unset the transaction manager for this connection.
+ *
+ * @return void
+ */
+ public function unsetTransactionManager()
+ {
+ $this->transactionsManager = null;
+ }
+
+ /**
+ * Determine if the connection is in a "dry run".
*
* @return bool
*/
@@ -1130,13 +1584,29 @@ public function pretending()
/**
* Get the connection query log.
*
- * @return array
+ * @return array{query: string, bindings: array, time: float|null}[]
*/
public function getQueryLog()
{
return $this->queryLog;
}
+ /**
+ * Get the connection query log with embedded bindings.
+ *
+ * @return array
+ */
+ public function getRawQueryLog()
+ {
+ return array_map(fn (array $log) => [
+ 'raw_query' => $this->queryGrammar->substituteBindingsIntoRawSql(
+ $log['query'],
+ $this->prepareBindings($log['bindings'])
+ ),
+ 'time' => $log['time'],
+ ], $this->getQueryLog());
+ }
+
/**
* Clear the query log.
*
@@ -1200,6 +1670,29 @@ public function setDatabaseName($database)
return $this;
}
+ /**
+ * Set the read / write type of the connection.
+ *
+ * @param string|null $readWriteType
+ * @return $this
+ */
+ public function setReadWriteType($readWriteType)
+ {
+ $this->readWriteType = $readWriteType;
+
+ return $this;
+ }
+
+ /**
+ * Retrieve the latest read / write type used.
+ *
+ * @return 'read'|'write'|null
+ */
+ protected function latestReadWriteTypeUsed()
+ {
+ return $this->readWriteType ?? $this->latestPdoTypeRetrieved;
+ }
+
/**
* Get the table prefix for the connection.
*
@@ -1220,22 +1713,36 @@ public function setTablePrefix($prefix)
{
$this->tablePrefix = $prefix;
- $this->getQueryGrammar()->setTablePrefix($prefix);
-
return $this;
}
/**
- * Set the table prefix and return the grammar.
+ * Execute the given callback without table prefix.
*
- * @param \Illuminate\Database\Grammar $grammar
- * @return \Illuminate\Database\Grammar
+ * @param \Closure $callback
+ * @return mixed
*/
- public function withTablePrefix(Grammar $grammar)
+ public function withoutTablePrefix(Closure $callback): mixed
{
- $grammar->setTablePrefix($this->tablePrefix);
+ $tablePrefix = $this->getTablePrefix();
+
+ $this->setTablePrefix('');
+
+ try {
+ return $callback($this);
+ } finally {
+ $this->setTablePrefix($tablePrefix);
+ }
+ }
- return $grammar;
+ /**
+ * Get the server version for the connection.
+ *
+ * @return string
+ */
+ public function getServerVersion(): string
+ {
+ return $this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
}
/**
@@ -1254,10 +1761,25 @@ public static function resolverFor($driver, Closure $callback)
* Get the connection resolver for the given driver.
*
* @param string $driver
- * @return mixed
+ * @return \Closure|null
*/
public static function getResolver($driver)
{
return static::$resolvers[$driver] ?? null;
}
+
+ /**
+ * Prepare the instance for cloning.
+ *
+ * @return void
+ */
+ public function __clone()
+ {
+ // When cloning, re-initialize grammars to reference cloned connection...
+ $this->useDefaultQueryGrammar();
+
+ if (! is_null($this->schemaGrammar)) {
+ $this->useDefaultSchemaGrammar();
+ }
+ }
}
diff --git a/ConnectionInterface.php b/ConnectionInterface.php
index 56127e1c7a..69e1fafa38 100755
--- a/ConnectionInterface.php
+++ b/ConnectionInterface.php
@@ -9,16 +9,17 @@ interface ConnectionInterface
/**
* Begin a fluent query against a database table.
*
- * @param string $table
+ * @param \Closure|\Illuminate\Database\Query\Builder|\UnitEnum|string $table
+ * @param string|null $as
* @return \Illuminate\Database\Query\Builder
*/
- public function table($table);
+ public function table($table, $as = null);
/**
* Get a new raw query expression.
*
* @param mixed $value
- * @return \Illuminate\Database\Query\Expression
+ * @return \Illuminate\Contracts\Database\Query\Expression
*/
public function raw($value);
@@ -26,21 +27,34 @@ public function raw($value);
* Run a select statement and return a single result.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @param bool $useReadPdo
* @return mixed
*/
public function selectOne($query, $bindings = [], $useReadPdo = true);
+ /**
+ * Run a select statement and return the first column of the first row.
+ *
+ * @param string $query
+ * @param array $bindings
+ * @param bool $useReadPdo
+ * @return mixed
+ *
+ * @throws \Illuminate\Database\MultipleColumnsSelectedException
+ */
+ public function scalar($query, $bindings = [], $useReadPdo = true);
+
/**
* Run a select statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @param bool $useReadPdo
+ * @param array $fetchUsing
* @return array
*/
- public function select($query, $bindings = [], $useReadPdo = true);
+ public function select($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []);
/**
* Run a select statement against the database and returns a generator.
@@ -48,15 +62,16 @@ public function select($query, $bindings = [], $useReadPdo = true);
* @param string $query
* @param array $bindings
* @param bool $useReadPdo
+ * @param array $fetchUsing
* @return \Generator
*/
- public function cursor($query, $bindings = [], $useReadPdo = true);
+ public function cursor($query, $bindings = [], $useReadPdo = true, array $fetchUsing = []);
/**
* Run an insert statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return bool
*/
public function insert($query, $bindings = []);
@@ -65,7 +80,7 @@ public function insert($query, $bindings = []);
* Run an update statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function update($query, $bindings = []);
@@ -74,7 +89,7 @@ public function update($query, $bindings = []);
* Run a delete statement against the database.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function delete($query, $bindings = []);
@@ -83,7 +98,7 @@ public function delete($query, $bindings = []);
* Execute an SQL statement and return the boolean result.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return bool
*/
public function statement($query, $bindings = []);
@@ -92,7 +107,7 @@ public function statement($query, $bindings = []);
* Run an SQL statement and get the number of rows affected.
*
* @param string $query
- * @param array $bindings
+ * @param array $bindings
* @return int
*/
public function affectingStatement($query, $bindings = []);
@@ -159,4 +174,11 @@ public function transactionLevel();
* @return array
*/
public function pretend(Closure $callback);
+
+ /**
+ * Get the name of the connected database.
+ *
+ * @return string
+ */
+ public function getDatabaseName();
}
diff --git a/ConnectionResolver.php b/ConnectionResolver.php
index 425ab6bce6..b7b6279e1f 100755
--- a/ConnectionResolver.php
+++ b/ConnectionResolver.php
@@ -7,7 +7,7 @@ class ConnectionResolver implements ConnectionResolverInterface
/**
* All of the registered connections.
*
- * @var array
+ * @var \Illuminate\Database\ConnectionInterface[]
*/
protected $connections = [];
@@ -21,8 +21,7 @@ class ConnectionResolver implements ConnectionResolverInterface
/**
* Create a new connection resolver instance.
*
- * @param array $connections
- * @return void
+ * @param array $connections
*/
public function __construct(array $connections = [])
{
@@ -34,7 +33,7 @@ public function __construct(array $connections = [])
/**
* Get a database connection instance.
*
- * @param string $name
+ * @param string|null $name
* @return \Illuminate\Database\ConnectionInterface
*/
public function connection($name = null)
diff --git a/ConnectionResolverInterface.php b/ConnectionResolverInterface.php
index eb0397a5d7..47161d37d6 100755
--- a/ConnectionResolverInterface.php
+++ b/ConnectionResolverInterface.php
@@ -7,7 +7,7 @@ interface ConnectionResolverInterface
/**
* Get a database connection instance.
*
- * @param string $name
+ * @param \UnitEnum|string|null $name
* @return \Illuminate\Database\ConnectionInterface
*/
public function connection($name = null);
diff --git a/Connectors/ConnectionFactory.php b/Connectors/ConnectionFactory.php
index d998a41845..7017c0aa5c 100755
--- a/Connectors/ConnectionFactory.php
+++ b/Connectors/ConnectionFactory.php
@@ -2,15 +2,16 @@
namespace Illuminate\Database\Connectors;
-use PDOException;
-use Illuminate\Support\Arr;
-use InvalidArgumentException;
+use Illuminate\Contracts\Container\Container;
use Illuminate\Database\Connection;
+use Illuminate\Database\MariaDbConnection;
use Illuminate\Database\MySqlConnection;
-use Illuminate\Database\SQLiteConnection;
use Illuminate\Database\PostgresConnection;
+use Illuminate\Database\SQLiteConnection;
use Illuminate\Database\SqlServerConnection;
-use Illuminate\Contracts\Container\Container;
+use Illuminate\Support\Arr;
+use InvalidArgumentException;
+use PDOException;
class ConnectionFactory
{
@@ -25,7 +26,6 @@ class ConnectionFactory
* Create a new connection factory instance.
*
* @param \Illuminate\Contracts\Container\Container $container
- * @return void
*/
public function __construct(Container $container)
{
@@ -35,8 +35,8 @@ public function __construct(Container $container)
/**
* Establish a PDO connection based on the configuration.
*
- * @param array $config
- * @param string $name
+ * @param array $config
+ * @param string|null $name
* @return \Illuminate\Database\Connection
*/
public function make(array $config, $name = null)
@@ -53,7 +53,7 @@ public function make(array $config, $name = null)
/**
* Parse and prepare the database configuration.
*
- * @param array $config
+ * @param array $config
* @param string $name
* @return array
*/
@@ -78,7 +78,7 @@ protected function createSingleConnection(array $config)
}
/**
- * Create a single database connection instance.
+ * Create a read / write database connection instance.
*
* @param array $config
* @return \Illuminate\Database\Connection
@@ -87,7 +87,9 @@ protected function createReadWriteConnection(array $config)
{
$connection = $this->createSingleConnection($this->getWriteConfig($config));
- return $connection->setReadPdo($this->createReadPdo($config));
+ return $connection
+ ->setReadPdo($this->createReadPdo($config))
+ ->setReadPdoConfig($this->getReadConfig($config));
}
/**
@@ -115,7 +117,7 @@ protected function getReadConfig(array $config)
}
/**
- * Get the read configuration for a read / write connection.
+ * Get the write configuration for a read / write connection.
*
* @param array $config
* @return array
@@ -130,15 +132,15 @@ protected function getWriteConfig(array $config)
/**
* Get a read / write level configuration.
*
- * @param array $config
+ * @param array $config
* @param string $type
* @return array
*/
protected function getReadWriteConfig(array $config, $type)
{
return isset($config[$type][0])
- ? Arr::random($config[$type])
- : $config[$type];
+ ? Arr::random($config[$type])
+ : $config[$type];
}
/**
@@ -162,8 +164,8 @@ protected function mergeReadWriteConfig(array $config, array $merge)
protected function createPdoResolver(array $config)
{
return array_key_exists('host', $config)
- ? $this->createPdoResolverWithHosts($config)
- : $this->createPdoResolverWithoutHosts($config);
+ ? $this->createPdoResolverWithHosts($config)
+ : $this->createPdoResolverWithoutHosts($config);
}
/**
@@ -171,11 +173,13 @@ protected function createPdoResolver(array $config)
*
* @param array $config
* @return \Closure
+ *
+ * @throws \PDOException
*/
protected function createPdoResolverWithHosts(array $config)
{
return function () use ($config) {
- foreach (Arr::shuffle($hosts = $this->parseHosts($config)) as $key => $host) {
+ foreach (Arr::shuffle($this->parseHosts($config)) as $host) {
$config['host'] = $host;
try {
@@ -185,7 +189,9 @@ protected function createPdoResolverWithHosts(array $config)
}
}
- throw $e;
+ if (isset($e)) {
+ throw $e;
+ }
};
}
@@ -194,6 +200,8 @@ protected function createPdoResolverWithHosts(array $config)
*
* @param array $config
* @return array
+ *
+ * @throws \InvalidArgumentException
*/
protected function parseHosts(array $config)
{
@@ -214,9 +222,7 @@ protected function parseHosts(array $config)
*/
protected function createPdoResolverWithoutHosts(array $config)
{
- return function () use ($config) {
- return $this->createConnector($config)->connect($config);
- };
+ return fn () => $this->createConnector($config)->connect($config);
}
/**
@@ -237,28 +243,24 @@ public function createConnector(array $config)
return $this->container->make($key);
}
- switch ($config['driver']) {
- case 'mysql':
- return new MySqlConnector;
- case 'pgsql':
- return new PostgresConnector;
- case 'sqlite':
- return new SQLiteConnector;
- case 'sqlsrv':
- return new SqlServerConnector;
- }
-
- throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]");
+ return match ($config['driver']) {
+ 'mysql' => new MySqlConnector,
+ 'mariadb' => new MariaDbConnector,
+ 'pgsql' => new PostgresConnector,
+ 'sqlite' => new SQLiteConnector,
+ 'sqlsrv' => new SqlServerConnector,
+ default => throw new InvalidArgumentException("Unsupported driver [{$config['driver']}]."),
+ };
}
/**
* Create a new connection instance.
*
- * @param string $driver
- * @param \PDO|\Closure $connection
- * @param string $database
- * @param string $prefix
- * @param array $config
+ * @param string $driver
+ * @param \PDO|\Closure $connection
+ * @param string $database
+ * @param string $prefix
+ * @param array $config
* @return \Illuminate\Database\Connection
*
* @throws \InvalidArgumentException
@@ -269,17 +271,13 @@ protected function createConnection($driver, $connection, $database, $prefix = '
return $resolver($connection, $database, $prefix, $config);
}
- switch ($driver) {
- case 'mysql':
- return new MySqlConnection($connection, $database, $prefix, $config);
- case 'pgsql':
- return new PostgresConnection($connection, $database, $prefix, $config);
- case 'sqlite':
- return new SQLiteConnection($connection, $database, $prefix, $config);
- case 'sqlsrv':
- return new SqlServerConnection($connection, $database, $prefix, $config);
- }
-
- throw new InvalidArgumentException("Unsupported driver [{$driver}]");
+ return match ($driver) {
+ 'mysql' => new MySqlConnection($connection, $database, $prefix, $config),
+ 'mariadb' => new MariaDbConnection($connection, $database, $prefix, $config),
+ 'pgsql' => new PostgresConnection($connection, $database, $prefix, $config),
+ 'sqlite' => new SQLiteConnection($connection, $database, $prefix, $config),
+ 'sqlsrv' => new SqlServerConnection($connection, $database, $prefix, $config),
+ default => throw new InvalidArgumentException("Unsupported driver [{$driver}]."),
+ };
}
}
diff --git a/Connectors/Connector.php b/Connectors/Connector.php
index ab0903d9ca..8d0af49a9a 100755
--- a/Connectors/Connector.php
+++ b/Connectors/Connector.php
@@ -2,11 +2,10 @@
namespace Illuminate\Database\Connectors;
-use PDO;
use Exception;
-use Throwable;
-use Doctrine\DBAL\Driver\PDOConnection;
use Illuminate\Database\DetectsLostConnections;
+use PDO;
+use Throwable;
class Connector
{
@@ -29,8 +28,8 @@ class Connector
* Create a new PDO connection.
*
* @param string $dsn
- * @param array $config
- * @param array $options
+ * @param array $config
+ * @param array $options
* @return \PDO
*
* @throws \Exception
@@ -61,25 +60,11 @@ public function createConnection($dsn, array $config, array $options)
* @param array $options
* @return \PDO
*/
- protected function createPdoConnection($dsn, $username, $password, $options)
+ protected function createPdoConnection($dsn, $username, #[\SensitiveParameter] $password, $options)
{
- if (class_exists(PDOConnection::class) && ! $this->isPersistentConnection($options)) {
- return new PDOConnection($dsn, $username, $password, $options);
- }
-
- return new PDO($dsn, $username, $password, $options);
- }
-
- /**
- * Determine if the connection is persistent.
- *
- * @param array $options
- * @return bool
- */
- protected function isPersistentConnection($options)
- {
- return isset($options[PDO::ATTR_PERSISTENT]) &&
- $options[PDO::ATTR_PERSISTENT];
+ return version_compare(PHP_VERSION, '8.4.0', '<')
+ ? new PDO($dsn, $username, $password, $options)
+ : PDO::connect($dsn, $username, $password, $options); /** @phpstan-ignore staticMethod.notFound (PHP 8.4) */
}
/**
@@ -89,12 +74,12 @@ protected function isPersistentConnection($options)
* @param string $dsn
* @param string $username
* @param string $password
- * @param array $options
+ * @param array $options
* @return \PDO
*
- * @throws \Exception
+ * @throws \Throwable
*/
- protected function tryAgainIfCausedByLostConnection(Throwable $e, $dsn, $username, $password, $options)
+ protected function tryAgainIfCausedByLostConnection(Throwable $e, $dsn, $username, #[\SensitiveParameter] $password, $options)
{
if ($this->causedByLostConnection($e)) {
return $this->createPdoConnection($dsn, $username, $password, $options);
diff --git a/Connectors/MariaDbConnector.php b/Connectors/MariaDbConnector.php
new file mode 100755
index 0000000000..b7203f87ae
--- /dev/null
+++ b/Connectors/MariaDbConnector.php
@@ -0,0 +1,32 @@
+createConnection($dsn, $config, $options);
- if (! empty($config['database'])) {
+ if (! empty($config['database']) &&
+ (! isset($config['use_db_after_connecting']) ||
+ $config['use_db_after_connecting'])) {
$connection->exec("use `{$config['database']}`;");
}
- $this->configureEncoding($connection, $config);
-
- // Next, we will check to see if a timezone has been specified in this config
- // and if it has we will issue a statement to modify the timezone with the
- // database. Setting this DB timezone is an optional configuration item.
- $this->configureTimezone($connection, $config);
-
- $this->setModes($connection, $config);
+ $this->configureConnection($connection, $config);
return $connection;
}
- /**
- * Set the connection character set and collation.
- *
- * @param \PDO $connection
- * @param array $config
- * @return void
- */
- protected function configureEncoding($connection, array $config)
- {
- if (! isset($config['charset'])) {
- return $connection;
- }
-
- $connection->prepare(
- "set names '{$config['charset']}'".$this->getCollation($config)
- )->execute();
- }
-
- /**
- * Get the collation for the configuration.
- *
- * @param array $config
- * @return string
- */
- protected function getCollation(array $config)
- {
- return isset($config['collation']) ? " collate '{$config['collation']}'" : '';
- }
-
- /**
- * Set the timezone on the connection.
- *
- * @param \PDO $connection
- * @param array $config
- * @return void
- */
- protected function configureTimezone($connection, array $config)
- {
- if (isset($config['timezone'])) {
- $connection->prepare('set time_zone="'.$config['timezone'].'"')->execute();
- }
- }
-
/**
* Create a DSN string from a configuration.
*
* Chooses socket or host/port based on the 'unix_socket' config value.
*
- * @param array $config
+ * @param array $config
* @return string
*/
protected function getDsn(array $config)
{
return $this->hasSocket($config)
- ? $this->getSocketDsn($config)
- : $this->getHostDsn($config);
+ ? $this->getSocketDsn($config)
+ : $this->getHostDsn($config);
}
/**
@@ -127,59 +79,76 @@ protected function getSocketDsn(array $config)
*/
protected function getHostDsn(array $config)
{
- extract($config, EXTR_SKIP);
-
- return isset($port)
- ? "mysql:host={$host};port={$port};dbname={$database}"
- : "mysql:host={$host};dbname={$database}";
+ return isset($config['port'])
+ ? "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']}"
+ : "mysql:host={$config['host']};dbname={$config['database']}";
}
/**
- * Set the modes for the connection.
+ * Configure the given PDO connection.
*
* @param \PDO $connection
* @param array $config
* @return void
*/
- protected function setModes(PDO $connection, array $config)
+ protected function configureConnection(PDO $connection, array $config)
{
- if (isset($config['modes'])) {
- $this->setCustomModes($connection, $config);
- } elseif (isset($config['strict'])) {
- if ($config['strict']) {
- $connection->prepare($this->strictMode($connection))->execute();
+ if (isset($config['isolation_level'])) {
+ $connection->exec(sprintf('SET SESSION TRANSACTION ISOLATION LEVEL %s;', $config['isolation_level']));
+ }
+
+ $statements = [];
+
+ if (isset($config['charset'])) {
+ if (isset($config['collation'])) {
+ $statements[] = sprintf("NAMES '%s' COLLATE '%s'", $config['charset'], $config['collation']);
} else {
- $connection->prepare("set session sql_mode='NO_ENGINE_SUBSTITUTION'")->execute();
+ $statements[] = sprintf("NAMES '%s'", $config['charset']);
}
}
+
+ if (isset($config['timezone'])) {
+ $statements[] = sprintf("time_zone='%s'", $config['timezone']);
+ }
+
+ $sqlMode = $this->getSqlMode($connection, $config);
+
+ if ($sqlMode !== null) {
+ $statements[] = sprintf("SESSION sql_mode='%s'", $sqlMode);
+ }
+
+ if ($statements !== []) {
+ $connection->exec(sprintf('SET %s;', implode(', ', $statements)));
+ }
}
/**
- * Set the custom modes on the connection.
+ * Get the sql_mode value.
*
* @param \PDO $connection
* @param array $config
- * @return void
+ * @return string|null
*/
- protected function setCustomModes(PDO $connection, array $config)
+ protected function getSqlMode(PDO $connection, array $config)
{
- $modes = implode(',', $config['modes']);
+ if (isset($config['modes'])) {
+ return implode(',', $config['modes']);
+ }
- $connection->prepare("set session sql_mode='{$modes}'")->execute();
- }
+ if (! isset($config['strict'])) {
+ return null;
+ }
- /**
- * Get the query to enable strict mode.
- *
- * @param \PDO $connection
- * @return string
- */
- protected function strictMode(PDO $connection)
- {
- if (version_compare($connection->getAttribute(PDO::ATTR_SERVER_VERSION), '8.0.11') >= 0) {
- return "set session sql_mode='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'";
+ if (! $config['strict']) {
+ return 'NO_ENGINE_SUBSTITUTION';
+ }
+
+ $version = $config['version'] ?? $connection->getAttribute(PDO::ATTR_SERVER_VERSION);
+
+ if (version_compare($version, '8.0.11', '>=')) {
+ return 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
}
- return "set session sql_mode='ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION'";
+ return 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION';
}
}
diff --git a/Connectors/PostgresConnector.php b/Connectors/PostgresConnector.php
index c80fea6328..31d2ff4732 100755
--- a/Connectors/PostgresConnector.php
+++ b/Connectors/PostgresConnector.php
@@ -2,10 +2,13 @@
namespace Illuminate\Database\Connectors;
+use Illuminate\Database\Concerns\ParsesSearchPath;
use PDO;
class PostgresConnector extends Connector implements ConnectorInterface
{
+ use ParsesSearchPath;
+
/**
* The default PDO connection options.
*
@@ -33,144 +36,152 @@ public function connect(array $config)
$this->getDsn($config), $config, $this->getOptions($config)
);
- $this->configureEncoding($connection, $config);
+ $this->configureIsolationLevel($connection, $config);
// Next, we will check to see if a timezone has been specified in this config
// and if it has we will issue a statement to modify the timezone with the
// database. Setting this DB timezone is an optional configuration item.
$this->configureTimezone($connection, $config);
- $this->configureSchema($connection, $config);
+ $this->configureSearchPath($connection, $config);
- // Postgres allows an application_name to be set by the user and this name is
- // used to when monitoring the application with pg_stat_activity. So we'll
- // determine if the option has been specified and run a statement if so.
- $this->configureApplicationName($connection, $config);
+ $this->configureSynchronousCommit($connection, $config);
return $connection;
}
/**
- * Set the connection character set and collation.
+ * Create a DSN string from a configuration.
*
- * @param \PDO $connection
* @param array $config
- * @return void
+ * @return string
*/
- protected function configureEncoding($connection, $config)
+ protected function getDsn(array $config)
{
- if (! isset($config['charset'])) {
- return;
+ // First we will create the basic DSN setup as well as the port if it is in
+ // in the configuration options. This will give us the basic DSN we will
+ // need to establish the PDO connections and return them back for use.
+ extract($config, EXTR_SKIP);
+
+ $host = isset($host) ? "host={$host};" : '';
+
+ // Sometimes - users may need to connect to a database that has a different
+ // name than the database used for "information_schema" queries. This is
+ // typically the case if using "pgbouncer" type software when pooling.
+ $database = $connect_via_database ?? $database ?? null;
+ $port = $connect_via_port ?? $port ?? null;
+
+ $dsn = "pgsql:{$host}dbname='{$database}'";
+
+ // If a port was specified, we will add it to this Postgres DSN connections
+ // format. Once we have done that we are ready to return this connection
+ // string back out for usage, as this has been fully constructed here.
+ if (! is_null($port)) {
+ $dsn .= ";port={$port}";
}
- $connection->prepare("set names '{$config['charset']}'")->execute();
+ if (isset($charset)) {
+ $dsn .= ";client_encoding='{$charset}'";
+ }
+
+ // Postgres allows an application_name to be set by the user and this name is
+ // used to when monitoring the application with pg_stat_activity. So we'll
+ // determine if the option has been specified and run a statement if so.
+ if (isset($application_name)) {
+ $dsn .= ";application_name='".str_replace("'", "\'", $application_name)."'";
+ }
+
+ return $this->addSslOptions($dsn, $config);
}
/**
- * Set the timezone on the connection.
+ * Add the SSL options to the DSN.
*
- * @param \PDO $connection
+ * @param string $dsn
* @param array $config
- * @return void
+ * @return string
*/
- protected function configureTimezone($connection, array $config)
+ protected function addSslOptions($dsn, array $config)
{
- if (isset($config['timezone'])) {
- $timezone = $config['timezone'];
-
- $connection->prepare("set time zone '{$timezone}'")->execute();
+ foreach (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'] as $option) {
+ if (isset($config[$option])) {
+ $dsn .= ";{$option}={$config[$option]}";
+ }
}
+
+ return $dsn;
}
/**
- * Set the schema on the connection.
+ * Set the connection transaction isolation level.
*
* @param \PDO $connection
* @param array $config
* @return void
*/
- protected function configureSchema($connection, $config)
+ protected function configureIsolationLevel($connection, array $config)
{
- if (isset($config['schema'])) {
- $schema = $this->formatSchema($config['schema']);
-
- $connection->prepare("set search_path to {$schema}")->execute();
+ if (isset($config['isolation_level'])) {
+ $connection->prepare("set session characteristics as transaction isolation level {$config['isolation_level']}")->execute();
}
}
/**
- * Format the schema for the DSN.
+ * Set the timezone on the connection.
*
- * @param array|string $schema
- * @return string
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
*/
- protected function formatSchema($schema)
+ protected function configureTimezone($connection, array $config)
{
- if (is_array($schema)) {
- return '"'.implode('", "', $schema).'"';
- }
+ if (isset($config['timezone'])) {
+ $timezone = $config['timezone'];
- return '"'.$schema.'"';
+ $connection->prepare("set time zone '{$timezone}'")->execute();
+ }
}
/**
- * Set the schema on the connection.
+ * Set the "search_path" on the database connection.
*
* @param \PDO $connection
* @param array $config
* @return void
*/
- protected function configureApplicationName($connection, $config)
+ protected function configureSearchPath($connection, $config)
{
- if (isset($config['application_name'])) {
- $applicationName = $config['application_name'];
+ if (isset($config['search_path']) || isset($config['schema'])) {
+ $searchPath = $this->quoteSearchPath(
+ $this->parseSearchPath($config['search_path'] ?? $config['schema'])
+ );
- $connection->prepare("set application_name to '$applicationName'")->execute();
+ $connection->prepare("set search_path to {$searchPath}")->execute();
}
}
/**
- * Create a DSN string from a configuration.
+ * Format the search path for the DSN.
*
- * @param array $config
+ * @param array $searchPath
* @return string
*/
- protected function getDsn(array $config)
+ protected function quoteSearchPath($searchPath)
{
- // First we will create the basic DSN setup as well as the port if it is in
- // in the configuration options. This will give us the basic DSN we will
- // need to establish the PDO connections and return them back for use.
- extract($config, EXTR_SKIP);
-
- $host = isset($host) ? "host={$host};" : '';
-
- $dsn = "pgsql:{$host}dbname={$database}";
-
- // If a port was specified, we will add it to this Postgres DSN connections
- // format. Once we have done that we are ready to return this connection
- // string back out for usage, as this has been fully constructed here.
- if (isset($config['port'])) {
- $dsn .= ";port={$port}";
- }
-
- return $this->addSslOptions($dsn, $config);
+ return count($searchPath) === 1 ? '"'.$searchPath[0].'"' : '"'.implode('", "', $searchPath).'"';
}
/**
- * Add the SSL options to the DSN.
+ * Configure the synchronous_commit setting.
*
- * @param string $dsn
+ * @param \PDO $connection
* @param array $config
- * @return string
+ * @return void
*/
- protected function addSslOptions($dsn, array $config)
+ protected function configureSynchronousCommit($connection, array $config)
{
- foreach (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'] as $option) {
- if (isset($config[$option])) {
- $dsn .= ";{$option}={$config[$option]}";
- }
+ if (isset($config['synchronous_commit'])) {
+ $connection->prepare("set synchronous_commit to '{$config['synchronous_commit']}'")->execute();
}
-
- return $dsn;
}
}
diff --git a/Connectors/SQLiteConnector.php b/Connectors/SQLiteConnector.php
index 90dc16be24..858549ec55 100755
--- a/Connectors/SQLiteConnector.php
+++ b/Connectors/SQLiteConnector.php
@@ -2,7 +2,7 @@
namespace Illuminate\Database\Connectors;
-use InvalidArgumentException;
+use Illuminate\Database\SQLiteDatabaseDoesNotExistException;
class SQLiteConnector extends Connector implements ConnectorInterface
{
@@ -11,29 +11,139 @@ class SQLiteConnector extends Connector implements ConnectorInterface
*
* @param array $config
* @return \PDO
- *
- * @throws \InvalidArgumentException
*/
public function connect(array $config)
{
$options = $this->getOptions($config);
+ $path = $this->parseDatabasePath($config['database']);
+
+ $connection = $this->createConnection("sqlite:{$path}", $config, $options);
+
+ $this->configurePragmas($connection, $config);
+ $this->configureForeignKeyConstraints($connection, $config);
+ $this->configureBusyTimeout($connection, $config);
+ $this->configureJournalMode($connection, $config);
+ $this->configureSynchronous($connection, $config);
+
+ return $connection;
+ }
+
+ /**
+ * Get the absolute database path.
+ *
+ * @param string $path
+ * @return string
+ *
+ * @throws \Illuminate\Database\SQLiteDatabaseDoesNotExistException
+ */
+ protected function parseDatabasePath(string $path): string
+ {
+ $database = $path;
+
// SQLite supports "in-memory" databases that only last as long as the owning
// connection does. These are useful for tests or for short lifetime store
- // querying. In-memory databases may only have a single open connection.
- if ($config['database'] === ':memory:') {
- return $this->createConnection('sqlite::memory:', $config, $options);
+ // querying. In-memory databases shall be anonymous (:memory:) or named.
+ if ($path === ':memory:' ||
+ str_contains($path, '?mode=memory') ||
+ str_contains($path, '&mode=memory')
+ ) {
+ return $path;
}
- $path = realpath($config['database']);
+ $path = realpath($path) ?: realpath(base_path($path));
// Here we'll verify that the SQLite database exists before going any further
// as the developer probably wants to know if the database exists and this
// SQLite driver will not throw any exception if it does not by default.
if ($path === false) {
- throw new InvalidArgumentException("Database ({$config['database']}) does not exist.");
+ throw new SQLiteDatabaseDoesNotExistException($database);
+ }
+
+ return $path;
+ }
+
+ /**
+ * Set miscellaneous user-configured pragmas.
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configurePragmas($connection, array $config): void
+ {
+ if (! isset($config['pragmas'])) {
+ return;
+ }
+
+ foreach ($config['pragmas'] as $pragma => $value) {
+ $connection->prepare("pragma {$pragma} = {$value}")->execute();
+ }
+ }
+
+ /**
+ * Enable or disable foreign key constraints if configured.
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configureForeignKeyConstraints($connection, array $config): void
+ {
+ if (! isset($config['foreign_key_constraints'])) {
+ return;
+ }
+
+ $foreignKeys = $config['foreign_key_constraints'] ? 1 : 0;
+
+ $connection->prepare("pragma foreign_keys = {$foreignKeys}")->execute();
+ }
+
+ /**
+ * Set the busy timeout if configured.
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configureBusyTimeout($connection, array $config): void
+ {
+ if (! isset($config['busy_timeout'])) {
+ return;
+ }
+
+ $connection->prepare("pragma busy_timeout = {$config['busy_timeout']}")->execute();
+ }
+
+ /**
+ * Set the journal mode if configured.
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configureJournalMode($connection, array $config): void
+ {
+ if (! isset($config['journal_mode'])) {
+ return;
+ }
+
+ $connection->prepare("pragma journal_mode = {$config['journal_mode']}")->execute();
+ }
+
+ /**
+ * Set the synchronous mode if configured.
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configureSynchronous($connection, array $config): void
+ {
+ if (! isset($config['synchronous'])) {
+ return;
}
- return $this->createConnection("sqlite:{$path}", $config, $options);
+ $connection->prepare("pragma synchronous = {$config['synchronous']}")->execute();
}
}
diff --git a/Connectors/SqlServerConnector.php b/Connectors/SqlServerConnector.php
index 6cfc33fb63..14cb72dbbf 100755
--- a/Connectors/SqlServerConnector.php
+++ b/Connectors/SqlServerConnector.php
@@ -2,8 +2,8 @@
namespace Illuminate\Database\Connectors;
-use PDO;
use Illuminate\Support\Arr;
+use PDO;
class SqlServerConnector extends Connector implements ConnectorInterface
{
@@ -29,13 +29,37 @@ public function connect(array $config)
{
$options = $this->getOptions($config);
- return $this->createConnection($this->getDsn($config), $config, $options);
+ $connection = $this->createConnection($this->getDsn($config), $config, $options);
+
+ $this->configureIsolationLevel($connection, $config);
+
+ return $connection;
+ }
+
+ /**
+ * Set the connection transaction isolation level.
+ *
+ * https://learn.microsoft.com/en-us/sql/t-sql/statements/set-transaction-isolation-level-transact-sql
+ *
+ * @param \PDO $connection
+ * @param array $config
+ * @return void
+ */
+ protected function configureIsolationLevel($connection, array $config)
+ {
+ if (! isset($config['isolation_level'])) {
+ return;
+ }
+
+ $connection->prepare(
+ "SET TRANSACTION ISOLATION LEVEL {$config['isolation_level']}"
+ )->execute();
}
/**
* Create a DSN string from a configuration.
*
- * @param array $config
+ * @param array $config
* @return string
*/
protected function getDsn(array $config)
@@ -89,7 +113,8 @@ protected function getDblibDsn(array $config)
protected function getOdbcDsn(array $config)
{
return isset($config['odbc_datasource_name'])
- ? 'odbc:'.$config['odbc_datasource_name'] : '';
+ ? 'odbc:'.$config['odbc_datasource_name']
+ : '';
}
/**
@@ -140,6 +165,30 @@ protected function getSqlSrvDsn(array $config)
$arguments['MultiSubnetFailover'] = $config['multi_subnet_failover'];
}
+ if (isset($config['column_encryption'])) {
+ $arguments['ColumnEncryption'] = $config['column_encryption'];
+ }
+
+ if (isset($config['key_store_authentication'])) {
+ $arguments['KeyStoreAuthentication'] = $config['key_store_authentication'];
+ }
+
+ if (isset($config['key_store_principal_id'])) {
+ $arguments['KeyStorePrincipalId'] = $config['key_store_principal_id'];
+ }
+
+ if (isset($config['key_store_secret'])) {
+ $arguments['KeyStoreSecret'] = $config['key_store_secret'];
+ }
+
+ if (isset($config['login_timeout'])) {
+ $arguments['LoginTimeout'] = $config['login_timeout'];
+ }
+
+ if (isset($config['authentication'])) {
+ $arguments['Authentication'] = $config['authentication'];
+ }
+
return $this->buildConnectString('sqlsrv', $arguments);
}
@@ -166,11 +215,11 @@ protected function buildConnectString($driver, array $arguments)
*/
protected function buildHostString(array $config, $separator)
{
- if (isset($config['port']) && ! empty($config['port'])) {
- return $config['host'].$separator.$config['port'];
- } else {
+ if (empty($config['port'])) {
return $config['host'];
}
+
+ return $config['host'].$separator.$config['port'];
}
/**
diff --git a/Console/DatabaseInspectionCommand.php b/Console/DatabaseInspectionCommand.php
new file mode 100644
index 0000000000..8faab04147
--- /dev/null
+++ b/Console/DatabaseInspectionCommand.php
@@ -0,0 +1,50 @@
+getDriverTitle();
+ }
+
+ /**
+ * Get the number of open connections for a database.
+ *
+ * @param \Illuminate\Database\ConnectionInterface $connection
+ * @return int|null
+ *
+ * @deprecated
+ */
+ protected function getConnectionCount(ConnectionInterface $connection)
+ {
+ return $connection->threadCount();
+ }
+
+ /**
+ * Get the connection configuration details for the given connection.
+ *
+ * @param string|null $database
+ * @return array
+ */
+ protected function getConfigFromDatabase($database)
+ {
+ $database ??= config('database.default');
+
+ return Arr::except(config('database.connections.'.$database), ['password']);
+ }
+}
diff --git a/Console/DbCommand.php b/Console/DbCommand.php
new file mode 100644
index 0000000000..3017607355
--- /dev/null
+++ b/Console/DbCommand.php
@@ -0,0 +1,257 @@
+getConnection();
+
+ if (! isset($connection['host']) && $connection['driver'] !== 'sqlite') {
+ $this->components->error('No host specified for this database connection.');
+ $this->line(' Use the [--read]> and [--write]> options to specify a read or write connection.');
+ $this->newLine();
+
+ return Command::FAILURE;
+ }
+
+ try {
+ (new Process(
+ array_merge([$command = $this->getCommand($connection)], $this->commandArguments($connection)),
+ null,
+ $this->commandEnvironment($connection)
+ ))->setTimeout(null)->setTty(true)->mustRun(function ($type, $buffer) {
+ $this->output->write($buffer);
+ });
+ } catch (ProcessFailedException $e) {
+ throw_unless($e->getProcess()->getExitCode() === 127, $e);
+
+ $this->error("{$command} not found in path.");
+
+ return Command::FAILURE;
+ }
+
+ return 0;
+ }
+
+ /**
+ * Get the database connection configuration.
+ *
+ * @return array
+ *
+ * @throws \UnexpectedValueException
+ */
+ public function getConnection()
+ {
+ $connection = $this->laravel['config']['database.connections.'.
+ (($db = $this->argument('connection')) ?? $this->laravel['config']['database.default'])
+ ];
+
+ if (empty($connection)) {
+ throw new UnexpectedValueException("Invalid database connection [{$db}].");
+ }
+
+ if (! empty($connection['url'])) {
+ $connection = (new ConfigurationUrlParser)->parseConfiguration($connection);
+ }
+
+ if ($this->option('read')) {
+ if (is_array($connection['read']['host'])) {
+ $connection['read']['host'] = $connection['read']['host'][0];
+ }
+
+ $connection = array_merge($connection, $connection['read']);
+ } elseif ($this->option('write')) {
+ if (is_array($connection['write']['host'])) {
+ $connection['write']['host'] = $connection['write']['host'][0];
+ }
+
+ $connection = array_merge($connection, $connection['write']);
+ }
+
+ return $connection;
+ }
+
+ /**
+ * Get the arguments for the database client command.
+ *
+ * @param array $connection
+ * @return array
+ */
+ public function commandArguments(array $connection)
+ {
+ $driver = ucfirst($connection['driver']);
+
+ return $this->{"get{$driver}Arguments"}($connection);
+ }
+
+ /**
+ * Get the environment variables for the database client command.
+ *
+ * @param array $connection
+ * @return array|null
+ */
+ public function commandEnvironment(array $connection)
+ {
+ $driver = ucfirst($connection['driver']);
+
+ if (method_exists($this, "get{$driver}Environment")) {
+ return $this->{"get{$driver}Environment"}($connection);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the database client command to run.
+ *
+ * @param array $connection
+ * @return string
+ */
+ public function getCommand(array $connection)
+ {
+ return [
+ 'mysql' => 'mysql',
+ 'mariadb' => 'mariadb',
+ 'pgsql' => 'psql',
+ 'sqlite' => 'sqlite3',
+ 'sqlsrv' => 'sqlcmd',
+ ][$connection['driver']];
+ }
+
+ /**
+ * Get the arguments for the MySQL CLI.
+ *
+ * @param array $connection
+ * @return array
+ */
+ protected function getMysqlArguments(array $connection)
+ {
+ $optionalArguments = [
+ 'password' => '--password='.$connection['password'],
+ 'unix_socket' => '--socket='.($connection['unix_socket'] ?? ''),
+ 'charset' => '--default-character-set='.($connection['charset'] ?? ''),
+ ];
+
+ if (! $connection['password']) {
+ unset($optionalArguments['password']);
+ }
+
+ return array_merge([
+ '--host='.$connection['host'],
+ '--port='.$connection['port'],
+ '--user='.$connection['username'],
+ ], $this->getOptionalArguments($optionalArguments, $connection), [$connection['database']]);
+ }
+
+ /**
+ * Get the arguments for the MariaDB CLI.
+ *
+ * @param array $connection
+ * @return array
+ */
+ protected function getMariaDbArguments(array $connection)
+ {
+ return $this->getMysqlArguments($connection);
+ }
+
+ /**
+ * Get the arguments for the Postgres CLI.
+ *
+ * @param array $connection
+ * @return array
+ */
+ protected function getPgsqlArguments(array $connection)
+ {
+ return [$connection['database']];
+ }
+
+ /**
+ * Get the arguments for the SQLite CLI.
+ *
+ * @param array $connection
+ * @return array
+ */
+ protected function getSqliteArguments(array $connection)
+ {
+ return [$connection['database']];
+ }
+
+ /**
+ * Get the arguments for the SQL Server CLI.
+ *
+ * @param array $connection
+ * @return array
+ */
+ protected function getSqlsrvArguments(array $connection)
+ {
+ return array_merge(...$this->getOptionalArguments([
+ 'database' => ['-d', $connection['database']],
+ 'username' => ['-U', $connection['username']],
+ 'password' => ['-P', $connection['password']],
+ 'host' => ['-S', 'tcp:'.$connection['host']
+ .($connection['port'] ? ','.$connection['port'] : ''), ],
+ 'trust_server_certificate' => ['-C'],
+ ], $connection));
+ }
+
+ /**
+ * Get the environment variables for the Postgres CLI.
+ *
+ * @param array $connection
+ * @return array|null
+ */
+ protected function getPgsqlEnvironment(array $connection)
+ {
+ return array_merge(...$this->getOptionalArguments([
+ 'username' => ['PGUSER' => $connection['username']],
+ 'host' => ['PGHOST' => $connection['host']],
+ 'port' => ['PGPORT' => $connection['port']],
+ 'password' => ['PGPASSWORD' => $connection['password']],
+ ], $connection));
+ }
+
+ /**
+ * Get the optional arguments based on the connection configuration.
+ *
+ * @param array $args
+ * @param array $connection
+ * @return array
+ */
+ protected function getOptionalArguments(array $args, array $connection)
+ {
+ return array_values(array_filter($args, function ($key) use ($connection) {
+ return ! empty($connection[$key]);
+ }, ARRAY_FILTER_USE_KEY));
+ }
+}
diff --git a/Console/DumpCommand.php b/Console/DumpCommand.php
new file mode 100644
index 0000000000..fea8fc0554
--- /dev/null
+++ b/Console/DumpCommand.php
@@ -0,0 +1,104 @@
+isProhibited()) {
+ return Command::FAILURE;
+ }
+
+ $connection = $connections->connection($database = $this->input->getOption('database'));
+
+ $this->schemaState($connection)->dump(
+ $connection, $path = $this->path($connection)
+ );
+
+ $dispatcher->dispatch(new SchemaDumped($connection, $path));
+
+ $info = 'Database schema dumped';
+
+ if ($this->option('prune')) {
+ (new Filesystem)->deleteDirectory(
+ $path = database_path('migrations'), preserve: false
+ );
+
+ $info .= ' and pruned';
+
+ $dispatcher->dispatch(new MigrationsPruned($connection, $path));
+ }
+
+ $this->components->info($info.' successfully.');
+ }
+
+ /**
+ * Create a schema state instance for the given connection.
+ *
+ * @param \Illuminate\Database\Connection $connection
+ * @return mixed
+ */
+ protected function schemaState(Connection $connection)
+ {
+ $migrations = Config::get('database.migrations', 'migrations');
+
+ $migrationTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations;
+
+ return $connection->getSchemaState()
+ ->withMigrationTable($migrationTable)
+ ->handleOutputUsing(function ($type, $buffer) {
+ $this->output->write($buffer);
+ });
+ }
+
+ /**
+ * Get the path that the dump should be written to.
+ *
+ * @param \Illuminate\Database\Connection $connection
+ */
+ protected function path(Connection $connection)
+ {
+ return tap($this->option('path') ?: database_path('schema/'.$connection->getName().'-schema.sql'), function ($path) {
+ (new Filesystem)->ensureDirectoryExists(dirname($path));
+ });
+ }
+}
diff --git a/Console/Factories/FactoryMakeCommand.php b/Console/Factories/FactoryMakeCommand.php
index 86341594d1..6d080a1439 100644
--- a/Console/Factories/FactoryMakeCommand.php
+++ b/Console/Factories/FactoryMakeCommand.php
@@ -3,8 +3,12 @@
namespace Illuminate\Database\Console\Factories;
use Illuminate\Console\GeneratorCommand;
+use Illuminate\Support\Str;
+use Illuminate\Support\Stringable;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'make:factory')]
class FactoryMakeCommand extends GeneratorCommand
{
/**
@@ -35,7 +39,20 @@ class FactoryMakeCommand extends GeneratorCommand
*/
protected function getStub()
{
- return __DIR__.'/stubs/factory.stub';
+ return $this->resolveStubPath('/stubs/factory.stub');
+ }
+
+ /**
+ * Resolve the fully-qualified path to the stub.
+ *
+ * @param string $stub
+ * @return string
+ */
+ protected function resolveStubPath($stub)
+ {
+ return file_exists($customPath = $this->laravel->basePath(trim($stub, '/')))
+ ? $customPath
+ : __DIR__.$stub;
}
/**
@@ -46,12 +63,32 @@ protected function getStub()
*/
protected function buildClass($name)
{
- $model = $this->option('model')
- ? $this->qualifyClass($this->option('model'))
- : 'Model';
+ $factory = class_basename(Str::ucfirst(str_replace('Factory', '', $name)));
+
+ $namespaceModel = $this->option('model')
+ ? $this->qualifyModel($this->option('model'))
+ : $this->qualifyModel($this->guessModelName($name));
+
+ $model = class_basename($namespaceModel);
+
+ $namespace = $this->getNamespace(
+ Str::replaceFirst($this->rootNamespace(), 'Database\\Factories\\', $this->qualifyClass($this->getNameInput()))
+ );
+
+ $replace = [
+ '{{ factoryNamespace }}' => $namespace,
+ 'NamespacedDummyModel' => $namespaceModel,
+ '{{ namespacedModel }}' => $namespaceModel,
+ '{{namespacedModel}}' => $namespaceModel,
+ 'DummyModel' => $model,
+ '{{ model }}' => $model,
+ '{{model}}' => $model,
+ '{{ factory }}' => $factory,
+ '{{factory}}' => $factory,
+ ];
return str_replace(
- 'DummyModel', $model, parent::buildClass($name)
+ array_keys($replace), array_values($replace), parent::buildClass($name)
);
}
@@ -63,11 +100,34 @@ protected function buildClass($name)
*/
protected function getPath($name)
{
- $name = str_replace(
- ['\\', '/'], '', $this->argument('name')
- );
+ $name = (new Stringable($name))->replaceFirst($this->rootNamespace(), '')->finish('Factory')->value();
+
+ return $this->laravel->databasePath().'/factories/'.str_replace('\\', '/', $name).'.php';
+ }
+
+ /**
+ * Guess the model name from the Factory name or return a default model name.
+ *
+ * @param string $name
+ * @return string
+ */
+ protected function guessModelName($name)
+ {
+ if (str_ends_with($name, 'Factory')) {
+ $name = substr($name, 0, -7);
+ }
+
+ $modelName = $this->qualifyModel(Str::after($name, $this->rootNamespace()));
+
+ if (class_exists($modelName)) {
+ return $modelName;
+ }
+
+ if (is_dir(app_path('Models/'))) {
+ return $this->rootNamespace().'Models\Model';
+ }
- return $this->laravel->databasePath()."/factories/{$name}.php";
+ return $this->rootNamespace().'Model';
}
/**
diff --git a/Console/Factories/stubs/factory.stub b/Console/Factories/stubs/factory.stub
index 9e3f90b60f..f931493a27 100644
--- a/Console/Factories/stubs/factory.stub
+++ b/Console/Factories/stubs/factory.stub
@@ -1,9 +1,23 @@
define(DummyModel::class, function (Faker $faker) {
- return [
- //
- ];
-});
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\{{ namespacedModel }}>
+ */
+class {{ factory }}Factory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ //
+ ];
+ }
+}
diff --git a/Console/Migrations/BaseCommand.php b/Console/Migrations/BaseCommand.php
index 6c4f255070..a250d2945f 100755
--- a/Console/Migrations/BaseCommand.php
+++ b/Console/Migrations/BaseCommand.php
@@ -3,13 +3,14 @@
namespace Illuminate\Database\Console\Migrations;
use Illuminate\Console\Command;
+use Illuminate\Support\Collection;
class BaseCommand extends Command
{
/**
* Get all of the migration paths.
*
- * @return array
+ * @return string[]
*/
protected function getMigrationPaths()
{
@@ -17,10 +18,10 @@ protected function getMigrationPaths()
// use the path relative to the root of the installation folder so our database
// migrations may be run for any customized path from within the application.
if ($this->input->hasOption('path') && $this->option('path')) {
- return collect($this->option('path'))->map(function ($path) {
+ return (new Collection($this->option('path')))->map(function ($path) {
return ! $this->usingRealPath()
- ? $this->laravel->basePath().'/'.$path
- : $path;
+ ? $this->laravel->basePath().'/'.$path
+ : $path;
})->all();
}
diff --git a/Console/Migrations/FreshCommand.php b/Console/Migrations/FreshCommand.php
index 28c1e9973f..723d3c2298 100644
--- a/Console/Migrations/FreshCommand.php
+++ b/Console/Migrations/FreshCommand.php
@@ -4,11 +4,17 @@
use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
+use Illuminate\Console\Prohibitable;
+use Illuminate\Contracts\Events\Dispatcher;
+use Illuminate\Database\Events\DatabaseRefreshed;
+use Illuminate\Database\Migrations\Migrator;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'migrate:fresh')]
class FreshCommand extends Command
{
- use ConfirmableTrait;
+ use ConfirmableTrait, Prohibitable;
/**
* The console command name.
@@ -24,66 +30,74 @@ class FreshCommand extends Command
*/
protected $description = 'Drop all tables and re-run all migrations';
+ /**
+ * The migrator instance.
+ *
+ * @var \Illuminate\Database\Migrations\Migrator
+ */
+ protected $migrator;
+
+ /**
+ * Create a new fresh command instance.
+ *
+ * @param \Illuminate\Database\Migrations\Migrator $migrator
+ */
+ public function __construct(Migrator $migrator)
+ {
+ parent::__construct();
+
+ $this->migrator = $migrator;
+ }
+
/**
* Execute the console command.
*
- * @return void
+ * @return int
*/
public function handle()
{
- if (! $this->confirmToProceed()) {
- return;
+ if ($this->isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
}
$database = $this->input->getOption('database');
- if ($this->option('drop-views')) {
- $this->dropAllViews($database);
+ $this->migrator->usingConnection($database, function () use ($database) {
+ if ($this->migrator->repositoryExists()) {
+ $this->newLine();
- $this->info('Dropped all views successfully.');
- }
+ $this->components->task('Dropping all tables', fn () => $this->callSilent('db:wipe', array_filter([
+ '--database' => $database,
+ '--drop-views' => $this->option('drop-views'),
+ '--drop-types' => $this->option('drop-types'),
+ '--force' => true,
+ ])) == 0);
+ }
+ });
- $this->dropAllTables($database);
-
- $this->info('Dropped all tables successfully.');
+ $this->newLine();
$this->call('migrate', array_filter([
'--database' => $database,
'--path' => $this->input->getOption('path'),
'--realpath' => $this->input->getOption('realpath'),
+ '--schema-path' => $this->input->getOption('schema-path'),
'--force' => true,
'--step' => $this->option('step'),
]));
+ if ($this->laravel->bound(Dispatcher::class)) {
+ $this->laravel[Dispatcher::class]->dispatch(
+ new DatabaseRefreshed($database, $this->needsSeeding())
+ );
+ }
+
if ($this->needsSeeding()) {
$this->runSeeder($database);
}
- }
-
- /**
- * Drop all of the database tables.
- *
- * @param string $database
- * @return void
- */
- protected function dropAllTables($database)
- {
- $this->laravel['db']->connection($database)
- ->getSchemaBuilder()
- ->dropAllTables();
- }
- /**
- * Drop all of the database views.
- *
- * @param string $database
- * @return void
- */
- protected function dropAllViews($database)
- {
- $this->laravel['db']->connection($database)
- ->getSchemaBuilder()
- ->dropAllViews();
+ return 0;
}
/**
@@ -106,7 +120,7 @@ protected function runSeeder($database)
{
$this->call('db:seed', array_filter([
'--database' => $database,
- '--class' => $this->option('seeder') ?: 'DatabaseSeeder',
+ '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder',
'--force' => true,
]));
}
@@ -120,19 +134,14 @@ protected function getOptions()
{
return [
['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'],
-
['drop-views', null, InputOption::VALUE_NONE, 'Drop all tables and views'],
-
+ ['drop-types', null, InputOption::VALUE_NONE, 'Drop all tables and types (Postgres only)'],
['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'],
-
- ['path', null, InputOption::VALUE_OPTIONAL, 'The path to the migrations files to be executed'],
-
+ ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to be executed'],
['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'],
-
+ ['schema-path', null, InputOption::VALUE_OPTIONAL, 'The path to a schema dump file'],
['seed', null, InputOption::VALUE_NONE, 'Indicates if the seed task should be re-run'],
-
['seeder', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder'],
-
['step', null, InputOption::VALUE_NONE, 'Force the migrations to be run so they can be rolled back individually'],
];
}
diff --git a/Console/Migrations/InstallCommand.php b/Console/Migrations/InstallCommand.php
index 354d7a1c16..b89cd4b4e8 100755
--- a/Console/Migrations/InstallCommand.php
+++ b/Console/Migrations/InstallCommand.php
@@ -3,9 +3,11 @@
namespace Illuminate\Database\Console\Migrations;
use Illuminate\Console\Command;
-use Symfony\Component\Console\Input\InputOption;
use Illuminate\Database\Migrations\MigrationRepositoryInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'migrate:install')]
class InstallCommand extends Command
{
/**
@@ -33,7 +35,6 @@ class InstallCommand extends Command
* Create a new migration install command instance.
*
* @param \Illuminate\Database\Migrations\MigrationRepositoryInterface $repository
- * @return void
*/
public function __construct(MigrationRepositoryInterface $repository)
{
@@ -51,9 +52,11 @@ public function handle()
{
$this->repository->setSource($this->input->getOption('database'));
- $this->repository->createRepository();
+ if (! $this->repository->repositoryExists()) {
+ $this->repository->createRepository();
+ }
- $this->info('Migration table created successfully.');
+ $this->components->info('Migration table created successfully.');
}
/**
diff --git a/Console/Migrations/MigrateCommand.php b/Console/Migrations/MigrateCommand.php
index ee61ac52aa..e3116ca3d1 100755
--- a/Console/Migrations/MigrateCommand.php
+++ b/Console/Migrations/MigrateCommand.php
@@ -3,9 +3,22 @@
namespace Illuminate\Database\Console\Migrations;
use Illuminate\Console\ConfirmableTrait;
+use Illuminate\Contracts\Console\Isolatable;
+use Illuminate\Contracts\Events\Dispatcher;
+use Illuminate\Database\Events\SchemaLoaded;
use Illuminate\Database\Migrations\Migrator;
+use Illuminate\Database\SQLiteDatabaseDoesNotExistException;
+use Illuminate\Database\SqlServerConnection;
+use Illuminate\Support\Str;
+use PDOException;
+use RuntimeException;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Throwable;
-class MigrateCommand extends BaseCommand
+use function Laravel\Prompts\confirm;
+
+#[AsCommand(name: 'migrate')]
+class MigrateCommand extends BaseCommand implements Isolatable
{
use ConfirmableTrait;
@@ -16,11 +29,14 @@ class MigrateCommand extends BaseCommand
*/
protected $signature = 'migrate {--database= : The database connection to use}
{--force : Force the operation to run when in production}
- {--path= : The path to the migrations files to be executed}
+ {--path=* : The path(s) to the migrations files to be executed}
{--realpath : Indicate any provided migration file paths are pre-resolved absolute paths}
+ {--schema-path= : The path to a schema dump file}
{--pretend : Dump the SQL queries that would be run}
{--seed : Indicates if the seed task should be re-run}
- {--step : Force the migrations to be run so they can be rolled back individually}';
+ {--seeder= : The class name of the root seeder}
+ {--step : Force the migrations to be run so they can be rolled back individually}
+ {--graceful : Return a successful exit code even if an error occurs}';
/**
* The console command description.
@@ -36,47 +52,84 @@ class MigrateCommand extends BaseCommand
*/
protected $migrator;
+ /**
+ * The event dispatcher instance.
+ *
+ * @var \Illuminate\Contracts\Events\Dispatcher
+ */
+ protected $dispatcher;
+
/**
* Create a new migration command instance.
*
* @param \Illuminate\Database\Migrations\Migrator $migrator
- * @return void
+ * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher
*/
- public function __construct(Migrator $migrator)
+ public function __construct(Migrator $migrator, Dispatcher $dispatcher)
{
parent::__construct();
$this->migrator = $migrator;
+ $this->dispatcher = $dispatcher;
}
/**
* Execute the console command.
*
- * @return void
+ * @return int
+ *
+ * @throws \Throwable
*/
public function handle()
{
if (! $this->confirmToProceed()) {
- return;
+ return 1;
+ }
+
+ try {
+ $this->runMigrations();
+ } catch (Throwable $e) {
+ if ($this->option('graceful')) {
+ $this->components->warn($e->getMessage());
+
+ return 0;
+ }
+
+ throw $e;
}
- $this->prepareDatabase();
+ return 0;
+ }
+
+ /**
+ * Run the pending migrations.
+ *
+ * @return void
+ */
+ protected function runMigrations()
+ {
+ $this->migrator->usingConnection($this->option('database'), function () {
+ $this->prepareDatabase();
- // Next, we will check to see if a path option has been defined. If it has
- // we will use the path relative to the root of this installation folder
- // so that migrations may be run for any path within the applications.
- $this->migrator->setOutput($this->output)
+ // Next, we will check to see if a path option has been defined. If it has
+ // we will use the path relative to the root of this installation folder
+ // so that migrations may be run for any path within the applications.
+ $this->migrator->setOutput($this->output)
->run($this->getMigrationPaths(), [
'pretend' => $this->option('pretend'),
'step' => $this->option('step'),
]);
- // Finally, if the "seed" option has been given, we will re-run the database
- // seed task to re-populate the database, which is convenient when adding
- // a migration and a seed at the same time, as it is only this command.
- if ($this->option('seed') && ! $this->option('pretend')) {
- $this->call('db:seed', ['--force' => true]);
- }
+ // Finally, if the "seed" option has been given, we will re-run the database
+ // seed task to re-populate the database, which is convenient when adding
+ // a migration and a seed at the same time, as it is only this command.
+ if ($this->option('seed') && ! $this->option('pretend')) {
+ $this->call('db:seed', [
+ '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder',
+ '--force' => true,
+ ]);
+ }
+ });
}
/**
@@ -86,12 +139,205 @@ public function handle()
*/
protected function prepareDatabase()
{
- $this->migrator->setConnection($this->option('database'));
+ if (! $this->repositoryExists()) {
+ $this->components->info('Preparing database.');
+
+ $this->components->task('Creating migration table', function () {
+ return $this->callSilent('migrate:install', array_filter([
+ '--database' => $this->option('database'),
+ ])) == 0;
+ });
+
+ $this->newLine();
+ }
+
+ if (! $this->migrator->hasRunAnyMigrations() && ! $this->option('pretend')) {
+ $this->loadSchemaState();
+ }
+ }
+
+ /**
+ * Determine if the migrator repository exists.
+ *
+ * @return bool
+ */
+ protected function repositoryExists()
+ {
+ return retry(2, fn () => $this->migrator->repositoryExists(), 0, function ($e) {
+ try {
+ return $this->handleMissingDatabase($e->getPrevious());
+ } catch (Throwable) {
+ return false;
+ }
+ });
+ }
+
+ /**
+ * Attempt to create the database if it is missing.
+ *
+ * @param \Throwable $e
+ * @return bool
+ */
+ protected function handleMissingDatabase(Throwable $e)
+ {
+ if ($e instanceof SQLiteDatabaseDoesNotExistException) {
+ return $this->createMissingSqliteDatabase($e->path);
+ }
+
+ $connection = $this->migrator->resolveConnection($this->option('database'));
+
+ if (! $e instanceof PDOException) {
+ return false;
+ }
+
+ if (($e->getCode() === 1049 && in_array($connection->getDriverName(), ['mysql', 'mariadb'])) ||
+ (($e->errorInfo[0] ?? null) == '08006' &&
+ $connection->getDriverName() == 'pgsql' &&
+ Str::contains($e->getMessage(), '"'.$connection->getDatabaseName().'"'))) {
+ return $this->createMissingMySqlOrPgsqlDatabase($connection);
+ }
+
+ return false;
+ }
+
+ /**
+ * Create a missing SQLite database.
+ *
+ * @param string $path
+ * @return bool
+ *
+ * @throws \RuntimeException
+ */
+ protected function createMissingSqliteDatabase($path)
+ {
+ if ($this->option('force')) {
+ return touch($path);
+ }
+
+ if ($this->option('no-interaction')) {
+ return false;
+ }
+
+ $this->components->warn('The SQLite database configured for this application does not exist: '.$path);
+
+ if (! confirm('Would you like to create it?', default: true)) {
+ $this->components->info('Operation cancelled. No database was created.');
+
+ throw new RuntimeException('Database was not created. Aborting migration.');
+ }
+
+ return touch($path);
+ }
+
+ /**
+ * Create a missing MySQL or Postgres database.
+ *
+ * @param \Illuminate\Database\Connection $connection
+ * @return bool
+ *
+ * @throws \RuntimeException
+ */
+ protected function createMissingMySqlOrPgsqlDatabase($connection)
+ {
+ if ($this->laravel['config']->get("database.connections.{$connection->getName()}.database") !== $connection->getDatabaseName()) {
+ return false;
+ }
+
+ if (! $this->option('force') && $this->option('no-interaction')) {
+ return false;
+ }
+
+ if (! $this->option('force') && ! $this->option('no-interaction')) {
+ $this->components->warn("The database '{$connection->getDatabaseName()}' does not exist on the '{$connection->getName()}' connection.");
+
+ if (! confirm('Would you like to create it?', default: true)) {
+ $this->components->info('Operation cancelled. No database was created.');
+
+ throw new RuntimeException('Database was not created. Aborting migration.');
+ }
+ }
+ try {
+ $this->laravel['config']->set(
+ "database.connections.{$connection->getName()}.database",
+ match ($connection->getDriverName()) {
+ 'mysql', 'mariadb' => null,
+ 'pgsql' => 'postgres',
+ },
+ );
+
+ $this->laravel['db']->purge();
+
+ $freshConnection = $this->migrator->resolveConnection($this->option('database'));
- if (! $this->migrator->repositoryExists()) {
- $this->call('migrate:install', array_filter([
- '--database' => $this->option('database'),
- ]));
+ return tap($freshConnection->unprepared(
+ match ($connection->getDriverName()) {
+ 'mysql', 'mariadb' => "CREATE DATABASE IF NOT EXISTS `{$connection->getDatabaseName()}`",
+ 'pgsql' => 'CREATE DATABASE "'.$connection->getDatabaseName().'"',
+ }
+ ), function () {
+ $this->laravel['db']->purge();
+ });
+ } finally {
+ $this->laravel['config']->set("database.connections.{$connection->getName()}.database", $connection->getDatabaseName());
}
}
+
+ /**
+ * Load the schema state to seed the initial database schema structure.
+ *
+ * @return void
+ */
+ protected function loadSchemaState()
+ {
+ $connection = $this->migrator->resolveConnection($this->option('database'));
+
+ // First, we will make sure that the connection supports schema loading and that
+ // the schema file exists before we proceed any further. If not, we will just
+ // continue with the standard migration operation as normal without errors.
+ if ($connection instanceof SqlServerConnection ||
+ ! is_file($path = $this->schemaPath($connection))) {
+ return;
+ }
+
+ $this->components->info('Loading stored database schemas.');
+
+ $this->components->task($path, function () use ($connection, $path) {
+ // Since the schema file will create the "migrations" table and reload it to its
+ // proper state, we need to delete it here so we don't get an error that this
+ // table already exists when the stored database schema file gets executed.
+ $this->migrator->deleteRepository();
+
+ $connection->getSchemaState()->handleOutputUsing(function ($type, $buffer) {
+ $this->output->write($buffer);
+ })->load($path);
+ });
+
+ $this->newLine();
+
+ // Finally, we will fire an event that this schema has been loaded so developers
+ // can perform any post schema load tasks that are necessary in listeners for
+ // this event, which may seed the database tables with some necessary data.
+ $this->dispatcher->dispatch(
+ new SchemaLoaded($connection, $path)
+ );
+ }
+
+ /**
+ * Get the path to the stored schema for the given connection.
+ *
+ * @param \Illuminate\Database\Connection $connection
+ * @return string
+ */
+ protected function schemaPath($connection)
+ {
+ if ($this->option('schema-path')) {
+ return $this->option('schema-path');
+ }
+
+ if (file_exists($path = database_path('schema/'.$connection->getName().'-schema.dump'))) {
+ return $path;
+ }
+
+ return database_path('schema/'.$connection->getName().'-schema.sql');
+ }
}
diff --git a/Console/Migrations/MigrateMakeCommand.php b/Console/Migrations/MigrateMakeCommand.php
index 1cce57b25f..ac5077f58d 100644
--- a/Console/Migrations/MigrateMakeCommand.php
+++ b/Console/Migrations/MigrateMakeCommand.php
@@ -2,11 +2,14 @@
namespace Illuminate\Database\Console\Migrations;
-use Illuminate\Support\Str;
-use Illuminate\Support\Composer;
+use Illuminate\Contracts\Console\PromptsForMissingInput;
use Illuminate\Database\Migrations\MigrationCreator;
+use Illuminate\Support\Composer;
+use Illuminate\Support\Str;
+use Symfony\Component\Console\Attribute\AsCommand;
-class MigrateMakeCommand extends BaseCommand
+#[AsCommand(name: 'make:migration')]
+class MigrateMakeCommand extends BaseCommand implements PromptsForMissingInput
{
/**
* The console command signature.
@@ -17,7 +20,8 @@ class MigrateMakeCommand extends BaseCommand
{--create= : The table to be created}
{--table= : The table to migrate}
{--path= : The location where the migration file should be created}
- {--realpath : Indicate any provided migration file paths are pre-resolved absolute paths}';
+ {--realpath : Indicate any provided migration file paths are pre-resolved absolute paths}
+ {--fullpath : Output the full path of the migration (Deprecated)}';
/**
* The console command description.
@@ -37,6 +41,8 @@ class MigrateMakeCommand extends BaseCommand
* The Composer instance.
*
* @var \Illuminate\Support\Composer
+ *
+ * @deprecated Will be removed in a future Laravel version.
*/
protected $composer;
@@ -45,7 +51,6 @@ class MigrateMakeCommand extends BaseCommand
*
* @param \Illuminate\Database\Migrations\MigrationCreator $creator
* @param \Illuminate\Support\Composer $composer
- * @return void
*/
public function __construct(MigrationCreator $creator, Composer $composer)
{
@@ -91,8 +96,6 @@ public function handle()
// the migration out, we will dump-autoload for the entire framework to
// make sure that the migrations are registered by the class loaders.
$this->writeMigration($name, $table, $create);
-
- $this->composer->dumpAutoloads();
}
/**
@@ -100,16 +103,20 @@ public function handle()
*
* @param string $name
* @param string $table
- * @param bool $create
- * @return string
+ * @param bool $create
+ * @return void
*/
protected function writeMigration($name, $table, $create)
{
- $file = pathinfo($this->creator->create(
+ $file = $this->creator->create(
$name, $this->getMigrationPath(), $table, $create
- ), PATHINFO_FILENAME);
+ );
+
+ if (windows_os()) {
+ $file = str_replace('/', '\\', $file);
+ }
- $this->line("Created Migration: {$file}");
+ $this->components->info(sprintf('Migration [%s] created successfully.', $file));
}
/**
@@ -121,20 +128,22 @@ protected function getMigrationPath()
{
if (! is_null($targetPath = $this->input->getOption('path'))) {
return ! $this->usingRealPath()
- ? $this->laravel->basePath().'/'.$targetPath
- : $targetPath;
+ ? $this->laravel->basePath().'/'.$targetPath
+ : $targetPath;
}
return parent::getMigrationPath();
}
/**
- * Determine if the given path(s) are pre-resolved "real" paths.
+ * Prompt for missing input arguments using the returned questions.
*
- * @return bool
+ * @return array
*/
- protected function usingRealPath()
+ protected function promptForMissingArgumentsUsing()
{
- return $this->input->hasOption('realpath') && $this->option('realpath');
+ return [
+ 'name' => ['What should the migration be named?', 'E.g. create_flights_table'],
+ ];
}
}
diff --git a/Console/Migrations/RefreshCommand.php b/Console/Migrations/RefreshCommand.php
index d27bb15550..7d74f5b38c 100755
--- a/Console/Migrations/RefreshCommand.php
+++ b/Console/Migrations/RefreshCommand.php
@@ -4,11 +4,16 @@
use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
+use Illuminate\Console\Prohibitable;
+use Illuminate\Contracts\Events\Dispatcher;
+use Illuminate\Database\Events\DatabaseRefreshed;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'migrate:refresh')]
class RefreshCommand extends Command
{
- use ConfirmableTrait;
+ use ConfirmableTrait, Prohibitable;
/**
* The console command name.
@@ -27,12 +32,13 @@ class RefreshCommand extends Command
/**
* Execute the console command.
*
- * @return void
+ * @return int
*/
public function handle()
{
- if (! $this->confirmToProceed()) {
- return;
+ if ($this->isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
}
// Next we'll gather some of the options so that we can have the right options
@@ -63,9 +69,17 @@ public function handle()
'--force' => true,
]));
+ if ($this->laravel->bound(Dispatcher::class)) {
+ $this->laravel[Dispatcher::class]->dispatch(
+ new DatabaseRefreshed($database, $this->needsSeeding())
+ );
+ }
+
if ($this->needsSeeding()) {
$this->runSeeder($database);
}
+
+ return 0;
}
/**
@@ -124,7 +138,7 @@ protected function runSeeder($database)
{
$this->call('db:seed', array_filter([
'--database' => $database,
- '--class' => $this->option('seeder') ?: 'DatabaseSeeder',
+ '--class' => $this->option('seeder') ?: 'Database\\Seeders\\DatabaseSeeder',
'--force' => true,
]));
}
@@ -138,17 +152,11 @@ protected function getOptions()
{
return [
['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'],
-
['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'],
-
- ['path', null, InputOption::VALUE_OPTIONAL, 'The path to the migrations files to be executed'],
-
+ ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to be executed'],
['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'],
-
['seed', null, InputOption::VALUE_NONE, 'Indicates if the seed task should be re-run'],
-
['seeder', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder'],
-
['step', null, InputOption::VALUE_OPTIONAL, 'The number of migrations to be reverted & re-run'],
];
}
diff --git a/Console/Migrations/ResetCommand.php b/Console/Migrations/ResetCommand.php
index 28803806a3..787801bab2 100755
--- a/Console/Migrations/ResetCommand.php
+++ b/Console/Migrations/ResetCommand.php
@@ -2,13 +2,17 @@
namespace Illuminate\Database\Console\Migrations;
+use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
+use Illuminate\Console\Prohibitable;
use Illuminate\Database\Migrations\Migrator;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'migrate:reset')]
class ResetCommand extends BaseCommand
{
- use ConfirmableTrait;
+ use ConfirmableTrait, Prohibitable;
/**
* The console command name.
@@ -35,7 +39,6 @@ class ResetCommand extends BaseCommand
* Create a new migration rollback command instance.
*
* @param \Illuminate\Database\Migrations\Migrator $migrator
- * @return void
*/
public function __construct(Migrator $migrator)
{
@@ -47,26 +50,27 @@ public function __construct(Migrator $migrator)
/**
* Execute the console command.
*
- * @return void
+ * @return int
*/
public function handle()
{
- if (! $this->confirmToProceed()) {
- return;
+ if ($this->isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
}
- $this->migrator->setConnection($this->option('database'));
-
- // First, we'll make sure that the migration table actually exists before we
- // start trying to rollback and re-run all of the migrations. If it's not
- // present we'll just bail out with an info message for the developers.
- if (! $this->migrator->repositoryExists()) {
- return $this->comment('Migration table not found.');
- }
-
- $this->migrator->setOutput($this->output)->reset(
- $this->getMigrationPaths(), $this->option('pretend')
- );
+ return $this->migrator->usingConnection($this->option('database'), function () {
+ // First, we'll make sure that the migration table actually exists before we
+ // start trying to rollback and re-run all of the migrations. If it's not
+ // present we'll just bail out with an info message for the developers.
+ if (! $this->migrator->repositoryExists()) {
+ return $this->components->warn('Migration table not found.');
+ }
+
+ $this->migrator->setOutput($this->output)->reset(
+ $this->getMigrationPaths(), $this->option('pretend')
+ );
+ });
}
/**
diff --git a/Console/Migrations/RollbackCommand.php b/Console/Migrations/RollbackCommand.php
index 457bcb1dbe..9c3543ec5b 100755
--- a/Console/Migrations/RollbackCommand.php
+++ b/Console/Migrations/RollbackCommand.php
@@ -2,13 +2,17 @@
namespace Illuminate\Database\Console\Migrations;
+use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
+use Illuminate\Console\Prohibitable;
use Illuminate\Database\Migrations\Migrator;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand('migrate:rollback')]
class RollbackCommand extends BaseCommand
{
- use ConfirmableTrait;
+ use ConfirmableTrait, Prohibitable;
/**
* The console command name.
@@ -35,7 +39,6 @@ class RollbackCommand extends BaseCommand
* Create a new migration rollback command instance.
*
* @param \Illuminate\Database\Migrations\Migrator $migrator
- * @return void
*/
public function __construct(Migrator $migrator)
{
@@ -47,22 +50,26 @@ public function __construct(Migrator $migrator)
/**
* Execute the console command.
*
- * @return void
+ * @return int
*/
public function handle()
{
- if (! $this->confirmToProceed()) {
- return;
+ if ($this->isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
}
- $this->migrator->setConnection($this->option('database'));
-
- $this->migrator->setOutput($this->output)->rollback(
- $this->getMigrationPaths(), [
- 'pretend' => $this->option('pretend'),
- 'step' => (int) $this->option('step'),
- ]
- );
+ $this->migrator->usingConnection($this->option('database'), function () {
+ $this->migrator->setOutput($this->output)->rollback(
+ $this->getMigrationPaths(), [
+ 'pretend' => $this->option('pretend'),
+ 'step' => (int) $this->option('step'),
+ 'batch' => (int) $this->option('batch'),
+ ]
+ );
+ });
+
+ return 0;
}
/**
@@ -74,16 +81,12 @@ protected function getOptions()
{
return [
['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'],
-
['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'],
-
- ['path', null, InputOption::VALUE_OPTIONAL, 'The path to the migrations files to be executed'],
-
+ ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to be executed'],
['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'],
-
['pretend', null, InputOption::VALUE_NONE, 'Dump the SQL queries that would be run'],
-
['step', null, InputOption::VALUE_OPTIONAL, 'The number of migrations to be reverted'],
+ ['batch', null, InputOption::VALUE_REQUIRED, 'The batch of migrations (identified by their batch number) to be reverted'],
];
}
}
diff --git a/Console/Migrations/StatusCommand.php b/Console/Migrations/StatusCommand.php
index 2e9f4f362c..cbb16a133c 100644
--- a/Console/Migrations/StatusCommand.php
+++ b/Console/Migrations/StatusCommand.php
@@ -2,10 +2,13 @@
namespace Illuminate\Database\Console\Migrations;
-use Illuminate\Support\Collection;
use Illuminate\Database\Migrations\Migrator;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Stringable;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'migrate:status')]
class StatusCommand extends BaseCommand
{
/**
@@ -32,8 +35,7 @@ class StatusCommand extends BaseCommand
/**
* Create a new migration rollback command instance.
*
- * @param \Illuminate\Database\Migrations\Migrator $migrator
- * @return void
+ * @param \Illuminate\Database\Migrations\Migrator $migrator
*/
public function __construct(Migrator $migrator)
{
@@ -45,29 +47,51 @@ public function __construct(Migrator $migrator)
/**
* Execute the console command.
*
- * @return void
+ * @return int|null
*/
public function handle()
{
- $this->migrator->setConnection($this->option('database'));
+ return $this->migrator->usingConnection($this->option('database'), function () {
+ if (! $this->migrator->repositoryExists()) {
+ $this->components->error('Migration table not found.');
+
+ return 1;
+ }
+
+ $ran = $this->migrator->getRepository()->getRan();
+
+ $batches = $this->migrator->getRepository()->getMigrationBatches();
+
+ $migrations = $this->getStatusFor($ran, $batches)
+ ->when($this->option('pending') !== false, fn ($collection) => $collection->filter(function ($migration) {
+ return (new Stringable($migration[1]))->contains('Pending');
+ }));
- if (! $this->migrator->repositoryExists()) {
- return $this->error('Migration table not found.');
- }
+ if (count($migrations) > 0) {
+ $this->newLine();
- $ran = $this->migrator->getRepository()->getRan();
+ $this->components->twoColumnDetail('Migration name>', 'Batch / Status>');
- $batches = $this->migrator->getRepository()->getMigrationBatches();
+ $migrations
+ ->each(
+ fn ($migration) => $this->components->twoColumnDetail($migration[0], $migration[1])
+ );
- if (count($migrations = $this->getStatusFor($ran, $batches)) > 0) {
- $this->table(['Ran?', 'Migration', 'Batch'], $migrations);
- } else {
- $this->error('No migrations found');
- }
+ $this->newLine();
+ } elseif ($this->option('pending') !== false) {
+ $this->components->info('No pending migrations');
+ } else {
+ $this->components->info('No migrations found');
+ }
+
+ if ($this->option('pending') && $migrations->some(fn ($m) => (new Stringable($m[1]))->contains('Pending'))) {
+ return $this->option('pending');
+ }
+ });
}
/**
- * Get the status for the given ran migrations.
+ * Get the status for the given run migrations.
*
* @param array $ran
* @param array $batches
@@ -75,14 +99,20 @@ public function handle()
*/
protected function getStatusFor(array $ran, array $batches)
{
- return Collection::make($this->getAllMigrationFiles())
- ->map(function ($migration) use ($ran, $batches) {
- $migrationName = $this->migrator->getMigrationName($migration);
-
- return in_array($migrationName, $ran)
- ? ['Yes', $migrationName, $batches[$migrationName]]
- : ['No', $migrationName];
- });
+ return (new Collection($this->getAllMigrationFiles()))
+ ->map(function ($migration) use ($ran, $batches) {
+ $migrationName = $this->migrator->getMigrationName($migration);
+
+ $status = in_array($migrationName, $ran)
+ ? 'Ran>'
+ : 'Pending>';
+
+ if (in_array($migrationName, $ran)) {
+ $status = '['.$batches[$migrationName].'] '.$status;
+ }
+
+ return [$migrationName, $status];
+ });
}
/**
@@ -104,9 +134,8 @@ protected function getOptions()
{
return [
['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'],
-
- ['path', null, InputOption::VALUE_OPTIONAL, 'The path to the migrations files to use'],
-
+ ['pending', null, InputOption::VALUE_OPTIONAL, 'Only list pending migrations', false],
+ ['path', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'The path(s) to the migrations files to use'],
['realpath', null, InputOption::VALUE_NONE, 'Indicate any provided migration file paths are pre-resolved absolute paths'],
];
}
diff --git a/Console/Migrations/TableGuesser.php b/Console/Migrations/TableGuesser.php
index 82dfbddbbc..baf19485ee 100644
--- a/Console/Migrations/TableGuesser.php
+++ b/Console/Migrations/TableGuesser.php
@@ -10,15 +10,15 @@ class TableGuesser
];
const CHANGE_PATTERNS = [
- '/_(to|from|in)_(\w+)_table$/',
- '/_(to|from|in)_(\w+)$/',
+ '/.+_(to|from|in)_(\w+)_table$/',
+ '/.+_(to|from|in)_(\w+)$/',
];
/**
* Attempt to guess the table name and "creation" status of the given migration.
*
* @param string $migration
- * @return array
+ * @return array{string, bool}
*/
public static function guess($migration)
{
diff --git a/Console/MonitorCommand.php b/Console/MonitorCommand.php
new file mode 100644
index 0000000000..334422b6c4
--- /dev/null
+++ b/Console/MonitorCommand.php
@@ -0,0 +1,141 @@
+connection = $connection;
+ $this->events = $events;
+ }
+
+ /**
+ * Execute the console command.
+ *
+ * @return void
+ */
+ public function handle()
+ {
+ $databases = $this->parseDatabases($this->option('databases'));
+
+ $this->displayConnections($databases);
+
+ if ($this->option('max')) {
+ $this->dispatchEvents($databases);
+ }
+ }
+
+ /**
+ * Parse the database into an array of the connections.
+ *
+ * @param string $databases
+ * @return \Illuminate\Support\Collection
+ */
+ protected function parseDatabases($databases)
+ {
+ return (new Collection(explode(',', $databases)))->map(function ($database) {
+ if (! $database) {
+ $database = $this->laravel['config']['database.default'];
+ }
+
+ $maxConnections = $this->option('max');
+
+ $connections = $this->connection->connection($database)->threadCount();
+
+ return [
+ 'database' => $database,
+ 'connections' => $connections,
+ 'status' => $maxConnections && $connections >= $maxConnections ? 'ALERT>' : 'OK>',
+ ];
+ });
+ }
+
+ /**
+ * Display the databases and their connection counts in the console.
+ *
+ * @param \Illuminate\Support\Collection $databases
+ * @return void
+ */
+ protected function displayConnections($databases)
+ {
+ $this->newLine();
+
+ $this->components->twoColumnDetail('Database name>', 'Connections>');
+
+ $databases->each(function ($database) {
+ $status = '['.$database['connections'].'] '.$database['status'];
+
+ $this->components->twoColumnDetail($database['database'], $status);
+ });
+
+ $this->newLine();
+ }
+
+ /**
+ * Dispatch the database monitoring events.
+ *
+ * @param \Illuminate\Support\Collection $databases
+ * @return void
+ */
+ protected function dispatchEvents($databases)
+ {
+ $databases->each(function ($database) {
+ if ($database['status'] === 'OK>') {
+ return;
+ }
+
+ $this->events->dispatch(
+ new DatabaseBusy(
+ $database['database'],
+ $database['connections']
+ )
+ );
+ });
+ }
+}
diff --git a/Console/PruneCommand.php b/Console/PruneCommand.php
new file mode 100644
index 0000000000..527ab70c6f
--- /dev/null
+++ b/Console/PruneCommand.php
@@ -0,0 +1,199 @@
+models();
+
+ if ($models->isEmpty()) {
+ $this->components->info('No prunable models found.');
+
+ return;
+ }
+
+ if ($this->option('pretend')) {
+ $models->each(function ($model) {
+ $this->pretendToPrune($model);
+ });
+
+ return;
+ }
+
+ $pruning = [];
+
+ $events->listen(ModelsPruned::class, function ($event) use (&$pruning) {
+ if (! in_array($event->model, $pruning)) {
+ $pruning[] = $event->model;
+
+ $this->newLine();
+
+ $this->components->info(sprintf('Pruning [%s] records.', $event->model));
+ }
+
+ $this->components->twoColumnDetail($event->model, "{$event->count} records");
+ });
+
+ $events->dispatch(new ModelPruningStarting($models->all()));
+
+ $models->each(function ($model) {
+ $this->pruneModel($model);
+ });
+
+ $events->dispatch(new ModelPruningFinished($models->all()));
+
+ $events->forget(ModelsPruned::class);
+ }
+
+ /**
+ * Prune the given model.
+ *
+ * @param string $model
+ * @return void
+ */
+ protected function pruneModel(string $model)
+ {
+ $instance = new $model;
+
+ $chunkSize = property_exists($instance, 'prunableChunkSize')
+ ? $instance->prunableChunkSize
+ : $this->option('chunk');
+
+ $total = $model::isPrunable()
+ ? $instance->pruneAll($chunkSize)
+ : 0;
+
+ if ($total == 0) {
+ $this->components->info("No prunable [$model] records found.");
+ }
+ }
+
+ /**
+ * Determine the models that should be pruned.
+ *
+ * @return \Illuminate\Support\Collection
+ *
+ * @throws \InvalidArgumentException
+ */
+ protected function models()
+ {
+ $models = $this->option('model');
+ $except = $this->option('except');
+
+ if ($models && $except) {
+ throw new InvalidArgumentException('The --models and --except options cannot be combined.');
+ }
+
+ if ($models) {
+ return (new Collection($models))
+ ->filter(static fn (string $model) => class_exists($model))
+ ->values();
+ }
+
+ return (new Collection(Finder::create()->in($this->getPath())->files()->name('*.php')))
+ ->map(function ($model) {
+ $namespace = $this->laravel->getNamespace();
+
+ return $namespace.str_replace(
+ ['/', '.php'],
+ ['\\', ''],
+ Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR)
+ );
+ })
+ ->when(! empty($except), fn ($models) => $models->reject(fn ($model) => in_array($model, $except)))
+ ->filter(fn ($model) => $this->isPrunable($model))
+ ->values();
+ }
+
+ /**
+ * Get the path where models are located.
+ *
+ * @return string[]|string
+ */
+ protected function getPath()
+ {
+ if (! empty($path = $this->option('path'))) {
+ return (new Collection($path))
+ ->map(fn ($path) => base_path($path))
+ ->all();
+ }
+
+ return app_path('Models');
+ }
+
+ /**
+ * Display how many models will be pruned.
+ *
+ * @param class-string $model
+ * @return void
+ */
+ protected function pretendToPrune($model)
+ {
+ $instance = new $model;
+
+ $count = $instance->prunable()
+ ->when($model::isSoftDeletable(), function ($query) {
+ $query->withTrashed();
+ })->count();
+
+ if ($count === 0) {
+ $this->components->info("No prunable [$model] records found.");
+ } else {
+ $this->components->info("{$count} [{$model}] records will be pruned.");
+ }
+ }
+
+ /**
+ * Determine if the given model is prunable.
+ *
+ * @param string $model
+ * @return bool
+ */
+ protected function isPrunable(string $model)
+ {
+ return class_exists($model)
+ && is_a($model, Model::class, true)
+ && ! (new \ReflectionClass($model))->isAbstract()
+ && $model::isPrunable();
+ }
+}
diff --git a/Console/Seeds/SeedCommand.php b/Console/Seeds/SeedCommand.php
index 06a13e0cb4..515ff410b3 100644
--- a/Console/Seeds/SeedCommand.php
+++ b/Console/Seeds/SeedCommand.php
@@ -3,14 +3,18 @@
namespace Illuminate\Database\Console\Seeds;
use Illuminate\Console\Command;
-use Illuminate\Database\Eloquent\Model;
use Illuminate\Console\ConfirmableTrait;
-use Symfony\Component\Console\Input\InputOption;
+use Illuminate\Console\Prohibitable;
use Illuminate\Database\ConnectionResolverInterface as Resolver;
+use Illuminate\Database\Eloquent\Model;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputOption;
+#[AsCommand(name: 'db:seed')]
class SeedCommand extends Command
{
- use ConfirmableTrait;
+ use ConfirmableTrait, Prohibitable;
/**
* The console command name.
@@ -37,7 +41,6 @@ class SeedCommand extends Command
* Create a new database seed command instance.
*
* @param \Illuminate\Database\ConnectionResolverInterface $resolver
- * @return void
*/
public function __construct(Resolver $resolver)
{
@@ -49,21 +52,30 @@ public function __construct(Resolver $resolver)
/**
* Execute the console command.
*
- * @return void
+ * @return int
*/
public function handle()
{
- if (! $this->confirmToProceed()) {
- return;
+ if ($this->isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
}
+ $this->components->info('Seeding database.');
+
+ $previousConnection = $this->resolver->getDefaultConnection();
+
$this->resolver->setDefaultConnection($this->getDatabase());
Model::unguarded(function () {
$this->getSeeder()->__invoke();
});
- $this->info('Database seeding completed successfully.');
+ if ($previousConnection) {
+ $this->resolver->setDefaultConnection($previousConnection);
+ }
+
+ return 0;
}
/**
@@ -73,9 +85,20 @@ public function handle()
*/
protected function getSeeder()
{
- $class = $this->laravel->make($this->input->getOption('class'));
+ $class = $this->input->getArgument('class') ?? $this->input->getOption('class');
+
+ if (! str_contains($class, '\\')) {
+ $class = 'Database\\Seeders\\'.$class;
+ }
+
+ if ($class === 'Database\\Seeders\\DatabaseSeeder' &&
+ ! class_exists($class)) {
+ $class = 'DatabaseSeeder';
+ }
- return $class->setContainer($this->laravel)->setCommand($this);
+ return $this->laravel->make($class)
+ ->setContainer($this->laravel)
+ ->setCommand($this);
}
/**
@@ -90,6 +113,18 @@ protected function getDatabase()
return $database ?: $this->laravel['config']['database.default'];
}
+ /**
+ * Get the console command arguments.
+ *
+ * @return array
+ */
+ protected function getArguments()
+ {
+ return [
+ ['class', InputArgument::OPTIONAL, 'The class name of the root seeder', null],
+ ];
+ }
+
/**
* Get the console command options.
*
@@ -98,10 +133,8 @@ protected function getDatabase()
protected function getOptions()
{
return [
- ['class', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder', 'DatabaseSeeder'],
-
+ ['class', null, InputOption::VALUE_OPTIONAL, 'The class name of the root seeder', 'Database\\Seeders\\DatabaseSeeder'],
['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to seed'],
-
['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'],
];
}
diff --git a/Console/Seeds/SeederMakeCommand.php b/Console/Seeds/SeederMakeCommand.php
index 6e85e3e43f..c021bbbe54 100644
--- a/Console/Seeds/SeederMakeCommand.php
+++ b/Console/Seeds/SeederMakeCommand.php
@@ -2,10 +2,11 @@
namespace Illuminate\Database\Console\Seeds;
-use Illuminate\Support\Composer;
-use Illuminate\Filesystem\Filesystem;
use Illuminate\Console\GeneratorCommand;
+use Illuminate\Support\Str;
+use Symfony\Component\Console\Attribute\AsCommand;
+#[AsCommand(name: 'make:seeder')]
class SeederMakeCommand extends GeneratorCommand
{
/**
@@ -30,46 +31,36 @@ class SeederMakeCommand extends GeneratorCommand
protected $type = 'Seeder';
/**
- * The Composer instance.
- *
- * @var \Illuminate\Support\Composer
- */
- protected $composer;
-
- /**
- * Create a new command instance.
+ * Execute the console command.
*
- * @param \Illuminate\Filesystem\Filesystem $files
- * @param \Illuminate\Support\Composer $composer
* @return void
*/
- public function __construct(Filesystem $files, Composer $composer)
+ public function handle()
{
- parent::__construct($files);
-
- $this->composer = $composer;
+ parent::handle();
}
/**
- * Execute the console command.
+ * Get the stub file for the generator.
*
- * @return void
+ * @return string
*/
- public function handle()
+ protected function getStub()
{
- parent::handle();
-
- $this->composer->dumpAutoloads();
+ return $this->resolveStubPath('/stubs/seeder.stub');
}
/**
- * Get the stub file for the generator.
+ * Resolve the fully-qualified path to the stub.
*
+ * @param string $stub
* @return string
*/
- protected function getStub()
+ protected function resolveStubPath($stub)
{
- return __DIR__.'/stubs/seeder.stub';
+ return is_file($customPath = $this->laravel->basePath(trim($stub, '/')))
+ ? $customPath
+ : __DIR__.$stub;
}
/**
@@ -80,17 +71,22 @@ protected function getStub()
*/
protected function getPath($name)
{
- return $this->laravel->databasePath().'/seeds/'.$name.'.php';
+ $name = str_replace('\\', '/', Str::replaceFirst($this->rootNamespace(), '', $name));
+
+ if (is_dir($this->laravel->databasePath().'/seeds')) {
+ return $this->laravel->databasePath().'/seeds/'.$name.'.php';
+ }
+
+ return $this->laravel->databasePath().'/seeders/'.$name.'.php';
}
/**
- * Parse the class name and format according to the root namespace.
+ * Get the root namespace for the class.
*
- * @param string $name
* @return string
*/
- protected function qualifyClass($name)
+ protected function rootNamespace()
{
- return $name;
+ return 'Database\Seeders\\';
}
}
diff --git a/Console/Seeds/WithoutModelEvents.php b/Console/Seeds/WithoutModelEvents.php
new file mode 100644
index 0000000000..acd9ec3f20
--- /dev/null
+++ b/Console/Seeds/WithoutModelEvents.php
@@ -0,0 +1,19 @@
+ Model::withoutEvents($callback);
+ }
+}
diff --git a/Console/Seeds/stubs/seeder.stub b/Console/Seeds/stubs/seeder.stub
index 4aa3845422..8b5403f38d 100644
--- a/Console/Seeds/stubs/seeder.stub
+++ b/Console/Seeds/stubs/seeder.stub
@@ -1,15 +1,16 @@
Note: This can be slow on large databases >}
+ {--views : Show the database views Note: This can be slow on large databases >}
+ {--types : Show the user defined types}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Display information about the given database';
+
+ /**
+ * Execute the console command.
+ *
+ * @param \Illuminate\Database\ConnectionResolverInterface $connections
+ * @return int
+ */
+ public function handle(ConnectionResolverInterface $connections)
+ {
+ $connection = $connections->connection($database = $this->input->getOption('database'));
+
+ $schema = $connection->getSchemaBuilder();
+
+ $data = [
+ 'platform' => [
+ 'config' => $this->getConfigFromDatabase($database),
+ 'name' => $connection->getDriverTitle(),
+ 'connection' => $connection->getName(),
+ 'version' => $connection->getServerVersion(),
+ 'open_connections' => $connection->threadCount(),
+ ],
+ 'tables' => $this->tables($connection, $schema),
+ ];
+
+ if ($this->option('views')) {
+ $data['views'] = $this->views($connection, $schema);
+ }
+
+ if ($this->option('types')) {
+ $data['types'] = $this->types($connection, $schema);
+ }
+
+ $this->display($data);
+
+ return 0;
+ }
+
+ /**
+ * Get information regarding the tables within the database.
+ *
+ * @param \Illuminate\Database\ConnectionInterface $connection
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @return \Illuminate\Support\Collection
+ */
+ protected function tables(ConnectionInterface $connection, Builder $schema)
+ {
+ return (new Collection($schema->getTables()))->map(fn ($table) => [
+ 'table' => $table['name'],
+ 'schema' => $table['schema'],
+ 'schema_qualified_name' => $table['schema_qualified_name'],
+ 'size' => $table['size'],
+ 'rows' => $this->option('counts')
+ ? $connection->withoutTablePrefix(fn ($connection) => $connection->table($table['schema_qualified_name'])->count())
+ : null,
+ 'engine' => $table['engine'],
+ 'collation' => $table['collation'],
+ 'comment' => $table['comment'],
+ ]);
+ }
+
+ /**
+ * Get information regarding the views within the database.
+ *
+ * @param \Illuminate\Database\ConnectionInterface $connection
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @return \Illuminate\Support\Collection
+ */
+ protected function views(ConnectionInterface $connection, Builder $schema)
+ {
+ return (new Collection($schema->getViews()))
+ ->map(fn ($view) => [
+ 'view' => $view['name'],
+ 'schema' => $view['schema'],
+ 'rows' => $connection->withoutTablePrefix(fn ($connection) => $connection->table($view['schema_qualified_name'])->count()),
+ ]);
+ }
+
+ /**
+ * Get information regarding the user-defined types within the database.
+ *
+ * @param \Illuminate\Database\ConnectionInterface $connection
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @return \Illuminate\Support\Collection
+ */
+ protected function types(ConnectionInterface $connection, Builder $schema)
+ {
+ return (new Collection($schema->getTypes()))
+ ->map(fn ($type) => [
+ 'name' => $type['name'],
+ 'schema' => $type['schema'],
+ 'type' => $type['type'],
+ 'category' => $type['category'],
+ ]);
+ }
+
+ /**
+ * Render the database information.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function display(array $data)
+ {
+ $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data);
+ }
+
+ /**
+ * Render the database information as JSON.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function displayJson(array $data)
+ {
+ $this->output->writeln(json_encode($data));
+ }
+
+ /**
+ * Render the database information formatted for the CLI.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function displayForCli(array $data)
+ {
+ $platform = $data['platform'];
+ $tables = $data['tables'];
+ $views = $data['views'] ?? null;
+ $types = $data['types'] ?? null;
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail(''.$platform['name'].'>', $platform['version']);
+ $this->components->twoColumnDetail('Connection', $platform['connection']);
+ $this->components->twoColumnDetail('Database', Arr::get($platform['config'], 'database'));
+ $this->components->twoColumnDetail('Host', Arr::get($platform['config'], 'host'));
+ $this->components->twoColumnDetail('Port', Arr::get($platform['config'], 'port'));
+ $this->components->twoColumnDetail('Username', Arr::get($platform['config'], 'username'));
+ $this->components->twoColumnDetail('URL', Arr::get($platform['config'], 'url'));
+ $this->components->twoColumnDetail('Open Connections', $platform['open_connections']);
+ $this->components->twoColumnDetail('Tables', $tables->count());
+
+ if ($tableSizeSum = $tables->sum('size')) {
+ $this->components->twoColumnDetail('Total Size', Number::fileSize($tableSizeSum, 2));
+ }
+
+ $this->newLine();
+
+ if ($tables->isNotEmpty()) {
+ $hasSchema = ! is_null($tables->first()['schema']);
+
+ $this->components->twoColumnDetail(
+ ($hasSchema ? 'Schema> /> ' : '').'Table>',
+ 'Size'.($this->option('counts') ? ' /> Rows>' : '')
+ );
+
+ $tables->each(function ($table) {
+ $tableSize = is_null($table['size']) ? null : Number::fileSize($table['size'], 2);
+
+ $this->components->twoColumnDetail(
+ ($table['schema'] ? $table['schema'].' /> ' : '').$table['table'].($this->output->isVerbose() ? ' '.$table['engine'].'>' : null),
+ ($tableSize ?? '—').($this->option('counts') ? ' /> '.Number::format($table['rows']).'>' : '')
+ );
+
+ if ($this->output->isVerbose()) {
+ if ($table['comment']) {
+ $this->components->bulletList([
+ $table['comment'],
+ ]);
+ }
+ }
+ });
+
+ $this->newLine();
+ }
+
+ if ($views && $views->isNotEmpty()) {
+ $hasSchema = ! is_null($views->first()['schema']);
+
+ $this->components->twoColumnDetail(
+ ($hasSchema ? 'Schema> /> ' : '').'View>',
+ 'Rows>'
+ );
+
+ $views->each(fn ($view) => $this->components->twoColumnDetail(
+ ($view['schema'] ? $view['schema'].' /> ' : '').$view['view'],
+ Number::format($view['rows'])
+ ));
+
+ $this->newLine();
+ }
+
+ if ($types && $types->isNotEmpty()) {
+ $hasSchema = ! is_null($types->first()['schema']);
+
+ $this->components->twoColumnDetail(
+ ($hasSchema ? 'Schema> /> ' : '').'Type>',
+ 'Type> /> Category>'
+ );
+
+ $types->each(fn ($type) => $this->components->twoColumnDetail(
+ ($type['schema'] ? $type['schema'].' /> ' : '').$type['name'],
+ $type['type'].' /> '.$type['category']
+ ));
+
+ $this->newLine();
+ }
+ }
+}
diff --git a/Console/ShowModelCommand.php b/Console/ShowModelCommand.php
new file mode 100644
index 0000000000..463b0b73e6
--- /dev/null
+++ b/Console/ShowModelCommand.php
@@ -0,0 +1,191 @@
+inspect(
+ $this->argument('model'),
+ $this->option('database')
+ );
+ } catch (BindingResolutionException $e) {
+ $this->components->error($e->getMessage());
+
+ return 1;
+ }
+
+ $this->display($info);
+
+ return 0;
+ }
+
+ /**
+ * Render the model information.
+ *
+ * @return void
+ */
+ protected function display(ModelInfo $modelData)
+ {
+ $this->option('json')
+ ? $this->displayJson($modelData)
+ : $this->displayCli($modelData);
+ }
+
+ /**
+ * Render the model information as JSON.
+ *
+ * @return void
+ */
+ protected function displayJson(ModelInfo $modelData)
+ {
+ $this->output->writeln(
+ (new Collection($modelData))->toJson()
+ );
+ }
+
+ /**
+ * Render the model information for the CLI.
+ *
+ * @return void
+ */
+ protected function displayCli(ModelInfo $modelData)
+ {
+ $this->newLine();
+
+ $this->components->twoColumnDetail(''.$modelData->class.'>');
+ $this->components->twoColumnDetail('Database', $modelData->database);
+ $this->components->twoColumnDetail('Table', $modelData->table);
+
+ if ($policy = $modelData->policy ?? false) {
+ $this->components->twoColumnDetail('Policy', $policy);
+ }
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail(
+ 'Attributes>',
+ 'type /> cast>',
+ );
+
+ foreach ($modelData->attributes as $attribute) {
+ $first = trim(sprintf(
+ '%s %s',
+ $attribute['name'],
+ (new Collection(['increments', 'unique', 'nullable', 'fillable', 'hidden', 'appended']))
+ ->filter(fn ($property) => $attribute[$property])
+ ->map(fn ($property) => sprintf('%s>', $property))
+ ->implode(',> ')
+ ));
+
+ $second = (new Collection([
+ $attribute['type'],
+ $attribute['cast'] ? ''.$attribute['cast'].'>' : null,
+ ]))->filter()->implode(' /> ');
+
+ $this->components->twoColumnDetail($first, $second);
+
+ if ($attribute['default'] !== null) {
+ $this->components->bulletList(
+ [sprintf('default: %s', $attribute['default'])],
+ OutputInterface::VERBOSITY_VERBOSE
+ );
+ }
+ }
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail('Relations>');
+
+ foreach ($modelData->relations as $relation) {
+ $this->components->twoColumnDetail(
+ sprintf('%s %s>', $relation['name'], $relation['type']),
+ $relation['related']
+ );
+ }
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail('Events>');
+
+ if ($modelData->events->count()) {
+ foreach ($modelData->events as $event) {
+ $this->components->twoColumnDetail(
+ sprintf('%s', $event['event']),
+ sprintf('%s', $event['class']),
+ );
+ }
+ }
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail('Observers>');
+
+ if ($modelData->observers->count()) {
+ foreach ($modelData->observers as $observer) {
+ $this->components->twoColumnDetail(
+ sprintf('%s', $observer['event']),
+ implode(', ', $observer['observer'])
+ );
+ }
+ }
+
+ $this->newLine();
+ }
+
+ /**
+ * Prompt for missing input arguments using the returned questions.
+ *
+ * @return array
+ */
+ protected function promptForMissingArgumentsUsing(): array
+ {
+ return [
+ 'model' => fn (): string => suggest('Which model would you like to show?', $this->findAvailableModels()),
+ ];
+ }
+}
diff --git a/Console/TableCommand.php b/Console/TableCommand.php
new file mode 100644
index 0000000000..ecfa00a9e1
--- /dev/null
+++ b/Console/TableCommand.php
@@ -0,0 +1,283 @@
+connection($this->input->getOption('database'));
+ $tables = (new Collection($connection->getSchemaBuilder()->getTables()))
+ ->keyBy('schema_qualified_name')->all();
+
+ $tableNames = (new Collection($tables))->keys();
+
+ $tableName = $this->argument('table') ?: search(
+ 'Which table would you like to inspect?',
+ fn (string $query) => $tableNames
+ ->filter(fn ($table) => str_contains(strtolower($table), strtolower($query)))
+ ->values()
+ ->all()
+ );
+
+ $table = $tables[$tableName] ?? (new Collection($tables))->when(
+ Arr::wrap($connection->getSchemaBuilder()->getCurrentSchemaListing()
+ ?? $connection->getSchemaBuilder()->getCurrentSchemaName()),
+ fn (Collection $collection, array $currentSchemas) => $collection->sortBy(
+ function (array $table) use ($currentSchemas) {
+ $index = array_search($table['schema'], $currentSchemas);
+
+ return $index === false ? PHP_INT_MAX : $index;
+ }
+ )
+ )->firstWhere('name', $tableName);
+
+ if (! $table) {
+ $this->components->warn("Table [{$tableName}] doesn't exist.");
+
+ return 1;
+ }
+
+ [$columns, $indexes, $foreignKeys] = $connection->withoutTablePrefix(function ($connection) use ($table) {
+ $schema = $connection->getSchemaBuilder();
+ $tableName = $table['schema_qualified_name'];
+
+ return [
+ $this->columns($schema, $tableName),
+ $this->indexes($schema, $tableName),
+ $this->foreignKeys($schema, $tableName),
+ ];
+ });
+
+ $data = [
+ 'table' => [
+ 'schema' => $table['schema'],
+ 'name' => $table['name'],
+ 'schema_qualified_name' => $table['schema_qualified_name'],
+ 'columns' => count($columns),
+ 'size' => $table['size'],
+ 'comment' => $table['comment'],
+ 'collation' => $table['collation'],
+ 'engine' => $table['engine'],
+ ],
+ 'columns' => $columns,
+ 'indexes' => $indexes,
+ 'foreign_keys' => $foreignKeys,
+ ];
+
+ $this->display($data);
+
+ return 0;
+ }
+
+ /**
+ * Get the information regarding the table's columns.
+ *
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @param string $table
+ * @return \Illuminate\Support\Collection
+ */
+ protected function columns(Builder $schema, string $table)
+ {
+ return (new Collection($schema->getColumns($table)))->map(fn ($column) => [
+ 'column' => $column['name'],
+ 'attributes' => $this->getAttributesForColumn($column),
+ 'default' => $column['default'],
+ 'type' => $column['type'],
+ ]);
+ }
+
+ /**
+ * Get the attributes for a table column.
+ *
+ * @param array $column
+ * @return \Illuminate\Support\Collection
+ */
+ protected function getAttributesForColumn($column)
+ {
+ return (new Collection([
+ $column['type_name'],
+ $column['generation'] ? $column['generation']['type'] : null,
+ $column['auto_increment'] ? 'autoincrement' : null,
+ $column['nullable'] ? 'nullable' : null,
+ $column['collation'],
+ ]))->filter();
+ }
+
+ /**
+ * Get the information regarding the table's indexes.
+ *
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @param string $table
+ * @return \Illuminate\Support\Collection
+ */
+ protected function indexes(Builder $schema, string $table)
+ {
+ return (new Collection($schema->getIndexes($table)))->map(fn ($index) => [
+ 'name' => $index['name'],
+ 'columns' => new Collection($index['columns']),
+ 'attributes' => $this->getAttributesForIndex($index),
+ ]);
+ }
+
+ /**
+ * Get the attributes for a table index.
+ *
+ * @param array $index
+ * @return \Illuminate\Support\Collection
+ */
+ protected function getAttributesForIndex($index)
+ {
+ return (new Collection([
+ $index['type'],
+ count($index['columns']) > 1 ? 'compound' : null,
+ $index['unique'] && ! $index['primary'] ? 'unique' : null,
+ $index['primary'] ? 'primary' : null,
+ ]))->filter();
+ }
+
+ /**
+ * Get the information regarding the table's foreign keys.
+ *
+ * @param \Illuminate\Database\Schema\Builder $schema
+ * @param string $table
+ * @return \Illuminate\Support\Collection
+ */
+ protected function foreignKeys(Builder $schema, string $table)
+ {
+ return (new Collection($schema->getForeignKeys($table)))->map(fn ($foreignKey) => [
+ 'name' => $foreignKey['name'],
+ 'columns' => new Collection($foreignKey['columns']),
+ 'foreign_schema' => $foreignKey['foreign_schema'],
+ 'foreign_table' => $foreignKey['foreign_table'],
+ 'foreign_columns' => new Collection($foreignKey['foreign_columns']),
+ 'on_update' => $foreignKey['on_update'],
+ 'on_delete' => $foreignKey['on_delete'],
+ ]);
+ }
+
+ /**
+ * Render the table information.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function display(array $data)
+ {
+ $this->option('json') ? $this->displayJson($data) : $this->displayForCli($data);
+ }
+
+ /**
+ * Render the table information as JSON.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function displayJson(array $data)
+ {
+ $this->output->writeln(json_encode($data));
+ }
+
+ /**
+ * Render the table information formatted for the CLI.
+ *
+ * @param array $data
+ * @return void
+ */
+ protected function displayForCli(array $data)
+ {
+ [$table, $columns, $indexes, $foreignKeys] = [
+ $data['table'], $data['columns'], $data['indexes'], $data['foreign_keys'],
+ ];
+
+ $this->newLine();
+
+ $this->components->twoColumnDetail(''.$table['schema_qualified_name'].'>', $table['comment'] ? ''.$table['comment'].'>' : null);
+ $this->components->twoColumnDetail('Columns', $table['columns']);
+
+ if (! is_null($table['size'])) {
+ $this->components->twoColumnDetail('Size', Number::fileSize($table['size'], 2));
+ }
+
+ if ($table['engine']) {
+ $this->components->twoColumnDetail('Engine', $table['engine']);
+ }
+
+ if ($table['collation']) {
+ $this->components->twoColumnDetail('Collation', $table['collation']);
+ }
+
+ $this->newLine();
+
+ if ($columns->isNotEmpty()) {
+ $this->components->twoColumnDetail('Column>', 'Type');
+
+ $columns->each(function ($column) {
+ $this->components->twoColumnDetail(
+ $column['column'].' '.$column['attributes']->implode(', ').'>',
+ (! is_null($column['default']) ? ''.$column['default'].'> ' : '').$column['type']
+ );
+ });
+
+ $this->newLine();
+ }
+
+ if ($indexes->isNotEmpty()) {
+ $this->components->twoColumnDetail('Index>');
+
+ $indexes->each(function ($index) {
+ $this->components->twoColumnDetail(
+ $index['name'].' '.$index['columns']->implode(', ').'>',
+ $index['attributes']->implode(', ')
+ );
+ });
+
+ $this->newLine();
+ }
+
+ if ($foreignKeys->isNotEmpty()) {
+ $this->components->twoColumnDetail('Foreign Key>', 'On Update / On Delete');
+
+ $foreignKeys->each(function ($foreignKey) {
+ $this->components->twoColumnDetail(
+ $foreignKey['name'].' '.$foreignKey['columns']->implode(', ').' references '.$foreignKey['foreign_columns']->implode(', ').' on '.$foreignKey['foreign_table'].'>',
+ $foreignKey['on_update'].' / '.$foreignKey['on_delete'],
+ );
+ });
+
+ $this->newLine();
+ }
+ }
+}
diff --git a/Console/WipeCommand.php b/Console/WipeCommand.php
new file mode 100644
index 0000000000..d638db41d0
--- /dev/null
+++ b/Console/WipeCommand.php
@@ -0,0 +1,129 @@
+isProhibited() ||
+ ! $this->confirmToProceed()) {
+ return Command::FAILURE;
+ }
+
+ $database = $this->input->getOption('database');
+
+ if ($this->option('drop-views')) {
+ $this->dropAllViews($database);
+
+ $this->components->info('Dropped all views successfully.');
+ }
+
+ $this->dropAllTables($database);
+
+ $this->components->info('Dropped all tables successfully.');
+
+ if ($this->option('drop-types')) {
+ $this->dropAllTypes($database);
+
+ $this->components->info('Dropped all types successfully.');
+ }
+
+ $this->flushDatabaseConnection($database);
+
+ return 0;
+ }
+
+ /**
+ * Drop all of the database tables.
+ *
+ * @param string $database
+ * @return void
+ */
+ protected function dropAllTables($database)
+ {
+ $this->laravel['db']->connection($database)
+ ->getSchemaBuilder()
+ ->dropAllTables();
+ }
+
+ /**
+ * Drop all of the database views.
+ *
+ * @param string $database
+ * @return void
+ */
+ protected function dropAllViews($database)
+ {
+ $this->laravel['db']->connection($database)
+ ->getSchemaBuilder()
+ ->dropAllViews();
+ }
+
+ /**
+ * Drop all of the database types.
+ *
+ * @param string $database
+ * @return void
+ */
+ protected function dropAllTypes($database)
+ {
+ $this->laravel['db']->connection($database)
+ ->getSchemaBuilder()
+ ->dropAllTypes();
+ }
+
+ /**
+ * Flush the given database connection.
+ *
+ * @param string $database
+ * @return void
+ */
+ protected function flushDatabaseConnection($database)
+ {
+ $this->laravel['db']->connection($database)->disconnect();
+ }
+
+ /**
+ * Get the console command options.
+ *
+ * @return array
+ */
+ protected function getOptions()
+ {
+ return [
+ ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection to use'],
+ ['drop-views', null, InputOption::VALUE_NONE, 'Drop all tables and views'],
+ ['drop-types', null, InputOption::VALUE_NONE, 'Drop all tables and types (Postgres only)'],
+ ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'],
+ ];
+ }
+}
diff --git a/DatabaseManager.php b/DatabaseManager.php
index f28493e5fe..2d8edbc533 100755
--- a/DatabaseManager.php
+++ b/DatabaseManager.php
@@ -2,17 +2,28 @@
namespace Illuminate\Database;
-use PDO;
+use Illuminate\Database\Connectors\ConnectionFactory;
+use Illuminate\Database\Events\ConnectionEstablished;
use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
+use Illuminate\Support\ConfigurationUrlParser;
use Illuminate\Support\Str;
+use Illuminate\Support\Traits\Macroable;
use InvalidArgumentException;
-use Illuminate\Database\Connectors\ConnectionFactory;
+use PDO;
+use RuntimeException;
+
+use function Illuminate\Support\enum_value;
/**
* @mixin \Illuminate\Database\Connection
*/
class DatabaseManager implements ConnectionResolverInterface
{
+ use Macroable {
+ __call as macroCall;
+ }
+
/**
* The application instance.
*
@@ -30,41 +41,58 @@ class DatabaseManager implements ConnectionResolverInterface
/**
* The active connection instances.
*
- * @var array
+ * @var array
*/
protected $connections = [];
+ /**
+ * The dynamically configured (DB::build) connection configurations.
+ *
+ * @var array
+ */
+ protected $dynamicConnectionConfigurations = [];
+
/**
* The custom connection resolvers.
*
- * @var array
+ * @var array
*/
protected $extensions = [];
+ /**
+ * The callback to be executed to reconnect to a database.
+ *
+ * @var callable
+ */
+ protected $reconnector;
+
/**
* Create a new database manager instance.
*
* @param \Illuminate\Contracts\Foundation\Application $app
* @param \Illuminate\Database\Connectors\ConnectionFactory $factory
- * @return void
*/
public function __construct($app, ConnectionFactory $factory)
{
$this->app = $app;
$this->factory = $factory;
+
+ $this->reconnector = function ($connection) {
+ $connection->setPdo(
+ $this->reconnect($connection->getNameWithReadWriteType())->getRawPdo()
+ );
+ };
}
/**
* Get a database connection instance.
*
- * @param string $name
+ * @param \UnitEnum|string|null $name
* @return \Illuminate\Database\Connection
*/
public function connection($name = null)
{
- [$database, $type] = $this->parseConnectionName($name);
-
- $name = $name ?: $database;
+ [$database, $type] = $this->parseConnectionName($name = enum_value($name) ?: $this->getDefaultConnection());
// If we haven't created this connection, we'll create it based on the config
// provided in the application. Once we've created the connections we will
@@ -73,11 +101,70 @@ public function connection($name = null)
$this->connections[$name] = $this->configure(
$this->makeConnection($database), $type
);
+
+ $this->dispatchConnectionEstablishedEvent($this->connections[$name]);
}
return $this->connections[$name];
}
+ /**
+ * Build a database connection instance from the given configuration.
+ *
+ * @param array $config
+ * @return \Illuminate\Database\ConnectionInterface
+ */
+ public function build(array $config)
+ {
+ $config['name'] ??= static::calculateDynamicConnectionName($config);
+
+ $this->dynamicConnectionConfigurations[$config['name']] = $config;
+
+ return $this->connectUsing($config['name'], $config, true);
+ }
+
+ /**
+ * Calculate the dynamic connection name for an on-demand connection based on its configuration.
+ *
+ * @param array $config
+ * @return string
+ */
+ public static function calculateDynamicConnectionName(array $config)
+ {
+ return 'dynamic_'.md5((new Collection($config))->map(function ($value, $key) {
+ return $key.(is_string($value) || is_int($value) ? $value : '');
+ })->implode(''));
+ }
+
+ /**
+ * Get a database connection instance from the given configuration.
+ *
+ * @param \UnitEnum|string $name
+ * @param array $config
+ * @param bool $force
+ * @return \Illuminate\Database\ConnectionInterface
+ *
+ * @throws \RuntimeException
+ */
+ public function connectUsing(string $name, array $config, bool $force = false)
+ {
+ if ($force) {
+ $this->purge($name = enum_value($name));
+ }
+
+ if (isset($this->connections[$name])) {
+ throw new RuntimeException("Cannot establish connection [$name] because another connection with that name already exists.");
+ }
+
+ $connection = $this->configure(
+ $this->factory->make($config, $name), null
+ );
+
+ $this->dispatchConnectionEstablishedEvent($connection);
+
+ return tap($connection, fn ($connection) => $this->connections[$name] = $connection);
+ }
+
/**
* Parse the connection into an array of the name and read / write type.
*
@@ -86,10 +173,9 @@ public function connection($name = null)
*/
protected function parseConnectionName($name)
{
- $name = $name ?: $this->getDefaultConnection();
-
return Str::endsWith($name, ['::read', '::write'])
- ? explode('::', $name, 2) : [$name, null];
+ ? explode('::', $name, 2)
+ : [$name, null];
}
/**
@@ -129,18 +215,16 @@ protected function makeConnection($name)
*/
protected function configuration($name)
{
- $name = $name ?: $this->getDefaultConnection();
-
- // To get the database connection configuration, we will just pull each of the
- // connection configurations and get the configurations for the given name.
- // If the configuration doesn't exist, we'll throw an exception and bail.
$connections = $this->app['config']['database.connections'];
- if (is_null($config = Arr::get($connections, $name))) {
- throw new InvalidArgumentException("Database [{$name}] not configured.");
+ $config = $this->dynamicConnectionConfigurations[$name] ?? Arr::get($connections, $name);
+
+ if (is_null($config)) {
+ throw new InvalidArgumentException("Database connection [{$name}] not configured.");
}
- return $config;
+ return (new ConfigurationUrlParser)
+ ->parseConfiguration($config);
}
/**
@@ -152,7 +236,7 @@ protected function configuration($name)
*/
protected function configure(Connection $connection, $type)
{
- $connection = $this->setPdoForType($connection, $type);
+ $connection = $this->setPdoForType($connection, $type)->setReadWriteType($type);
// First we'll set the fetch mode and a few other dependencies of the database
// connection. This method basically just configures and prepares it to get
@@ -161,21 +245,40 @@ protected function configure(Connection $connection, $type)
$connection->setEventDispatcher($this->app['events']);
}
+ if ($this->app->bound('db.transactions')) {
+ $connection->setTransactionManager($this->app['db.transactions']);
+ }
+
// Here we'll set a reconnector callback. This reconnector can be any callable
// so we will set a Closure to reconnect from this manager with the name of
// the connection, which will allow us to reconnect from the connections.
- $connection->setReconnector(function ($connection) {
- $this->reconnect($connection->getName());
- });
+ $connection->setReconnector($this->reconnector);
return $connection;
}
+ /**
+ * Dispatch the ConnectionEstablished event if the event dispatcher is available.
+ *
+ * @param \Illuminate\Database\Connection $connection
+ * @return void
+ */
+ protected function dispatchConnectionEstablishedEvent(Connection $connection)
+ {
+ if (! $this->app->bound('events')) {
+ return;
+ }
+
+ $this->app['events']->dispatch(
+ new ConnectionEstablished($connection)
+ );
+ }
+
/**
* Prepare the read / write mode for database connection instance.
*
* @param \Illuminate\Database\Connection $connection
- * @param string $type
+ * @param string|null $type
* @return \Illuminate\Database\Connection
*/
protected function setPdoForType(Connection $connection, $type = null)
@@ -192,14 +295,12 @@ protected function setPdoForType(Connection $connection, $type = null)
/**
* Disconnect from the given database and remove from local cache.
*
- * @param string $name
+ * @param \UnitEnum|string|null $name
* @return void
*/
public function purge($name = null)
{
- $name = $name ?: $this->getDefaultConnection();
-
- $this->disconnect($name);
+ $this->disconnect($name = enum_value($name) ?: $this->getDefaultConnection());
unset($this->connections[$name]);
}
@@ -207,12 +308,12 @@ public function purge($name = null)
/**
* Disconnect from the given database.
*
- * @param string $name
+ * @param \UnitEnum|string|null $name
* @return void
*/
public function disconnect($name = null)
{
- if (isset($this->connections[$name = $name ?: $this->getDefaultConnection()])) {
+ if (isset($this->connections[$name = enum_value($name) ?: $this->getDefaultConnection()])) {
$this->connections[$name]->disconnect();
}
}
@@ -220,18 +321,40 @@ public function disconnect($name = null)
/**
* Reconnect to the given database.
*
- * @param string $name
+ * @param \UnitEnum|string|null $name
* @return \Illuminate\Database\Connection
*/
public function reconnect($name = null)
{
- $this->disconnect($name = $name ?: $this->getDefaultConnection());
+ $this->disconnect($name = enum_value($name) ?: $this->getDefaultConnection());
if (! isset($this->connections[$name])) {
return $this->connection($name);
}
- return $this->refreshPdoConnections($name);
+ return tap($this->refreshPdoConnections($name), function ($connection) {
+ $this->dispatchConnectionEstablishedEvent($connection);
+ });
+ }
+
+ /**
+ * Set the default database connection for the callback execution.
+ *
+ * @param \UnitEnum|string $name
+ * @param callable $callback
+ * @return mixed
+ */
+ public function usingConnection($name, callable $callback)
+ {
+ $previousName = $this->getDefaultConnection();
+
+ $this->setDefaultConnection($name = enum_value($name));
+
+ try {
+ return $callback();
+ } finally {
+ $this->setDefaultConnection($previousName);
+ }
}
/**
@@ -242,11 +365,15 @@ public function reconnect($name = null)
*/
protected function refreshPdoConnections($name)
{
- $fresh = $this->makeConnection($name);
+ [$database, $type] = $this->parseConnectionName($name);
+
+ $fresh = $this->configure(
+ $this->makeConnection($database), $type
+ );
return $this->connections[$name]
- ->setPdo($fresh->getPdo())
- ->setReadPdo($fresh->getReadPdo());
+ ->setPdo($fresh->getRawPdo())
+ ->setReadPdo($fresh->getRawReadPdo());
}
/**
@@ -271,19 +398,19 @@ public function setDefaultConnection($name)
}
/**
- * Get all of the support drivers.
+ * Get all of the supported drivers.
*
- * @return array
+ * @return string[]
*/
public function supportedDrivers()
{
- return ['mysql', 'pgsql', 'sqlite', 'sqlsrv'];
+ return ['mysql', 'mariadb', 'pgsql', 'sqlite', 'sqlsrv'];
}
/**
* Get all of the drivers that are actually available.
*
- * @return array
+ * @return string[]
*/
public function availableDrivers()
{
@@ -296,7 +423,7 @@ public function availableDrivers()
/**
* Register an extension connection resolver.
*
- * @param string $name
+ * @param string $name
* @param callable $resolver
* @return void
*/
@@ -305,25 +432,64 @@ public function extend($name, callable $resolver)
$this->extensions[$name] = $resolver;
}
+ /**
+ * Remove an extension connection resolver.
+ *
+ * @param string $name
+ * @return void
+ */
+ public function forgetExtension($name)
+ {
+ unset($this->extensions[$name]);
+ }
+
/**
* Return all of the created connections.
*
- * @return array
+ * @return array
*/
public function getConnections()
{
return $this->connections;
}
+ /**
+ * Set the database reconnector callback.
+ *
+ * @param callable $reconnector
+ * @return void
+ */
+ public function setReconnector(callable $reconnector)
+ {
+ $this->reconnector = $reconnector;
+ }
+
+ /**
+ * Set the application instance used by the manager.
+ *
+ * @param \Illuminate\Contracts\Foundation\Application $app
+ * @return $this
+ */
+ public function setApplication($app)
+ {
+ $this->app = $app;
+
+ return $this;
+ }
+
/**
* Dynamically pass methods to the default connection.
*
* @param string $method
- * @param array $parameters
+ * @param array $parameters
* @return mixed
*/
public function __call($method, $parameters)
{
+ if (static::hasMacro($method)) {
+ return $this->macroCall($method, $parameters);
+ }
+
return $this->connection()->$method(...$parameters);
}
}
diff --git a/DatabaseServiceProvider.php b/DatabaseServiceProvider.php
index 66dd4b9a39..8a6115ba1e 100755
--- a/DatabaseServiceProvider.php
+++ b/DatabaseServiceProvider.php
@@ -4,15 +4,23 @@
use Faker\Factory as FakerFactory;
use Faker\Generator as FakerGenerator;
-use Illuminate\Database\Eloquent\Model;
-use Illuminate\Support\ServiceProvider;
+use Illuminate\Contracts\Database\ConcurrencyErrorDetector as ConcurrencyErrorDetectorContract;
+use Illuminate\Contracts\Database\LostConnectionDetector as LostConnectionDetectorContract;
use Illuminate\Contracts\Queue\EntityResolver;
use Illuminate\Database\Connectors\ConnectionFactory;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\QueueEntityResolver;
-use Illuminate\Database\Eloquent\Factory as EloquentFactory;
+use Illuminate\Support\ServiceProvider;
class DatabaseServiceProvider extends ServiceProvider
{
+ /**
+ * The array of resolved Faker instances.
+ *
+ * @var array
+ */
+ protected static $fakers = [];
+
/**
* Bootstrap the application events.
*
@@ -35,9 +43,7 @@ public function register()
Model::clearBootedModels();
$this->registerConnectionServices();
-
- $this->registerEloquentFactory();
-
+ $this->registerFakerGenerator();
$this->registerQueueableEntityResolver();
}
@@ -69,23 +75,41 @@ protected function registerConnectionServices()
$this->app->bind('db.schema', function ($app) {
return $app['db']->connection()->getSchemaBuilder();
});
+
+ $this->app->singleton('db.transactions', function () {
+ return new DatabaseTransactionsManager;
+ });
+
+ $this->app->singleton(ConcurrencyErrorDetectorContract::class, function () {
+ return new ConcurrencyErrorDetector;
+ });
+
+ $this->app->singleton(LostConnectionDetectorContract::class, function () {
+ return new LostConnectionDetector;
+ });
}
/**
- * Register the Eloquent factory instance in the container.
+ * Register the Faker Generator instance in the container.
*
* @return void
*/
- protected function registerEloquentFactory()
+ protected function registerFakerGenerator()
{
- $this->app->singleton(FakerGenerator::class, function ($app) {
- return FakerFactory::create($app['config']->get('app.faker_locale', 'en_US'));
- });
+ if (! class_exists(FakerGenerator::class)) {
+ return;
+ }
+
+ $this->app->singleton(FakerGenerator::class, function ($app, $parameters) {
+ $locale = $parameters['locale'] ?? $app['config']->get('app.faker_locale', 'en_US');
+
+ if (! isset(static::$fakers[$locale])) {
+ static::$fakers[$locale] = FakerFactory::create($locale);
+ }
+
+ static::$fakers[$locale]->unique(true);
- $this->app->singleton(EloquentFactory::class, function ($app) {
- return EloquentFactory::construct(
- $app->make(FakerGenerator::class), $this->app->databasePath('factories')
- );
+ return static::$fakers[$locale];
});
}
diff --git a/DatabaseTransactionRecord.php b/DatabaseTransactionRecord.php
new file mode 100755
index 0000000000..08fd471323
--- /dev/null
+++ b/DatabaseTransactionRecord.php
@@ -0,0 +1,121 @@
+connection = $connection;
+ $this->level = $level;
+ $this->parent = $parent;
+ }
+
+ /**
+ * Register a callback to be executed after committing.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ public function addCallback($callback)
+ {
+ $this->callbacks[] = $callback;
+ }
+
+ /**
+ * Register a callback to be executed after rollback.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ public function addCallbackForRollback($callback)
+ {
+ $this->callbacksForRollback[] = $callback;
+ }
+
+ /**
+ * Execute all of the callbacks.
+ *
+ * @return void
+ */
+ public function executeCallbacks()
+ {
+ foreach ($this->callbacks as $callback) {
+ $callback();
+ }
+ }
+
+ /**
+ * Execute all of the callbacks for rollback.
+ *
+ * @return void
+ */
+ public function executeCallbacksForRollback()
+ {
+ foreach ($this->callbacksForRollback as $callback) {
+ $callback();
+ }
+ }
+
+ /**
+ * Get all of the callbacks.
+ *
+ * @return array
+ */
+ public function getCallbacks()
+ {
+ return $this->callbacks;
+ }
+
+ /**
+ * Get all of the callbacks for rollback.
+ *
+ * @return array
+ */
+ public function getCallbacksForRollback()
+ {
+ return $this->callbacksForRollback;
+ }
+}
diff --git a/DatabaseTransactionsManager.php b/DatabaseTransactionsManager.php
new file mode 100755
index 0000000000..9713c66d82
--- /dev/null
+++ b/DatabaseTransactionsManager.php
@@ -0,0 +1,267 @@
+
+ */
+ protected $committedTransactions;
+
+ /**
+ * All of the pending transactions.
+ *
+ * @var \Illuminate\Support\Collection
+ */
+ protected $pendingTransactions;
+
+ /**
+ * The current transaction.
+ *
+ * @var array
+ */
+ protected $currentTransaction = [];
+
+ /**
+ * Create a new database transactions manager instance.
+ */
+ public function __construct()
+ {
+ $this->committedTransactions = new Collection;
+ $this->pendingTransactions = new Collection;
+ }
+
+ /**
+ * Start a new database transaction.
+ *
+ * @param string $connection
+ * @param int $level
+ * @return void
+ */
+ public function begin($connection, $level)
+ {
+ $this->pendingTransactions->push(
+ $newTransaction = new DatabaseTransactionRecord(
+ $connection,
+ $level,
+ $this->currentTransaction[$connection] ?? null
+ )
+ );
+
+ $this->currentTransaction[$connection] = $newTransaction;
+ }
+
+ /**
+ * Commit the root database transaction and execute callbacks.
+ *
+ * @param string $connection
+ * @param int $levelBeingCommitted
+ * @param int $newTransactionLevel
+ * @return array
+ */
+ public function commit($connection, $levelBeingCommitted, $newTransactionLevel)
+ {
+ $this->stageTransactions($connection, $levelBeingCommitted);
+
+ if (isset($this->currentTransaction[$connection])) {
+ $this->currentTransaction[$connection] = $this->currentTransaction[$connection]->parent;
+ }
+
+ if (! $this->afterCommitCallbacksShouldBeExecuted($newTransactionLevel) &&
+ $newTransactionLevel !== 0) {
+ return [];
+ }
+
+ // This method is only called when the root database transaction is committed so there
+ // shouldn't be any pending transactions, but going to clear them here anyways just
+ // in case. This method could be refactored to receive a level in the future too.
+ $this->pendingTransactions = $this->pendingTransactions->reject(
+ fn ($transaction) => $transaction->connection === $connection &&
+ $transaction->level >= $levelBeingCommitted
+ )->values();
+
+ [$forThisConnection, $forOtherConnections] = $this->committedTransactions->partition(
+ fn ($transaction) => $transaction->connection == $connection
+ );
+
+ $this->committedTransactions = $forOtherConnections->values();
+
+ $forThisConnection->map->executeCallbacks();
+
+ return $forThisConnection;
+ }
+
+ /**
+ * Move relevant pending transactions to a committed state.
+ *
+ * @param string $connection
+ * @param int $levelBeingCommitted
+ * @return void
+ */
+ public function stageTransactions($connection, $levelBeingCommitted)
+ {
+ $this->committedTransactions = $this->committedTransactions->merge(
+ $this->pendingTransactions->filter(
+ fn ($transaction) => $transaction->connection === $connection &&
+ $transaction->level >= $levelBeingCommitted
+ )
+ );
+
+ $this->pendingTransactions = $this->pendingTransactions->reject(
+ fn ($transaction) => $transaction->connection === $connection &&
+ $transaction->level >= $levelBeingCommitted
+ );
+ }
+
+ /**
+ * Rollback the active database transaction.
+ *
+ * @param string $connection
+ * @param int $newTransactionLevel
+ * @return void
+ */
+ public function rollback($connection, $newTransactionLevel)
+ {
+ if ($newTransactionLevel === 0) {
+ $this->removeAllTransactionsForConnection($connection);
+ } else {
+ $this->pendingTransactions = $this->pendingTransactions->reject(
+ fn ($transaction) => $transaction->connection == $connection &&
+ $transaction->level > $newTransactionLevel
+ )->values();
+
+ if ($this->currentTransaction) {
+ do {
+ $this->removeCommittedTransactionsThatAreChildrenOf($this->currentTransaction[$connection]);
+
+ $this->currentTransaction[$connection]->executeCallbacksForRollback();
+
+ $this->currentTransaction[$connection] = $this->currentTransaction[$connection]->parent;
+ } while (
+ isset($this->currentTransaction[$connection]) &&
+ $this->currentTransaction[$connection]->level > $newTransactionLevel
+ );
+ }
+ }
+ }
+
+ /**
+ * Remove all pending, completed, and current transactions for the given connection name.
+ *
+ * @param string $connection
+ * @return void
+ */
+ protected function removeAllTransactionsForConnection($connection)
+ {
+ if ($this->currentTransaction) {
+ for ($currentTransaction = $this->currentTransaction[$connection]; isset($currentTransaction); $currentTransaction = $currentTransaction->parent) {
+ $currentTransaction->executeCallbacksForRollback();
+ }
+ }
+
+ $this->currentTransaction[$connection] = null;
+
+ $this->pendingTransactions = $this->pendingTransactions->reject(
+ fn ($transaction) => $transaction->connection == $connection
+ )->values();
+
+ $this->committedTransactions = $this->committedTransactions->reject(
+ fn ($transaction) => $transaction->connection == $connection
+ )->values();
+ }
+
+ /**
+ * Remove all transactions that are children of the given transaction.
+ *
+ * @param \Illuminate\Database\DatabaseTransactionRecord $transaction
+ * @return void
+ */
+ protected function removeCommittedTransactionsThatAreChildrenOf(DatabaseTransactionRecord $transaction)
+ {
+ [$removedTransactions, $this->committedTransactions] = $this->committedTransactions->partition(
+ fn ($committed) => $committed->connection == $transaction->connection &&
+ $committed->parent === $transaction
+ );
+
+ // There may be multiple deeply nested transactions that have already committed that we
+ // also need to remove. We will recurse down the children of all removed transaction
+ // instances until there are no more deeply nested child transactions for removal.
+ $removedTransactions->each(
+ fn ($transaction) => $this->removeCommittedTransactionsThatAreChildrenOf($transaction)
+ );
+ }
+
+ /**
+ * Register a transaction callback.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ public function addCallback($callback)
+ {
+ if ($current = $this->callbackApplicableTransactions()->last()) {
+ return $current->addCallback($callback);
+ }
+
+ $callback();
+ }
+
+ /**
+ * Register a callback for transaction rollback.
+ *
+ * @param callable $callback
+ * @return void
+ */
+ public function addCallbackForRollback($callback)
+ {
+ if ($current = $this->callbackApplicableTransactions()->last()) {
+ return $current->addCallbackForRollback($callback);
+ }
+ }
+
+ /**
+ * Get the transactions that are applicable to callbacks.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function callbackApplicableTransactions()
+ {
+ return $this->pendingTransactions;
+ }
+
+ /**
+ * Determine if after commit callbacks should be executed for the given transaction level.
+ *
+ * @param int $level
+ * @return bool
+ */
+ public function afterCommitCallbacksShouldBeExecuted($level)
+ {
+ return $level === 0;
+ }
+
+ /**
+ * Get all of the pending transactions.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function getPendingTransactions()
+ {
+ return $this->pendingTransactions;
+ }
+
+ /**
+ * Get all of the committed transactions.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function getCommittedTransactions()
+ {
+ return $this->committedTransactions;
+ }
+}
diff --git a/DeadlockException.php b/DeadlockException.php
new file mode 100644
index 0000000000..375a39bc96
--- /dev/null
+++ b/DeadlockException.php
@@ -0,0 +1,10 @@
+bound(ConcurrencyErrorDetectorContract::class)
+ ? $container[ConcurrencyErrorDetectorContract::class]
+ : new ConcurrencyErrorDetector();
+
+ return $detector->causedByConcurrencyError($e);
+ }
+}
diff --git a/DetectsLostConnections.php b/DetectsLostConnections.php
index 7a69bec0e8..ba649afe2a 100644
--- a/DetectsLostConnections.php
+++ b/DetectsLostConnections.php
@@ -2,8 +2,9 @@
namespace Illuminate\Database;
+use Illuminate\Container\Container;
+use Illuminate\Contracts\Database\LostConnectionDetector as LostConnectionDetectorContract;
use Throwable;
-use Illuminate\Support\Str;
trait DetectsLostConnections
{
@@ -15,29 +16,12 @@ trait DetectsLostConnections
*/
protected function causedByLostConnection(Throwable $e)
{
- $message = $e->getMessage();
+ $container = Container::getInstance();
- return Str::contains($message, [
- 'server has gone away',
- 'no connection to the server',
- 'Lost connection',
- 'is dead or not enabled',
- 'Error while sending',
- 'decryption failed or bad record mac',
- 'server closed the connection unexpectedly',
- 'SSL connection has been closed unexpectedly',
- 'Error writing data to the connection',
- 'Resource deadlock avoided',
- 'Transaction() on null',
- 'child connection forced to terminate due to client_idle_limit',
- 'query_wait_timeout',
- 'reset by peer',
- 'Physical connection is not usable',
- 'TCP Provider: Error code 0x68',
- 'ORA-03114',
- 'Packets out of order. Expected',
- 'Adaptive Server connection failed',
- 'Communication link failure',
- ]);
+ $detector = $container->bound(LostConnectionDetectorContract::class)
+ ? $container[LostConnectionDetectorContract::class]
+ : new LostConnectionDetector();
+
+ return $detector->causedByLostConnection($e);
}
}
diff --git a/Eloquent/Attributes/Appends.php b/Eloquent/Attributes/Appends.php
new file mode 100644
index 0000000000..6b696e3123
--- /dev/null
+++ b/Eloquent/Attributes/Appends.php
@@ -0,0 +1,18 @@
+ $columns
+ */
+ public function __construct(public array $columns)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Boot.php b/Eloquent/Attributes/Boot.php
new file mode 100644
index 0000000000..f57da7af94
--- /dev/null
+++ b/Eloquent/Attributes/Boot.php
@@ -0,0 +1,11 @@
+> $collectionClass
+ */
+ public function __construct(public string $collectionClass)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Connection.php b/Eloquent/Attributes/Connection.php
new file mode 100644
index 0000000000..d02fcc502f
--- /dev/null
+++ b/Eloquent/Attributes/Connection.php
@@ -0,0 +1,18 @@
+ $columns
+ */
+ public function __construct(public array $columns)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Guarded.php b/Eloquent/Attributes/Guarded.php
new file mode 100644
index 0000000000..d2f9c34e8d
--- /dev/null
+++ b/Eloquent/Attributes/Guarded.php
@@ -0,0 +1,18 @@
+ $columns
+ */
+ public function __construct(public array $columns)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Hidden.php b/Eloquent/Attributes/Hidden.php
new file mode 100644
index 0000000000..a7dbfbc446
--- /dev/null
+++ b/Eloquent/Attributes/Hidden.php
@@ -0,0 +1,18 @@
+ $columns
+ */
+ public function __construct(public array $columns)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Initialize.php b/Eloquent/Attributes/Initialize.php
new file mode 100644
index 0000000000..58f48769eb
--- /dev/null
+++ b/Eloquent/Attributes/Initialize.php
@@ -0,0 +1,11 @@
+ $relations
+ */
+ public function __construct(public array $relations)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Unguarded.php b/Eloquent/Attributes/Unguarded.php
new file mode 100644
index 0000000000..005105e47e
--- /dev/null
+++ b/Eloquent/Attributes/Unguarded.php
@@ -0,0 +1,11 @@
+ $builderClass
+ */
+ public function __construct(public string $builderClass)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/UseFactory.php b/Eloquent/Attributes/UseFactory.php
new file mode 100644
index 0000000000..a013102fcd
--- /dev/null
+++ b/Eloquent/Attributes/UseFactory.php
@@ -0,0 +1,18 @@
+ $factoryClass
+ */
+ public function __construct(public string $factoryClass)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/UsePolicy.php b/Eloquent/Attributes/UsePolicy.php
new file mode 100644
index 0000000000..9306598e07
--- /dev/null
+++ b/Eloquent/Attributes/UsePolicy.php
@@ -0,0 +1,18 @@
+ $class
+ */
+ public function __construct(public string $class)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/UseResource.php b/Eloquent/Attributes/UseResource.php
new file mode 100644
index 0000000000..a1cbc48f3a
--- /dev/null
+++ b/Eloquent/Attributes/UseResource.php
@@ -0,0 +1,18 @@
+ $class
+ */
+ public function __construct(public string $class)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/UseResourceCollection.php b/Eloquent/Attributes/UseResourceCollection.php
new file mode 100644
index 0000000000..c17e1f1768
--- /dev/null
+++ b/Eloquent/Attributes/UseResourceCollection.php
@@ -0,0 +1,18 @@
+ $class
+ */
+ public function __construct(public string $class)
+ {
+ }
+}
diff --git a/Eloquent/Attributes/Visible.php b/Eloquent/Attributes/Visible.php
new file mode 100644
index 0000000000..b9c87575a4
--- /dev/null
+++ b/Eloquent/Attributes/Visible.php
@@ -0,0 +1,18 @@
+ $columns
+ */
+ public function __construct(public array $columns)
+ {
+ }
+}
diff --git a/Eloquent/BroadcastableModelEventOccurred.php b/Eloquent/BroadcastableModelEventOccurred.php
new file mode 100644
index 0000000000..8bd028032e
--- /dev/null
+++ b/Eloquent/BroadcastableModelEventOccurred.php
@@ -0,0 +1,144 @@
+model = $model;
+ $this->event = $event;
+ }
+
+ /**
+ * The channels the event should broadcast on.
+ *
+ * @return array
+ */
+ public function broadcastOn()
+ {
+ $channels = empty($this->channels)
+ ? ($this->model->broadcastOn($this->event) ?: [])
+ : $this->channels;
+
+ return (new BaseCollection($channels))
+ ->map(fn ($channel) => $channel instanceof Model ? new PrivateChannel($channel) : $channel)
+ ->all();
+ }
+
+ /**
+ * The name the event should broadcast as.
+ *
+ * @return string
+ */
+ public function broadcastAs()
+ {
+ $default = class_basename($this->model).ucfirst($this->event);
+
+ return method_exists($this->model, 'broadcastAs')
+ ? ($this->model->broadcastAs($this->event) ?: $default)
+ : $default;
+ }
+
+ /**
+ * Get the data that should be sent with the broadcasted event.
+ *
+ * @return array|null
+ */
+ public function broadcastWith()
+ {
+ return method_exists($this->model, 'broadcastWith')
+ ? $this->model->broadcastWith($this->event)
+ : null;
+ }
+
+ /**
+ * Manually specify the channels the event should broadcast on.
+ *
+ * @param array $channels
+ * @return $this
+ */
+ public function onChannels(array $channels)
+ {
+ $this->channels = $channels;
+
+ return $this;
+ }
+
+ /**
+ * Determine if the event should be broadcast synchronously.
+ *
+ * @return bool
+ */
+ public function shouldBroadcastNow()
+ {
+ return $this->event === 'deleted' &&
+ ! method_exists($this->model, 'bootSoftDeletes');
+ }
+
+ /**
+ * Get the event name.
+ *
+ * @return string
+ */
+ public function event()
+ {
+ return $this->event;
+ }
+}
diff --git a/Eloquent/BroadcastsEvents.php b/Eloquent/BroadcastsEvents.php
new file mode 100644
index 0000000000..c0461ddb0a
--- /dev/null
+++ b/Eloquent/BroadcastsEvents.php
@@ -0,0 +1,197 @@
+broadcastCreated();
+ });
+
+ static::updated(function ($model) {
+ $model->broadcastUpdated();
+ });
+
+ if (method_exists(static::class, 'bootSoftDeletes')) {
+ static::softDeleted(function ($model) {
+ $model->broadcastTrashed();
+ });
+
+ static::restored(function ($model) {
+ $model->broadcastRestored();
+ });
+ }
+
+ static::deleted(function ($model) {
+ $model->broadcastDeleted();
+ });
+ }
+
+ /**
+ * Broadcast that the model was created.
+ *
+ * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast
+ */
+ public function broadcastCreated($channels = null)
+ {
+ return $this->broadcastIfBroadcastChannelsExistForEvent(
+ $this->newBroadcastableModelEvent('created'), 'created', $channels
+ );
+ }
+
+ /**
+ * Broadcast that the model was updated.
+ *
+ * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast
+ */
+ public function broadcastUpdated($channels = null)
+ {
+ return $this->broadcastIfBroadcastChannelsExistForEvent(
+ $this->newBroadcastableModelEvent('updated'), 'updated', $channels
+ );
+ }
+
+ /**
+ * Broadcast that the model was trashed.
+ *
+ * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast
+ */
+ public function broadcastTrashed($channels = null)
+ {
+ return $this->broadcastIfBroadcastChannelsExistForEvent(
+ $this->newBroadcastableModelEvent('trashed'), 'trashed', $channels
+ );
+ }
+
+ /**
+ * Broadcast that the model was restored.
+ *
+ * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast
+ */
+ public function broadcastRestored($channels = null)
+ {
+ return $this->broadcastIfBroadcastChannelsExistForEvent(
+ $this->newBroadcastableModelEvent('restored'), 'restored', $channels
+ );
+ }
+
+ /**
+ * Broadcast that the model was deleted.
+ *
+ * @param \Illuminate\Broadcasting\Channel|\Illuminate\Contracts\Broadcasting\HasBroadcastChannel|array|null $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast
+ */
+ public function broadcastDeleted($channels = null)
+ {
+ return $this->broadcastIfBroadcastChannelsExistForEvent(
+ $this->newBroadcastableModelEvent('deleted'), 'deleted', $channels
+ );
+ }
+
+ /**
+ * Broadcast the given event instance if channels are configured for the model event.
+ *
+ * @param mixed $instance
+ * @param string $event
+ * @param mixed $channels
+ * @return \Illuminate\Broadcasting\PendingBroadcast|null
+ */
+ protected function broadcastIfBroadcastChannelsExistForEvent($instance, $event, $channels = null)
+ {
+ if (! static::$isBroadcasting) {
+ return;
+ }
+
+ if (! empty($this->broadcastOn($event)) || ! empty($channels)) {
+ return broadcast($instance->onChannels(Arr::wrap($channels)));
+ }
+ }
+
+ /**
+ * Create a new broadcastable model event event.
+ *
+ * @param string $event
+ * @return mixed
+ */
+ public function newBroadcastableModelEvent($event)
+ {
+ return tap($this->newBroadcastableEvent($event), function ($event) {
+ $event->connection = property_exists($this, 'broadcastConnection')
+ ? $this->broadcastConnection
+ : $this->broadcastConnection();
+
+ $event->queue = property_exists($this, 'broadcastQueue')
+ ? $this->broadcastQueue
+ : $this->broadcastQueue();
+
+ $event->afterCommit = property_exists($this, 'broadcastAfterCommit')
+ ? $this->broadcastAfterCommit
+ : $this->broadcastAfterCommit();
+ });
+ }
+
+ /**
+ * Create a new broadcastable model event for the model.
+ *
+ * @param string $event
+ * @return \Illuminate\Database\Eloquent\BroadcastableModelEventOccurred
+ */
+ protected function newBroadcastableEvent(string $event)
+ {
+ return new BroadcastableModelEventOccurred($this, $event);
+ }
+
+ /**
+ * Get the channels that model events should broadcast on.
+ *
+ * @param string $event
+ * @return \Illuminate\Broadcasting\Channel|array
+ */
+ public function broadcastOn($event)
+ {
+ return [$this];
+ }
+
+ /**
+ * Get the queue connection that should be used to broadcast model events.
+ *
+ * @return string|null
+ */
+ public function broadcastConnection()
+ {
+ //
+ }
+
+ /**
+ * Get the queue that should be used to broadcast model events.
+ *
+ * @return string|null
+ */
+ public function broadcastQueue()
+ {
+ //
+ }
+
+ /**
+ * Determine if the model event broadcast queued job should be dispatched after all transactions are committed.
+ *
+ * @return bool
+ */
+ public function broadcastAfterCommit()
+ {
+ return false;
+ }
+}
diff --git a/Eloquent/BroadcastsEventsAfterCommit.php b/Eloquent/BroadcastsEventsAfterCommit.php
new file mode 100644
index 0000000000..806af70f87
--- /dev/null
+++ b/Eloquent/BroadcastsEventsAfterCommit.php
@@ -0,0 +1,18 @@
+ */
+ use BuildsQueries, ForwardsCalls, QueriesRelationships {
+ BuildsQueries::sole as baseSole;
+ }
/**
* The base query builder instance.
@@ -33,10 +49,17 @@ class Builder
/**
* The model being queried.
*
- * @var \Illuminate\Database\Eloquent\Model
+ * @var TModel
*/
protected $model;
+ /**
+ * The attributes that should be added to new models created by this builder.
+ *
+ * @var array
+ */
+ public $pendingAttributes = [];
+
/**
* The relationships that should be eager loaded.
*
@@ -65,14 +88,53 @@ class Builder
*/
protected $onDelete;
+ /**
+ * The properties that should be returned from query builder.
+ *
+ * @var string[]
+ */
+ protected $propertyPassthru = [
+ 'from',
+ ];
+
/**
* The methods that should be returned from query builder.
*
- * @var array
+ * @var string[]
*/
protected $passthru = [
- 'insert', 'insertGetId', 'getBindings', 'toSql',
- 'exists', 'doesntExist', 'count', 'min', 'max', 'avg', 'average', 'sum', 'getConnection',
+ 'aggregate',
+ 'average',
+ 'avg',
+ 'count',
+ 'dd',
+ 'ddrawsql',
+ 'doesntexist',
+ 'doesntexistor',
+ 'dump',
+ 'dumprawsql',
+ 'exists',
+ 'existsor',
+ 'explain',
+ 'getbindings',
+ 'getconnection',
+ 'getcountforpagination',
+ 'getgrammar',
+ 'getrawbindings',
+ 'implode',
+ 'insert',
+ 'insertgetid',
+ 'insertorignore',
+ 'insertusing',
+ 'insertorignoreusing',
+ 'max',
+ 'min',
+ 'numericaggregate',
+ 'raw',
+ 'rawvalue',
+ 'sum',
+ 'tosql',
+ 'torawsql',
];
/**
@@ -89,11 +151,24 @@ class Builder
*/
protected $removedScopes = [];
+ /**
+ * The callbacks that should be invoked after retrieving data from the database.
+ *
+ * @var array
+ */
+ protected $afterQueryCallbacks = [];
+
+ /**
+ * The callbacks that should be invoked on clone.
+ *
+ * @var array
+ */
+ protected $onCloneCallbacks = [];
+
/**
* Create a new Eloquent query builder instance.
*
* @param \Illuminate\Database\Query\Builder $query
- * @return void
*/
public function __construct(QueryBuilder $query)
{
@@ -104,7 +179,7 @@ public function __construct(QueryBuilder $query)
* Create and return an un-saved model instance.
*
* @param array $attributes
- * @return \Illuminate\Database\Eloquent\Model
+ * @return TModel
*/
public function make(array $attributes = [])
{
@@ -154,7 +229,7 @@ public function withoutGlobalScope($scope)
* @param array|null $scopes
* @return $this
*/
- public function withoutGlobalScopes(array $scopes = null)
+ public function withoutGlobalScopes(?array $scopes = null)
{
if (! is_array($scopes)) {
$scopes = array_keys($this->scopes);
@@ -167,6 +242,21 @@ public function withoutGlobalScopes(array $scopes = null)
return $this;
}
+ /**
+ * Remove all global scopes except the given scopes.
+ *
+ * @param array $scopes
+ * @return $this
+ */
+ public function withoutGlobalScopesExcept(array $scopes = [])
+ {
+ $this->withoutGlobalScopes(
+ array_diff(array_keys($this->scopes), $scopes)
+ );
+
+ return $this;
+ }
+
/**
* Get an array of global scopes that were removed from the query.
*
@@ -185,12 +275,24 @@ public function removedScopes()
*/
public function whereKey($id)
{
+ if ($id instanceof Model) {
+ $id = $id->getKey();
+ }
+
if (is_array($id) || $id instanceof Arrayable) {
- $this->query->whereIn($this->model->getQualifiedKeyName(), $id);
+ if (in_array($this->model->getKeyType(), ['int', 'integer'])) {
+ $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id);
+ } else {
+ $this->query->whereIn($this->model->getQualifiedKeyName(), $id);
+ }
return $this;
}
+ if ($id !== null && $this->model->getKeyType() === 'string') {
+ $id = (string) $id;
+ }
+
return $this->where($this->model->getQualifiedKeyName(), '=', $id);
}
@@ -202,29 +304,61 @@ public function whereKey($id)
*/
public function whereKeyNot($id)
{
+ if ($id instanceof Model) {
+ $id = $id->getKey();
+ }
+
if (is_array($id) || $id instanceof Arrayable) {
- $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id);
+ if (in_array($this->model->getKeyType(), ['int', 'integer'])) {
+ $this->query->whereIntegerNotInRaw($this->model->getQualifiedKeyName(), $id);
+ } else {
+ $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id);
+ }
return $this;
}
+ if ($id !== null && $this->model->getKeyType() === 'string') {
+ $id = (string) $id;
+ }
+
return $this->where($this->model->getQualifiedKeyName(), '!=', $id);
}
+ /**
+ * Exclude the given models from the query results.
+ *
+ * @param iterable|mixed $models
+ * @return static
+ */
+ public function except($models)
+ {
+ return $this->whereKeyNot(
+ $models instanceof Model
+ ? $models->getKey()
+ : Collection::wrap($models)->modelKeys()
+ );
+ }
+
/**
* Add a basic where clause to the query.
*
- * @param string|array|\Closure $column
- * @param mixed $operator
- * @param mixed $value
+ * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column
+ * @param mixed $operator
+ * @param mixed $value
* @param string $boolean
* @return $this
*/
public function where($column, $operator = null, $value = null, $boolean = 'and')
{
- if ($column instanceof Closure) {
- $column($query = $this->model->newModelQuery());
+ if ($column instanceof Closure && is_null($operator)) {
+ $column($query = $this->model->newQueryWithoutRelationships());
+
+ $this->eagerLoad = array_merge($this->eagerLoad, $query->getEagerLoads());
+ $this->withoutGlobalScopes(
+ $query->removedScopes()
+ );
$this->query->addNestedWhereQuery($query->getQuery(), $boolean);
} else {
$this->query->where(...func_get_args());
@@ -233,13 +367,27 @@ public function where($column, $operator = null, $value = null, $boolean = 'and'
return $this;
}
+ /**
+ * Add a basic where clause to the query, and return the first result.
+ *
+ * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column
+ * @param mixed $operator
+ * @param mixed $value
+ * @param string $boolean
+ * @return TModel|null
+ */
+ public function firstWhere($column, $operator = null, $value = null, $boolean = 'and')
+ {
+ return $this->where(...func_get_args())->first();
+ }
+
/**
* Add an "or where" clause to the query.
*
- * @param \Closure|array|string $column
+ * @param (\Closure(static): mixed)|array|string|\Illuminate\Contracts\Database\Query\Expression $column
* @param mixed $operator
* @param mixed $value
- * @return \Illuminate\Database\Eloquent\Builder|static
+ * @return $this
*/
public function orWhere($column, $operator = null, $value = null)
{
@@ -250,10 +398,37 @@ public function orWhere($column, $operator = null, $value = null)
return $this->where($column, $operator, $value, 'or');
}
+ /**
+ * Add a basic "where not" clause to the query.
+ *
+ * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column
+ * @param mixed $operator
+ * @param mixed $value
+ * @param string $boolean
+ * @return $this
+ */
+ public function whereNot($column, $operator = null, $value = null, $boolean = 'and')
+ {
+ return $this->where($column, $operator, $value, $boolean.' not');
+ }
+
+ /**
+ * Add an "or where not" clause to the query.
+ *
+ * @param (\Closure(static): mixed)|array|string|\Illuminate\Contracts\Database\Query\Expression $column
+ * @param mixed $operator
+ * @param mixed $value
+ * @return $this
+ */
+ public function orWhereNot($column, $operator = null, $value = null)
+ {
+ return $this->whereNot($column, $operator, $value, 'or');
+ }
+
/**
* Add an "order by" clause for a timestamp to the query.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @return $this
*/
public function latest($column = null)
@@ -270,7 +445,7 @@ public function latest($column = null)
/**
* Add an "order by" clause for a timestamp to the query.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @return $this
*/
public function oldest($column = null)
@@ -288,23 +463,90 @@ public function oldest($column = null)
* Create a collection of models from plain arrays.
*
* @param array $items
- * @return \Illuminate\Database\Eloquent\Collection
+ * @return \Illuminate\Database\Eloquent\Collection
*/
public function hydrate(array $items)
{
$instance = $this->newModelInstance();
- return $instance->newCollection(array_map(function ($item) use ($instance) {
- return $instance->newFromBuilder($item);
+ return $instance->newCollection(array_map(function ($item) use ($items, $instance) {
+ $model = $instance->newFromBuilder($item);
+
+ if (count($items) > 1) {
+ $model->preventsLazyLoading = Model::preventsLazyLoading();
+ }
+
+ return $model;
}, $items));
}
+ /**
+ * Insert into the database after merging the model's default attributes, setting timestamps, and casting values.
+ *
+ * @param array> $values
+ * @return bool
+ */
+ public function fillAndInsert(array $values)
+ {
+ return $this->insert($this->fillForInsert($values));
+ }
+
+ /**
+ * Insert (ignoring errors) into the database after merging the model's default attributes, setting timestamps, and casting values.
+ *
+ * @param array> $values
+ * @return int
+ */
+ public function fillAndInsertOrIgnore(array $values)
+ {
+ return $this->insertOrIgnore($this->fillForInsert($values));
+ }
+
+ /**
+ * Insert a record into the database and get its ID after merging the model's default attributes, setting timestamps, and casting values.
+ *
+ * @param array $values
+ * @return int
+ */
+ public function fillAndInsertGetId(array $values)
+ {
+ return $this->insertGetId($this->fillForInsert([$values])[0]);
+ }
+
+ /**
+ * Enrich the given values by merging in the model's default attributes, adding timestamps, and casting values.
+ *
+ * @param array> $values
+ * @return array>
+ */
+ public function fillForInsert(array $values)
+ {
+ if (empty($values)) {
+ return [];
+ }
+
+ if (! is_array(array_first($values))) {
+ $values = [$values];
+ }
+
+ $this->model->unguarded(function () use (&$values) {
+ foreach ($values as $key => $rowValues) {
+ $values[$key] = tap(
+ $this->newModelInstance($rowValues),
+ fn ($model) => $model->setUniqueIds()
+ )->getAttributes();
+ }
+ });
+
+ return $this->addTimestampsToUpsertValues($values);
+ }
+
/**
* Create a collection of models from a raw query.
*
* @param string $query
* @param array $bindings
- * @return \Illuminate\Database\Eloquent\Collection
+ * @return \Illuminate\Database\Eloquent\Collection
*/
public function fromQuery($query, $bindings = [])
{
@@ -317,8 +559,8 @@ public function fromQuery($query, $bindings = [])
* Find a model by its primary key.
*
* @param mixed $id
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static[]|static|null
+ * @param array|string $columns
+ * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel|null)
*/
public function find($id, $columns = ['*'])
{
@@ -329,15 +571,32 @@ public function find($id, $columns = ['*'])
return $this->whereKey($id)->first($columns);
}
+ /**
+ * Find a sole model by its primary key.
+ *
+ * @param mixed $id
+ * @param array|string $columns
+ * @return TModel
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws \Illuminate\Database\MultipleRecordsFoundException
+ */
+ public function findSole($id, $columns = ['*'])
+ {
+ return $this->whereKey($id)->sole($columns);
+ }
+
/**
* Find multiple models by their primary keys.
*
* @param \Illuminate\Contracts\Support\Arrayable|array $ids
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Collection
+ * @param array|string $columns
+ * @return \Illuminate\Database\Eloquent\Collection
*/
public function findMany($ids, $columns = ['*'])
{
+ $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids;
+
if (empty($ids)) {
return $this->model->newCollection();
}
@@ -349,34 +608,42 @@ public function findMany($ids, $columns = ['*'])
* Find a model by its primary key or throw an exception.
*
* @param mixed $id
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static|static[]
+ * @param array|string $columns
+ * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel)
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function findOrFail($id, $columns = ['*'])
{
$result = $this->find($id, $columns);
+ $id = $id instanceof Arrayable ? $id->toArray() : $id;
+
if (is_array($id)) {
- if (count($result) === count(array_unique($id))) {
- return $result;
+ if (count($result) !== count(array_unique($id))) {
+ throw (new ModelNotFoundException)->setModel(
+ get_class($this->model), array_diff($id, $result->modelKeys())
+ );
}
- } elseif (! is_null($result)) {
+
return $result;
}
- throw (new ModelNotFoundException)->setModel(
- get_class($this->model), $id
- );
+ if (is_null($result)) {
+ throw (new ModelNotFoundException)->setModel(
+ get_class($this->model), $id
+ );
+ }
+
+ return $result;
}
/**
* Find a model by its primary key or return fresh model instance.
*
* @param mixed $id
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @param array|string $columns
+ * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel)
*/
public function findOrNew($id, $columns = ['*'])
{
@@ -387,38 +654,83 @@ public function findOrNew($id, $columns = ['*'])
return $this->newModelInstance();
}
+ /**
+ * Find a model by its primary key or call a callback.
+ *
+ * @template TValue
+ *
+ * @param mixed $id
+ * @param (\Closure(): TValue)|list|string $columns
+ * @param (\Closure(): TValue)|null $callback
+ * @return (
+ * $id is (\Illuminate\Contracts\Support\Arrayable|array)
+ * ? \Illuminate\Database\Eloquent\Collection
+ * : TModel|TValue
+ * )
+ */
+ public function findOr($id, $columns = ['*'], ?Closure $callback = null)
+ {
+ if ($columns instanceof Closure) {
+ $callback = $columns;
+
+ $columns = ['*'];
+ }
+
+ if (! is_null($model = $this->find($id, $columns))) {
+ return $model;
+ }
+
+ return $callback();
+ }
+
/**
* Get the first record matching the attributes or instantiate it.
*
* @param array $attributes
* @param array $values
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @return TModel
*/
- public function firstOrNew(array $attributes, array $values = [])
+ public function firstOrNew(array $attributes = [], array $values = [])
{
if (! is_null($instance = $this->where($attributes)->first())) {
return $instance;
}
- return $this->newModelInstance($attributes + $values);
+ return $this->newModelInstance(array_merge($attributes, $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
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @param (\Closure(): array)|array $values
+ * @return TModel
*/
- public function firstOrCreate(array $attributes, array $values = [])
+ public function firstOrCreate(array $attributes = [], Closure|array $values = [])
{
- if (! is_null($instance = $this->where($attributes)->first())) {
+ if (! is_null($instance = (clone $this)->where($attributes)->first())) {
return $instance;
}
- return tap($this->newModelInstance($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 (\Closure(): array)|array $values
+ * @return TModel
+ *
+ * @throws \Illuminate\Database\UniqueConstraintViolationException
+ */
+ public function createOrFirst(array $attributes = [], Closure|array $values = [])
+ {
+ try {
+ return $this->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values))));
+ } catch (UniqueConstraintViolationException $e) {
+ return $this->useWritePdo()->where($attributes)->first() ?? throw $e;
+ }
}
/**
@@ -426,22 +738,43 @@ public function firstOrCreate(array $attributes, array $values = [])
*
* @param array $attributes
* @param array $values
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @return TModel
*/
public function updateOrCreate(array $attributes, array $values = [])
{
- return tap($this->firstOrNew($attributes), function ($instance) use ($values) {
- $instance->fill($values)->save();
+ return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) {
+ if (! $instance->wasRecentlyCreated) {
+ $instance->fill($values)->save();
+ }
+ });
+ }
+
+ /**
+ * Create a record matching the attributes, or increment the existing record.
+ *
+ * @param array $attributes
+ * @param string $column
+ * @param int|float $default
+ * @param int|float $step
+ * @param array $extra
+ * @return TModel
+ */
+ public function incrementOrCreate(array $attributes, string $column = 'count', $default = 1, $step = 1, array $extra = [])
+ {
+ return tap($this->firstOrCreate($attributes, [$column => $default]), function ($instance) use ($column, $step, $extra) {
+ if (! $instance->wasRecentlyCreated) {
+ $instance->increment($column, $step, $extra);
+ }
});
}
/**
* Execute the query and get the first result or throw an exception.
*
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @param array|string $columns
+ * @return TModel
*
- * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function firstOrFail($columns = ['*'])
{
@@ -455,11 +788,13 @@ public function firstOrFail($columns = ['*'])
/**
* Execute the query and get the first result or call a callback.
*
- * @param \Closure|array $columns
- * @param \Closure|null $callback
- * @return \Illuminate\Database\Eloquent\Model|static|mixed
+ * @template TValue
+ *
+ * @param (\Closure(): TValue)|list $columns
+ * @param (\Closure(): TValue)|null $callback
+ * @return TModel|TValue
*/
- public function firstOr($columns = ['*'], Closure $callback = null)
+ public function firstOr($columns = ['*'], ?Closure $callback = null)
{
if ($columns instanceof Closure) {
$callback = $columns;
@@ -471,27 +806,78 @@ public function firstOr($columns = ['*'], Closure $callback = null)
return $model;
}
- return call_user_func($callback);
+ return $callback();
+ }
+
+ /**
+ * Execute the query and get the first result if it's the sole matching record.
+ *
+ * @param array|string $columns
+ * @return TModel
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws \Illuminate\Database\MultipleRecordsFoundException
+ */
+ public function sole($columns = ['*'])
+ {
+ try {
+ return $this->baseSole($columns);
+ } catch (RecordsNotFoundException) {
+ throw (new ModelNotFoundException)->setModel(get_class($this->model));
+ }
}
/**
* Get a single column's value from the first result of a query.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @return mixed
*/
public function value($column)
{
if ($result = $this->first([$column])) {
- return $result->{$column};
+ $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+
+ return $result->{Str::afterLast($column, '.')};
}
}
+ /**
+ * Get a single column's value from the first result of a query if it's the sole matching record.
+ *
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
+ * @return mixed
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ * @throws \Illuminate\Database\MultipleRecordsFoundException
+ */
+ public function soleValue($column)
+ {
+ $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+
+ return $this->sole([$column])->{Str::afterLast($column, '.')};
+ }
+
+ /**
+ * Get a single column's value from the first result of the query or throw an exception.
+ *
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
+ * @return mixed
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ */
+ public function valueOrFail($column)
+ {
+ $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+
+ return $this->firstOrFail([$column])->{Str::afterLast($column, '.')};
+ }
+
/**
* Execute the query as a "select" statement.
*
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Collection|static[]
+ * @param array|string $columns
+ * @return \Illuminate\Database\Eloquent\Collection
*/
public function get($columns = ['*'])
{
@@ -504,14 +890,16 @@ public function get($columns = ['*'])
$models = $builder->eagerLoadRelations($models);
}
- return $builder->getModel()->newCollection($models);
+ return $this->applyAfterQueryCallbacks(
+ $builder->getModel()->newCollection($models)
+ );
}
/**
* Get the hydrated models without eager loading.
*
- * @param array $columns
- * @return \Illuminate\Database\Eloquent\Model[]|static[]
+ * @param array|string $columns
+ * @return array
*/
public function getModels($columns = ['*'])
{
@@ -523,8 +911,8 @@ public function getModels($columns = ['*'])
/**
* Eager load the relationships for the models.
*
- * @param array $models
- * @return array
+ * @param array $models
+ * @return array
*/
public function eagerLoadRelations(array $models)
{
@@ -532,7 +920,7 @@ public function eagerLoadRelations(array $models)
// For nested eager loads we'll skip loading them here and they will be set as an
// eager load on the query to retrieve the relation so that they will be eager
// loaded on that query, because that is where they get hydrated as models.
- if (strpos($name, '.') === false) {
+ if (! str_contains($name, '.')) {
$models = $this->eagerLoadRelation($models, $name, $constraints);
}
}
@@ -572,7 +960,7 @@ protected function eagerLoadRelation(array $models, $name, Closure $constraints)
* Get the relation instance for the given relation name.
*
* @param string $name
- * @return \Illuminate\Database\Eloquent\Relations\Relation
+ * @return \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, TModel, *>
*/
public function getRelation($name)
{
@@ -582,7 +970,7 @@ public function getRelation($name)
$relation = Relation::noConstraints(function () use ($name) {
try {
return $this->getModel()->newInstance()->$name();
- } catch (BadMethodCallException $e) {
+ } catch (BadMethodCallException) {
throw RelationNotFoundException::make($this->getModel(), $name);
}
});
@@ -630,65 +1018,49 @@ protected function relationsNestedUnder($relation)
*/
protected function isNestedUnder($relation, $name)
{
- return Str::contains($name, '.') && Str::startsWith($name, $relation.'.');
+ return str_contains($name, '.') && str_starts_with($name, $relation.'.');
}
/**
- * Get a generator for the given query.
+ * Register a closure to be invoked after the query is executed.
*
- * @return \Generator
+ * @param \Closure $callback
+ * @return $this
*/
- public function cursor()
+ public function afterQuery(Closure $callback)
{
- foreach ($this->applyScopes()->query->cursor() as $record) {
- yield $this->model->newFromBuilder($record);
- }
+ $this->afterQueryCallbacks[] = $callback;
+
+ return $this;
}
/**
- * Chunk the results of a query by comparing numeric IDs.
+ * Invoke the "after query" modification callbacks.
*
- * @param int $count
- * @param callable $callback
- * @param string|null $column
- * @param string|null $alias
- * @return bool
+ * @param mixed $result
+ * @return mixed
*/
- public function chunkById($count, callable $callback, $column = null, $alias = null)
+ public function applyAfterQueryCallbacks($result)
{
- $column = is_null($column) ? $this->getModel()->getKeyName() : $column;
-
- $alias = is_null($alias) ? $column : $alias;
-
- $lastId = null;
-
- do {
- $clone = clone $this;
-
- // We'll execute the query for the given page and get the results. If there are
- // no results we can just break and return from here. When there are results
- // we will call the callback with the current chunk of these results here.
- $results = $clone->forPageAfterId($count, $lastId, $column)->get();
-
- $countResults = $results->count();
-
- if ($countResults == 0) {
- break;
- }
-
- // On each chunk result set, we will pass them to the callback and then let the
- // developer take care of everything within the callback, which allows us to
- // keep the memory low for spinning through large result sets for working.
- if ($callback($results) === false) {
- return false;
- }
+ foreach ($this->afterQueryCallbacks as $afterQueryCallback) {
+ $result = $afterQueryCallback($result) ?: $result;
+ }
- $lastId = $results->last()->{$alias};
+ return $result;
+ }
- unset($results);
- } while ($countResults == $count);
+ /**
+ * Get a lazy collection for the given query.
+ *
+ * @return \Illuminate\Support\LazyCollection
+ */
+ public function cursor()
+ {
+ return $this->applyScopes()->query->cursor()->map(function ($record) {
+ $model = $this->newModelInstance()->newFromBuilder($record);
- return true;
+ return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first();
+ })->reject(fn ($model) => is_null($model));
}
/**
@@ -704,50 +1076,59 @@ protected function enforceOrderBy()
}
/**
- * Get an array with the values of a given column.
+ * Get a collection with the values of a given column.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @param string|null $key
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection
*/
public function pluck($column, $key = null)
{
$results = $this->toBase()->pluck($column, $key);
- // If the model has a mutator for the requested column, we will spin through
+ $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+
+ $column = Str::after($column, "{$this->model->getTable()}.");
+
+ // If the model has a mutator for the requested column, we will spin through
// the results and mutate the values so that the mutated version of these
// columns are returned as you would expect from these Eloquent models.
- if (! $this->model->hasGetMutator($column) &&
+ if (! $this->model->hasAnyGetMutator($column) &&
! $this->model->hasCast($column) &&
! in_array($column, $this->model->getDates())) {
- return $results;
+ return $this->applyAfterQueryCallbacks($results);
}
- return $results->map(function ($value) use ($column) {
- return $this->model->newFromBuilder([$column => $value])->{$column};
- });
+ return $this->applyAfterQueryCallbacks(
+ $results->map(function ($value) use ($column) {
+ return $this->model->newFromBuilder([$column => $value])->{$column};
+ })
+ );
}
/**
* Paginate the given query.
*
- * @param int $perPage
- * @param array $columns
+ * @param int|null|\Closure $perPage
+ * @param array|string $columns
* @param string $pageName
* @param int|null $page
- * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ * @param \Closure|int|null $total
+ * @return \Illuminate\Pagination\LengthAwarePaginator
*
* @throws \InvalidArgumentException
*/
- public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null)
+ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null)
{
$page = $page ?: Paginator::resolveCurrentPage($pageName);
- $perPage = $perPage ?: $this->model->getPerPage();
+ $total = value($total) ?? $this->toBase()->getCountForPagination();
- $results = ($total = $this->toBase()->getCountForPagination())
- ? $this->forPage($page, $perPage)->get($columns)
- : $this->model->newCollection();
+ $perPage = value($perPage, $total) ?: $this->model->getPerPage();
+
+ $results = $total
+ ? $this->forPage($page, $perPage)->get($columns)
+ : $this->model->newCollection();
return $this->paginator($results, $total, $perPage, $page, [
'path' => Paginator::resolveCurrentPath(),
@@ -758,8 +1139,8 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page',
/**
* Paginate the given query into a simple paginator.
*
- * @param int $perPage
- * @param array $columns
+ * @param int|null $perPage
+ * @param array|string $columns
* @param string $pageName
* @param int|null $page
* @return \Illuminate\Contracts\Pagination\Paginator
@@ -773,7 +1154,7 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p
// Next we will set the limit and offset for this query so that when we get the
// results we get the proper section of results. Then, we'll create the full
// paginator instances for these results with the given page and per page.
- $this->skip(($page - 1) * $perPage)->take($perPage + 1);
+ $this->offset(($page - 1) * $perPage)->limit($perPage + 1);
return $this->simplePaginator($this->get($columns), $perPage, $page, [
'path' => Paginator::resolveCurrentPath(),
@@ -781,11 +1162,61 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p
]);
}
+ /**
+ * Paginate the given query into a cursor paginator.
+ *
+ * @param int|null $perPage
+ * @param array|string $columns
+ * @param string $cursorName
+ * @param \Illuminate\Pagination\Cursor|string|null $cursor
+ * @return \Illuminate\Contracts\Pagination\CursorPaginator
+ */
+ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null)
+ {
+ $perPage = $perPage ?: $this->model->getPerPage();
+
+ return $this->paginateUsingCursor($perPage, $columns, $cursorName, $cursor);
+ }
+
+ /**
+ * Ensure the proper order by required for cursor pagination.
+ *
+ * @param bool $shouldReverse
+ * @return \Illuminate\Support\Collection
+ */
+ protected function ensureOrderForCursorPagination($shouldReverse = false)
+ {
+ if (empty($this->query->orders) && empty($this->query->unionOrders)) {
+ $this->enforceOrderBy();
+ }
+
+ $reverseDirection = function ($order) {
+ if (! isset($order['direction'])) {
+ return $order;
+ }
+
+ $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc';
+
+ return $order;
+ };
+
+ if ($shouldReverse) {
+ $this->query->orders = (new BaseCollection($this->query->orders))->map($reverseDirection)->toArray();
+ $this->query->unionOrders = (new BaseCollection($this->query->unionOrders))->map($reverseDirection)->toArray();
+ }
+
+ $orders = ! empty($this->query->unionOrders) ? $this->query->unionOrders : $this->query->orders;
+
+ return (new BaseCollection($orders))
+ ->filter(fn ($order) => Arr::has($order, 'direction'))
+ ->values();
+ }
+
/**
* Save a new model and return the instance.
*
* @param array $attributes
- * @return \Illuminate\Database\Eloquent\Model|$this
+ * @return TModel
*/
public function create(array $attributes = [])
{
@@ -794,11 +1225,22 @@ public function create(array $attributes = [])
});
}
+ /**
+ * Save a new model and return the instance without raising model events.
+ *
+ * @param array $attributes
+ * @return TModel
+ */
+ public function createQuietly(array $attributes = [])
+ {
+ return Model::withoutEvents(fn () => $this->create($attributes));
+ }
+
/**
* Save a new model and return the instance. Allow mass-assignment.
*
* @param array $attributes
- * @return \Illuminate\Database\Eloquent\Model|$this
+ * @return TModel
*/
public function forceCreate(array $attributes)
{
@@ -808,7 +1250,18 @@ public function forceCreate(array $attributes)
}
/**
- * Update a record in the database.
+ * Save a new model instance with mass assignment without raising model events.
+ *
+ * @param array $attributes
+ * @return TModel
+ */
+ public function forceCreateQuietly(array $attributes = [])
+ {
+ return Model::withoutEvents(fn () => $this->forceCreate($attributes));
+ }
+
+ /**
+ * Update records in the database.
*
* @param array $values
* @return int
@@ -818,10 +1271,62 @@ public function update(array $values)
return $this->toBase()->update($this->addUpdatedAtColumn($values));
}
+ /**
+ * Insert new records or update the existing ones.
+ *
+ * @param array $values
+ * @param array|string $uniqueBy
+ * @param array|null $update
+ * @return int
+ */
+ public function upsert(array $values, $uniqueBy, $update = null)
+ {
+ if (empty($values)) {
+ return 0;
+ }
+
+ if (! is_array(array_first($values))) {
+ $values = [$values];
+ }
+
+ if (is_null($update)) {
+ $update = array_keys(array_first($values));
+ }
+
+ return $this->toBase()->upsert(
+ $this->addTimestampsToUpsertValues($this->addUniqueIdsToUpsertValues($values)),
+ $uniqueBy,
+ $this->addUpdatedAtToUpsertColumns($update)
+ );
+ }
+
+ /**
+ * Update the column's update timestamp.
+ *
+ * @param string|null $column
+ * @return int|false
+ */
+ public function touch($column = null)
+ {
+ $time = $this->model->freshTimestamp();
+
+ if ($column) {
+ return $this->toBase()->update([$column => $time]);
+ }
+
+ $column = $this->model->getUpdatedAtColumn();
+
+ if (! $this->model->usesTimestamps() || is_null($column)) {
+ return false;
+ }
+
+ return $this->toBase()->update([$column => $time]);
+ }
+
/**
* Increment a column's value by a given amount.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @param float|int $amount
* @param array $extra
* @return int
@@ -836,7 +1341,7 @@ public function increment($column, $amount = 1, array $extra = [])
/**
* Decrement a column's value by a given amount.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @param float|int $amount
* @param array $extra
* @return int
@@ -863,12 +1368,27 @@ protected function addUpdatedAtColumn(array $values)
$column = $this->model->getUpdatedAtColumn();
- $values = array_merge(
- [$column => $this->model->freshTimestampString()],
- $values
- );
+ if (! array_key_exists($column, $values)) {
+ $timestamp = $this->model->freshTimestampString();
+
+ if (
+ $this->model->hasSetMutator($column)
+ || $this->model->hasAttributeSetMutator($column)
+ || $this->model->hasCast($column)
+ ) {
+ $timestamp = $this->model->newInstance()
+ ->forceFill([$column => $timestamp])
+ ->getAttributes()[$column] ?? $timestamp;
+ }
+
+ $values = array_merge([$column => $timestamp], $values);
+ }
+
+ $segments = preg_split('/\s+as\s+/i', $this->query->from);
+
+ $qualifiedColumn = array_last($segments).'.'.$column;
- $values[$this->qualifyColumn($column)] = $values[$column];
+ $values[$qualifiedColumn] = Arr::get($values, $qualifiedColumn, $values[$column]);
unset($values[$column]);
@@ -876,7 +1396,81 @@ protected function addUpdatedAtColumn(array $values)
}
/**
- * Delete a record from the database.
+ * Add unique IDs to the inserted values.
+ *
+ * @param array $values
+ * @return array
+ */
+ protected function addUniqueIdsToUpsertValues(array $values)
+ {
+ if (! $this->model->usesUniqueIds()) {
+ return $values;
+ }
+
+ foreach ($this->model->uniqueIds() as $uniqueIdAttribute) {
+ foreach ($values as &$row) {
+ if (! array_key_exists($uniqueIdAttribute, $row)) {
+ $row = array_merge([$uniqueIdAttribute => $this->model->newUniqueId()], $row);
+ }
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Add timestamps to the inserted values.
+ *
+ * @param array $values
+ * @return array
+ */
+ protected function addTimestampsToUpsertValues(array $values)
+ {
+ if (! $this->model->usesTimestamps()) {
+ return $values;
+ }
+
+ $timestamp = $this->model->freshTimestampString();
+
+ $columns = array_filter([
+ $this->model->getCreatedAtColumn(),
+ $this->model->getUpdatedAtColumn(),
+ ]);
+
+ foreach ($columns as $column) {
+ foreach ($values as &$row) {
+ $row = array_merge([$column => $timestamp], $row);
+ }
+ }
+
+ return $values;
+ }
+
+ /**
+ * Add the "updated at" column to the updated columns.
+ *
+ * @param array $update
+ * @return array
+ */
+ protected function addUpdatedAtToUpsertColumns(array $update)
+ {
+ if (! $this->model->usesTimestamps()) {
+ return $update;
+ }
+
+ $column = $this->model->getUpdatedAtColumn();
+
+ if (! is_null($column) &&
+ ! array_key_exists($column, $update) &&
+ ! in_array($column, $update)) {
+ $update[] = $column;
+ }
+
+ return $update;
+ }
+
+ /**
+ * Delete records from the database.
*
* @return mixed
*/
@@ -912,17 +1506,28 @@ public function onDelete(Closure $callback)
$this->onDelete = $callback;
}
+ /**
+ * Determine if the given model has a scope.
+ *
+ * @param string $scope
+ * @return bool
+ */
+ public function hasNamedScope($scope)
+ {
+ return $this->model && $this->model->hasNamedScope($scope);
+ }
+
/**
* Call the given local model scopes.
*
- * @param array $scopes
+ * @param array|string $scopes
* @return static|mixed
*/
- public function scopes(array $scopes)
+ public function scopes($scopes)
{
$builder = $this;
- foreach ($scopes as $scope => $parameters) {
+ foreach (Arr::wrap($scopes) as $scope => $parameters) {
// If the scope key is an integer, then the scope was passed as the value and
// the parameter list is empty, so we will format the scope name and these
// parameters here. Then, we'll be ready to call the scope on the model.
@@ -933,9 +1538,8 @@ public function scopes(array $scopes)
// Next we'll pass the scope callback to the callScope method which will take
// care of grouping the "wheres" properly so the logical order doesn't get
// messed up when adding scopes. Then we'll return back out the builder.
- $builder = $builder->callScope(
- [$this->model, 'scope'.ucfirst($scope)],
- (array) $parameters
+ $builder = $builder->callNamedScope(
+ $scope, Arr::wrap($parameters)
);
}
@@ -960,7 +1564,7 @@ public function applyScopes()
continue;
}
- $builder->callScope(function (Builder $builder) use ($scope) {
+ $builder->callScope(function (self $builder) use ($scope) {
// If the scope is a Closure we will just go ahead and call the scope with the
// builder instance. The "callScope" method will properly group the clauses
// that are added to this query so "where" clauses maintain proper logic.
@@ -987,7 +1591,7 @@ public function applyScopes()
* @param array $parameters
* @return mixed
*/
- protected function callScope(callable $scope, $parameters = [])
+ protected function callScope(callable $scope, array $parameters = [])
{
array_unshift($parameters, $this);
@@ -997,9 +1601,10 @@ protected function callScope(callable $scope, $parameters = [])
// scope so that we can properly group the added scope constraints in the
// query as their own isolated nested where statement and avoid issues.
$originalWhereCount = is_null($query->wheres)
- ? 0 : count($query->wheres);
+ ? 0
+ : count($query->wheres);
- $result = $scope(...array_values($parameters)) ?? $this;
+ $result = $scope(...$parameters) ?? $this;
if (count((array) $query->wheres) > $originalWhereCount) {
$this->addNewWheresWithinGroup($query, $originalWhereCount);
@@ -1008,6 +1613,20 @@ protected function callScope(callable $scope, $parameters = [])
return $result;
}
+ /**
+ * Apply the given named scope on the current builder instance.
+ *
+ * @param string $scope
+ * @param array $parameters
+ * @return mixed
+ */
+ protected function callNamedScope($scope, array $parameters = [])
+ {
+ return $this->callScope(function (...$parameters) use ($scope) {
+ return $this->model->callNamedScope($scope, $parameters);
+ }, $parameters);
+ }
+
/**
* Nest where conditions by slicing them at the given where count.
*
@@ -1042,14 +1661,14 @@ protected function addNewWheresWithinGroup(QueryBuilder $query, $originalWhereCo
*/
protected function groupWhereSliceForScope(QueryBuilder $query, $whereSlice)
{
- $whereBooleans = collect($whereSlice)->pluck('boolean');
+ $whereBooleans = (new BaseCollection($whereSlice))->pluck('boolean');
// Here we'll check if the given subset of where clauses contains any "or"
// booleans and in this case create a nested where expression. That way
// we don't add any unnecessary nesting thus keeping the query clean.
- if ($whereBooleans->contains('or')) {
+ if ($whereBooleans->contains(fn ($logicalOperator) => str_contains($logicalOperator, 'or'))) {
$query->wheres[] = $this->createNestedWhere(
- $whereSlice, $whereBooleans->first()
+ $whereSlice, str_replace(' not', '', $whereBooleans->first())
);
} else {
$query->wheres = array_merge($query->wheres, $whereSlice);
@@ -1073,14 +1692,19 @@ protected function createNestedWhere($whereSlice, $boolean = 'and')
}
/**
- * Set the relationships that should be eager loaded.
+ * Specify relationships that should be eager loaded.
*
- * @param mixed $relations
+ * @param array): mixed)|string>|string $relations
+ * @param (\Closure(\Illuminate\Database\Eloquent\Relations\Relation<*,*,*>): mixed)|string|null $callback
* @return $this
*/
- public function with($relations)
+ public function with($relations, $callback = null)
{
- $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
+ if ($callback instanceof Closure) {
+ $eagerLoad = $this->parseWithRelations([$relations => $callback]);
+ } else {
+ $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations);
+ }
$this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad);
@@ -1102,14 +1726,29 @@ public function without($relations)
return $this;
}
+ /**
+ * Set the relationships that should be eager loaded while removing any previously added eager loading specifications.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @return $this
+ */
+ public function withOnly($relations)
+ {
+ $this->eagerLoad = [];
+
+ return $this->with($relations);
+ }
+
/**
* Create a new instance of the model being queried.
*
* @param array $attributes
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @return TModel
*/
public function newModelInstance($attributes = [])
{
+ $attributes = array_merge($this->pendingAttributes, $attributes);
+
return $this->model->newInstance($attributes)->setConnection(
$this->query->getConnection()->getName()
);
@@ -1123,22 +1762,13 @@ public function newModelInstance($attributes = [])
*/
protected function parseWithRelations(array $relations)
{
- $results = [];
+ if ($relations === []) {
+ return [];
+ }
- foreach ($relations as $name => $constraints) {
- // If the "name" value is a numeric key, we can assume that no
- // constraints have been specified. We'll just put an empty
- // Closure there, so that we can treat them all the same.
- if (is_numeric($name)) {
- $name = $constraints;
-
- [$name, $constraints] = Str::contains($name, ':')
- ? $this->createSelectWithConstraint($name)
- : [$name, function () {
- //
- }];
- }
+ $results = [];
+ foreach ($this->prepareNestedWithRelationships($relations) as $name => $constraints) {
// We need to separate out any nested includes, which allows the developers
// to load deep relationships using "dots" without stating each level of
// the relationship with its own key in the array of eager-load names.
@@ -1150,6 +1780,91 @@ protected function parseWithRelations(array $relations)
return $results;
}
+ /**
+ * Prepare nested with relationships.
+ *
+ * @param array $relations
+ * @param string $prefix
+ * @return array
+ */
+ protected function prepareNestedWithRelationships($relations, $prefix = '')
+ {
+ $preparedRelationships = [];
+
+ if ($prefix !== '') {
+ $prefix .= '.';
+ }
+
+ // If any of the relationships are formatted with the [$attribute => array()]
+ // syntax, we shall loop over the nested relations and prepend each key of
+ // this array while flattening into the traditional dot notation format.
+ foreach ($relations as $key => $value) {
+ if (! is_string($key) || ! is_array($value)) {
+ continue;
+ }
+
+ [$attribute, $attributeSelectConstraint] = $this->parseNameAndAttributeSelectionConstraint($key);
+
+ $preparedRelationships = array_merge(
+ $preparedRelationships,
+ ["{$prefix}{$attribute}" => $attributeSelectConstraint],
+ $this->prepareNestedWithRelationships($value, "{$prefix}{$attribute}"),
+ );
+
+ unset($relations[$key]);
+ }
+
+ // We now know that the remaining relationships are in a dot notation format
+ // and may be a string or Closure. We'll loop over them and ensure all of
+ // the present Closures are merged + strings are made into constraints.
+ foreach ($relations as $key => $value) {
+ if (is_numeric($key) && is_string($value)) {
+ [$key, $value] = $this->parseNameAndAttributeSelectionConstraint($value);
+ }
+
+ $preparedRelationships[$prefix.$key] = $this->combineConstraints([
+ $value,
+ $preparedRelationships[$prefix.$key] ?? static function () {
+ //
+ },
+ ]);
+ }
+
+ return $preparedRelationships;
+ }
+
+ /**
+ * Combine an array of constraints into a single constraint.
+ *
+ * @param array $constraints
+ * @return \Closure
+ */
+ protected function combineConstraints(array $constraints)
+ {
+ return function ($builder) use ($constraints) {
+ foreach ($constraints as $constraint) {
+ $builder = $constraint($builder) ?? $builder;
+ }
+
+ return $builder;
+ };
+ }
+
+ /**
+ * Parse the attribute select constraints from the name.
+ *
+ * @param string $name
+ * @return array
+ */
+ protected function parseNameAndAttributeSelectionConstraint($name)
+ {
+ return str_contains($name, ':')
+ ? $this->createSelectWithConstraint($name)
+ : [$name, static function () {
+ //
+ }];
+ }
+
/**
* Create a constraint to select the given columns for the relation.
*
@@ -1158,8 +1873,12 @@ protected function parseWithRelations(array $relations)
*/
protected function createSelectWithConstraint($name)
{
- return [explode(':', $name)[0], function ($query) use ($name) {
- $query->select(explode(',', explode(':', $name)[1]));
+ return [explode(':', $name)[0], static function ($query) use ($name) {
+ $query->select(array_map(static function ($column) use ($query) {
+ return $query instanceof BelongsToMany
+ ? $query->getRelated()->qualifyColumn($column)
+ : $column;
+ }, explode(',', explode(':', $name)[1])));
}];
}
@@ -1181,7 +1900,7 @@ protected function addNestedWiths($name, $results)
$progress[] = $segment;
if (! isset($results[$last = implode('.', $progress)])) {
- $results[$last] = function () {
+ $results[$last] = static function () {
//
};
}
@@ -1190,6 +1909,73 @@ protected function addNestedWiths($name, $results)
return $results;
}
+ /**
+ * Specify attributes that should be added to any new models created by this builder.
+ *
+ * The given key / value pairs will also be added as where conditions to the query.
+ *
+ * @param \Illuminate\Contracts\Database\Query\Expression|array|string $attributes
+ * @param mixed $value
+ * @param bool $asConditions
+ * @return $this
+ */
+ public function withAttributes(Expression|array|string $attributes, $value = null, $asConditions = true)
+ {
+ if (! is_array($attributes)) {
+ $attributes = [$attributes => $value];
+ }
+
+ if ($asConditions) {
+ foreach ($attributes as $column => $value) {
+ $this->where($this->qualifyColumn($column), $value);
+ }
+ }
+
+ $this->pendingAttributes = array_merge($this->pendingAttributes, $attributes);
+
+ return $this;
+ }
+
+ /**
+ * Apply query-time casts to the model instance.
+ *
+ * @param array $casts
+ * @return $this
+ */
+ public function withCasts($casts)
+ {
+ $this->model->mergeCasts($casts);
+
+ return $this;
+ }
+
+ /**
+ * Execute the given Closure within a transaction savepoint if needed.
+ *
+ * @template TModelValue
+ *
+ * @param \Closure(): TModelValue $scope
+ * @return TModelValue
+ */
+ public function withSavepointIfNeeded(Closure $scope): mixed
+ {
+ return $this->getQuery()->getConnection()->transactionLevel() > 0
+ ? $this->getQuery()->getConnection()->transaction($scope)
+ : $scope();
+ }
+
+ /**
+ * Get the Eloquent builder instances that are used in the union of the query.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ protected function getUnionBuilders()
+ {
+ return isset($this->query->unions)
+ ? (new BaseCollection($this->query->unions))->pluck('query')
+ : new BaseCollection;
+ }
+
/**
* Get the underlying query builder instance.
*
@@ -1246,10 +2032,63 @@ public function setEagerLoads(array $eagerLoad)
return $this;
}
+ /**
+ * Indicate that the given relationships should not be eagerly loaded.
+ *
+ * @param array $relations
+ * @return $this
+ */
+ public function withoutEagerLoad(array $relations)
+ {
+ $relations = array_diff(array_keys($this->model->getRelations()), $relations);
+
+ return $this->with($relations);
+ }
+
+ /**
+ * Flush the relationships being eagerly loaded.
+ *
+ * @return $this
+ */
+ public function withoutEagerLoads()
+ {
+ return $this->setEagerLoads([]);
+ }
+
+ /**
+ * Get the "limit" value from the query or null if it's not set.
+ *
+ * @return mixed
+ */
+ public function getLimit()
+ {
+ return $this->query->getLimit();
+ }
+
+ /**
+ * Get the "offset" value from the query or null if it's not set.
+ *
+ * @return mixed
+ */
+ public function getOffset()
+ {
+ return $this->query->getOffset();
+ }
+
+ /**
+ * Get the default key name of the table.
+ *
+ * @return string
+ */
+ protected function defaultKeyName()
+ {
+ return $this->getModel()->getKeyName();
+ }
+
/**
* Get the model instance being queried.
*
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @return TModel
*/
public function getModel()
{
@@ -1259,8 +2098,10 @@ public function getModel()
/**
* Set a model instance for the model being queried.
*
- * @param \Illuminate\Database\Eloquent\Model $model
- * @return $this
+ * @template TModelNew of \Illuminate\Database\Eloquent\Model
+ *
+ * @param TModelNew $model
+ * @return static
*/
public function setModel(Model $model)
{
@@ -1274,14 +2115,27 @@ public function setModel(Model $model)
/**
* Qualify the given column name by the model's table.
*
- * @param string $column
+ * @param string|\Illuminate\Contracts\Database\Query\Expression $column
* @return string
*/
public function qualifyColumn($column)
{
+ $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column;
+
return $this->model->qualifyColumn($column);
}
+ /**
+ * Qualify the given columns with the model's table.
+ *
+ * @param array|\Illuminate\Contracts\Database\Query\Expression $columns
+ * @return array
+ */
+ public function qualifyColumns($columns)
+ {
+ return $this->model->qualifyColumns($columns);
+ }
+
/**
* Get the given macro by name.
*
@@ -1293,6 +2147,39 @@ public function getMacro($name)
return Arr::get($this->localMacros, $name);
}
+ /**
+ * Checks if a macro is registered.
+ *
+ * @param string $name
+ * @return bool
+ */
+ public function hasMacro($name)
+ {
+ return isset($this->localMacros[$name]);
+ }
+
+ /**
+ * Get the given global macro by name.
+ *
+ * @param string $name
+ * @return \Closure
+ */
+ public static function getGlobalMacro($name)
+ {
+ return Arr::get(static::$macros, $name);
+ }
+
+ /**
+ * Checks if a global macro is registered.
+ *
+ * @param string $name
+ * @return bool
+ */
+ public static function hasGlobalMacro($name)
+ {
+ return isset(static::$macros[$name]);
+ }
+
/**
* Dynamically access builder proxies.
*
@@ -1303,10 +2190,14 @@ public function getMacro($name)
*/
public function __get($key)
{
- if ($key === 'orWhere') {
+ if (in_array($key, ['orWhere', 'whereNot', 'orWhereNot'])) {
return new HigherOrderBuilderProxy($this, $key);
}
+ if (in_array($key, $this->propertyPassthru)) {
+ return $this->toBase()->{$key};
+ }
+
throw new Exception("Property [{$key}] does not exist on the Eloquent builder instance.");
}
@@ -1325,25 +2216,27 @@ public function __call($method, $parameters)
return;
}
- if (isset($this->localMacros[$method])) {
+ if ($this->hasMacro($method)) {
array_unshift($parameters, $this);
return $this->localMacros[$method](...$parameters);
}
- if (isset(static::$macros[$method])) {
- if (static::$macros[$method] instanceof Closure) {
- return call_user_func_array(static::$macros[$method]->bindTo($this, static::class), $parameters);
+ if (static::hasGlobalMacro($method)) {
+ $callable = static::$macros[$method];
+
+ if ($callable instanceof Closure) {
+ $callable = $callable->bindTo($this, static::class);
}
- return call_user_func_array(static::$macros[$method], $parameters);
+ return $callable(...$parameters);
}
- if (method_exists($this->model, $scope = 'scope'.ucfirst($method))) {
- return $this->callScope([$this->model, $scope], $parameters);
+ if ($this->hasNamedScope($method)) {
+ return $this->callNamedScope($method, $parameters);
}
- if (in_array($method, $this->passthru)) {
+ if (in_array(strtolower($method), $this->passthru)) {
return $this->toBase()->{$method}(...$parameters);
}
@@ -1369,15 +2262,64 @@ public static function __callStatic($method, $parameters)
return;
}
- if (! isset(static::$macros[$method])) {
+ if ($method === 'mixin') {
+ return static::registerMixin($parameters[0], $parameters[1] ?? true);
+ }
+
+ if (! static::hasGlobalMacro($method)) {
static::throwBadMethodCallException($method);
}
- if (static::$macros[$method] instanceof Closure) {
- return call_user_func_array(Closure::bind(static::$macros[$method], null, static::class), $parameters);
+ $callable = static::$macros[$method];
+
+ if ($callable instanceof Closure) {
+ $callable = $callable->bindTo(null, static::class);
}
- return call_user_func_array(static::$macros[$method], $parameters);
+ return $callable(...$parameters);
+ }
+
+ /**
+ * Register the given mixin with the builder.
+ *
+ * @param string $mixin
+ * @param bool $replace
+ * @return void
+ */
+ protected static function registerMixin($mixin, $replace)
+ {
+ $methods = (new ReflectionClass($mixin))->getMethods(
+ ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED
+ );
+
+ foreach ($methods as $method) {
+ if ($replace || ! static::hasGlobalMacro($method->name)) {
+ static::macro($method->name, $method->invoke($mixin));
+ }
+ }
+ }
+
+ /**
+ * Clone the Eloquent query builder.
+ *
+ * @return static
+ */
+ public function clone()
+ {
+ return clone $this;
+ }
+
+ /**
+ * Register a closure to be invoked on a clone.
+ *
+ * @param \Closure $callback
+ * @return $this
+ */
+ public function onClone(Closure $callback)
+ {
+ $this->onCloneCallbacks[] = $callback;
+
+ return $this;
}
/**
@@ -1388,5 +2330,9 @@ public static function __callStatic($method, $parameters)
public function __clone()
{
$this->query = clone $this->query;
+
+ foreach ($this->onCloneCallbacks as $onCloneCallback) {
+ $onCloneCallback($this);
+ }
}
}
diff --git a/Eloquent/Casts/ArrayObject.php b/Eloquent/Casts/ArrayObject.php
new file mode 100644
index 0000000000..563545dacb
--- /dev/null
+++ b/Eloquent/Casts/ArrayObject.php
@@ -0,0 +1,47 @@
+
+ */
+class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable
+{
+ /**
+ * Get a collection containing the underlying array.
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ public function collect()
+ {
+ return new Collection($this->getArrayCopy());
+ }
+
+ /**
+ * Get the instance as an array.
+ *
+ * @return array
+ */
+ public function toArray()
+ {
+ return $this->getArrayCopy();
+ }
+
+ /**
+ * Get the array that should be JSON serialized.
+ *
+ * @return array
+ */
+ public function jsonSerialize(): array
+ {
+ return $this->getArrayCopy();
+ }
+}
diff --git a/Eloquent/Casts/AsArrayObject.php b/Eloquent/Casts/AsArrayObject.php
new file mode 100644
index 0000000000..5ee80d0bb4
--- /dev/null
+++ b/Eloquent/Casts/AsArrayObject.php
@@ -0,0 +1,42 @@
+, iterable>
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ if (! isset($attributes[$key])) {
+ return;
+ }
+
+ $data = Json::decode($attributes[$key]);
+
+ return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return [$key => Json::encode($value)];
+ }
+
+ public function serialize($model, string $key, $value, array $attributes)
+ {
+ return $value->getArrayCopy();
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/AsBinary.php b/Eloquent/Casts/AsBinary.php
new file mode 100644
index 0000000000..4f63154777
--- /dev/null
+++ b/Eloquent/Casts/AsBinary.php
@@ -0,0 +1,75 @@
+format = $this->arguments[0]
+ ?? throw new InvalidArgumentException('The binary codec format is required.');
+
+ if (! in_array($this->format, BinaryCodec::formats(), true)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Unsupported binary codec format [%s]. Allowed formats are: %s.',
+ $this->format,
+ implode(', ', BinaryCodec::formats()),
+ ));
+ }
+ }
+
+ public function get($model, $key, $value, $attributes)
+ {
+ return BinaryCodec::decode($attributes[$key] ?? null, $this->format);
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return [$key => BinaryCodec::encode($value, $this->format)];
+ }
+ };
+ }
+
+ /**
+ * Encode / decode values as binary UUIDs.
+ */
+ public static function uuid(): string
+ {
+ return self::class.':uuid';
+ }
+
+ /**
+ * Encode / decode values as binary ULIDs.
+ */
+ public static function ulid(): string
+ {
+ return self::class.':ulid';
+ }
+
+ /**
+ * Encode / decode values using the given format.
+ */
+ public static function of(string $format): string
+ {
+ return self::class.':'.$format;
+ }
+}
diff --git a/Eloquent/Casts/AsCollection.php b/Eloquent/Casts/AsCollection.php
new file mode 100644
index 0000000000..c6b0ffe4b7
--- /dev/null
+++ b/Eloquent/Casts/AsCollection.php
@@ -0,0 +1,96 @@
+, iterable>
+ *
+ * @throws \InvalidArgumentException
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class($arguments) implements CastsAttributes
+ {
+ public function __construct(protected array $arguments)
+ {
+ $this->arguments = array_pad(array_values($this->arguments), 2, '');
+ }
+
+ public function get($model, $key, $value, $attributes)
+ {
+ if (! isset($attributes[$key])) {
+ return;
+ }
+
+ $data = Json::decode($attributes[$key]);
+
+ $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0];
+
+ if (! is_a($collectionClass, Collection::class, true)) {
+ throw new InvalidArgumentException('The provided class must extend ['.Collection::class.'].');
+ }
+
+ if (! is_array($data)) {
+ return null;
+ }
+
+ $instance = new $collectionClass($data);
+
+ if (! isset($this->arguments[1]) || ! $this->arguments[1]) {
+ return $instance;
+ }
+
+ if (is_string($this->arguments[1])) {
+ $this->arguments[1] = Str::parseCallback($this->arguments[1]);
+ }
+
+ return is_callable($this->arguments[1])
+ ? $instance->map($this->arguments[1])
+ : $instance->mapInto($this->arguments[1][0]);
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return [$key => Json::encode($value)];
+ }
+ };
+ }
+
+ /**
+ * Specify the type of object each item in the collection should be mapped to.
+ *
+ * @param array{class-string, string}|class-string $map
+ * @return string
+ */
+ public static function of($map)
+ {
+ return static::using('', $map);
+ }
+
+ /**
+ * Specify the collection type for the cast.
+ *
+ * @param class-string $class
+ * @param array{class-string, string}|class-string|null $map
+ * @return string
+ */
+ public static function using($class, $map = null)
+ {
+ if (is_array($map) && is_callable($map)) {
+ $map = $map[0].'@'.$map[1];
+ }
+
+ return static::class.':'.implode(',', [$class, $map]);
+ }
+}
diff --git a/Eloquent/Casts/AsEncryptedArrayObject.php b/Eloquent/Casts/AsEncryptedArrayObject.php
new file mode 100644
index 0000000000..2122a7b1f9
--- /dev/null
+++ b/Eloquent/Casts/AsEncryptedArrayObject.php
@@ -0,0 +1,45 @@
+, iterable>
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ if (isset($attributes[$key])) {
+ return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key])), ArrayObject::ARRAY_AS_PROPS);
+ }
+
+ return null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ if (! is_null($value)) {
+ return [$key => Crypt::encryptString(Json::encode($value))];
+ }
+
+ return null;
+ }
+
+ public function serialize($model, string $key, $value, array $attributes)
+ {
+ return ! is_null($value) ? $value->getArrayCopy() : null;
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/AsEncryptedCollection.php b/Eloquent/Casts/AsEncryptedCollection.php
new file mode 100644
index 0000000000..a9078c2b38
--- /dev/null
+++ b/Eloquent/Casts/AsEncryptedCollection.php
@@ -0,0 +1,95 @@
+, iterable>
+ *
+ * @throws \InvalidArgumentException
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class($arguments) implements CastsAttributes
+ {
+ public function __construct(protected array $arguments)
+ {
+ $this->arguments = array_pad(array_values($this->arguments), 2, '');
+ }
+
+ public function get($model, $key, $value, $attributes)
+ {
+ $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0];
+
+ if (! is_a($collectionClass, Collection::class, true)) {
+ throw new InvalidArgumentException('The provided class must extend ['.Collection::class.'].');
+ }
+
+ if (! isset($attributes[$key])) {
+ return null;
+ }
+
+ $instance = new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key])));
+
+ if (! isset($this->arguments[1]) || ! $this->arguments[1]) {
+ return $instance;
+ }
+
+ if (is_string($this->arguments[1])) {
+ $this->arguments[1] = Str::parseCallback($this->arguments[1]);
+ }
+
+ return is_callable($this->arguments[1])
+ ? $instance->map($this->arguments[1])
+ : $instance->mapInto($this->arguments[1][0]);
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ if (! is_null($value)) {
+ return [$key => Crypt::encryptString(Json::encode($value))];
+ }
+
+ return null;
+ }
+ };
+ }
+
+ /**
+ * Specify the type of object each item in the collection should be mapped to.
+ *
+ * @param array{class-string, string}|class-string $map
+ * @return string
+ */
+ public static function of($map)
+ {
+ return static::using('', $map);
+ }
+
+ /**
+ * Specify the collection for the cast.
+ *
+ * @param class-string $class
+ * @param array{class-string, string}|class-string|null $map
+ * @return string
+ */
+ public static function using($class, $map = null)
+ {
+ if (is_array($map) && is_callable($map)) {
+ $map = $map[0].'@'.$map[1];
+ }
+
+ return static::class.':'.implode(',', [$class, $map]);
+ }
+}
diff --git a/Eloquent/Casts/AsEnumArrayObject.php b/Eloquent/Casts/AsEnumArrayObject.php
new file mode 100644
index 0000000000..061dcbf57e
--- /dev/null
+++ b/Eloquent/Casts/AsEnumArrayObject.php
@@ -0,0 +1,97 @@
+} $arguments
+ * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Casts\ArrayObject, iterable>
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class($arguments) implements CastsAttributes
+ {
+ protected $arguments;
+
+ public function __construct(array $arguments)
+ {
+ $this->arguments = $arguments;
+ }
+
+ public function get($model, $key, $value, $attributes)
+ {
+ if (! isset($attributes[$key])) {
+ return;
+ }
+
+ $data = Json::decode($attributes[$key]);
+
+ if (! is_array($data)) {
+ return;
+ }
+
+ $enumClass = $this->arguments[0];
+
+ return new ArrayObject((new Collection($data))->map(function ($value) use ($enumClass) {
+ return is_subclass_of($enumClass, BackedEnum::class)
+ ? $enumClass::from($value)
+ : constant($enumClass.'::'.$value);
+ })->toArray());
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ if ($value === null) {
+ return [$key => null];
+ }
+
+ $storable = [];
+
+ foreach ($value as $enum) {
+ $storable[] = $this->getStorableEnumValue($enum);
+ }
+
+ return [$key => Json::encode($storable)];
+ }
+
+ public function serialize($model, string $key, $value, array $attributes)
+ {
+ return (new Collection($value->getArrayCopy()))
+ ->map(fn ($enum) => $this->getStorableEnumValue($enum))
+ ->toArray();
+ }
+
+ protected function getStorableEnumValue($enum)
+ {
+ if (is_string($enum) || is_int($enum)) {
+ return $enum;
+ }
+
+ return enum_value($enum);
+ }
+ };
+ }
+
+ /**
+ * Specify the Enum for the cast.
+ *
+ * @param class-string $class
+ * @return string
+ */
+ public static function of($class)
+ {
+ return static::class.':'.$class;
+ }
+}
diff --git a/Eloquent/Casts/AsEnumCollection.php b/Eloquent/Casts/AsEnumCollection.php
new file mode 100644
index 0000000000..fa7116a0d0
--- /dev/null
+++ b/Eloquent/Casts/AsEnumCollection.php
@@ -0,0 +1,93 @@
+} $arguments
+ * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection, iterable>
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class($arguments) implements CastsAttributes
+ {
+ protected $arguments;
+
+ public function __construct(array $arguments)
+ {
+ $this->arguments = $arguments;
+ }
+
+ public function get($model, $key, $value, $attributes)
+ {
+ if (! isset($attributes[$key])) {
+ return;
+ }
+
+ $data = Json::decode($attributes[$key]);
+
+ if (! is_array($data)) {
+ return;
+ }
+
+ $enumClass = $this->arguments[0];
+
+ return (new Collection($data))->map(function ($value) use ($enumClass) {
+ return is_subclass_of($enumClass, BackedEnum::class)
+ ? $enumClass::from($value)
+ : constant($enumClass.'::'.$value);
+ });
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ $value = $value !== null
+ ? Json::encode((new Collection($value))->map(function ($enum) {
+ return $this->getStorableEnumValue($enum);
+ })->jsonSerialize())
+ : null;
+
+ return [$key => $value];
+ }
+
+ public function serialize($model, string $key, $value, array $attributes)
+ {
+ return (new Collection($value))
+ ->map(fn ($enum) => $this->getStorableEnumValue($enum))
+ ->toArray();
+ }
+
+ protected function getStorableEnumValue($enum)
+ {
+ if (is_string($enum) || is_int($enum)) {
+ return $enum;
+ }
+
+ return enum_value($enum);
+ }
+ };
+ }
+
+ /**
+ * Specify the Enum for the cast.
+ *
+ * @param class-string $class
+ * @return string
+ */
+ public static function of($class)
+ {
+ return static::class.':'.$class;
+ }
+}
diff --git a/Eloquent/Casts/AsFluent.php b/Eloquent/Casts/AsFluent.php
new file mode 100644
index 0000000000..bba1b1dac9
--- /dev/null
+++ b/Eloquent/Casts/AsFluent.php
@@ -0,0 +1,32 @@
+
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ return isset($value) ? new Fluent(Json::decode($value)) : null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return isset($value) ? [$key => Json::encode($value)] : null;
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/AsHtmlString.php b/Eloquent/Casts/AsHtmlString.php
new file mode 100644
index 0000000000..d4182d258f
--- /dev/null
+++ b/Eloquent/Casts/AsHtmlString.php
@@ -0,0 +1,32 @@
+
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ return isset($value) ? new HtmlString($value) : null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return isset($value) ? (string) $value : null;
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/AsStringable.php b/Eloquent/Casts/AsStringable.php
new file mode 100644
index 0000000000..4f6c787c85
--- /dev/null
+++ b/Eloquent/Casts/AsStringable.php
@@ -0,0 +1,32 @@
+
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ return isset($value) ? new Stringable($value) : null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return isset($value) ? (string) $value : null;
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/AsUri.php b/Eloquent/Casts/AsUri.php
new file mode 100644
index 0000000000..d55c6d7996
--- /dev/null
+++ b/Eloquent/Casts/AsUri.php
@@ -0,0 +1,32 @@
+
+ */
+ public static function castUsing(array $arguments)
+ {
+ return new class implements CastsAttributes
+ {
+ public function get($model, $key, $value, $attributes)
+ {
+ return isset($value) ? new Uri($value) : null;
+ }
+
+ public function set($model, $key, $value, $attributes)
+ {
+ return isset($value) ? (string) $value : null;
+ }
+ };
+ }
+}
diff --git a/Eloquent/Casts/Attribute.php b/Eloquent/Casts/Attribute.php
new file mode 100644
index 0000000000..26d13ba3fb
--- /dev/null
+++ b/Eloquent/Casts/Attribute.php
@@ -0,0 +1,104 @@
+get = $get;
+ $this->set = $set;
+ }
+
+ /**
+ * Create a new attribute accessor / mutator.
+ *
+ * @param callable|null $get
+ * @param callable|null $set
+ * @return static
+ */
+ public static function make(?callable $get = null, ?callable $set = null): static
+ {
+ return new static($get, $set);
+ }
+
+ /**
+ * Create a new attribute accessor.
+ *
+ * @param callable $get
+ * @return static
+ */
+ public static function get(callable $get)
+ {
+ return new static($get);
+ }
+
+ /**
+ * Create a new attribute mutator.
+ *
+ * @param callable $set
+ * @return static
+ */
+ public static function set(callable $set)
+ {
+ return new static(null, $set);
+ }
+
+ /**
+ * Disable object caching for the attribute.
+ *
+ * @return static
+ */
+ public function withoutObjectCaching()
+ {
+ $this->withObjectCaching = false;
+
+ return $this;
+ }
+
+ /**
+ * Enable caching for the attribute.
+ *
+ * @return static
+ */
+ public function shouldCache()
+ {
+ $this->withCaching = true;
+
+ return $this;
+ }
+}
diff --git a/Eloquent/Casts/Json.php b/Eloquent/Casts/Json.php
new file mode 100644
index 0000000000..783d5b9986
--- /dev/null
+++ b/Eloquent/Casts/Json.php
@@ -0,0 +1,56 @@
+
+ */
class Collection extends BaseCollection implements QueueableCollection
{
+ use InteractsWithDictionary;
+
/**
* Find a model in the collection by key.
*
+ * @template TFindDefault
+ *
* @param mixed $key
- * @param mixed $default
- * @return \Illuminate\Database\Eloquent\Model|static
+ * @param TFindDefault $default
+ * @return ($key is (\Illuminate\Contracts\Support\Arrayable|array) ? static : TModel|TFindDefault)
*/
public function find($key, $default = null)
{
@@ -37,15 +47,44 @@ public function find($key, $default = null)
return $this->whereIn($this->first()->getKeyName(), $key);
}
- return Arr::first($this->items, function ($model) use ($key) {
- return $model->getKey() == $key;
- }, $default);
+ return Arr::first($this->items, fn ($model) => $model->getKey() == $key, $default);
+ }
+
+ /**
+ * Find a model in the collection by key or throw an exception.
+ *
+ * @param mixed $key
+ * @return TModel
+ *
+ * @throws \Illuminate\Database\Eloquent\ModelNotFoundException
+ */
+ public function findOrFail($key)
+ {
+ $result = $this->find($key);
+
+ if (is_array($key) && count($result) === count(array_unique($key))) {
+ return $result;
+ } elseif (! is_array($key) && ! is_null($result)) {
+ return $result;
+ }
+
+ $exception = new ModelNotFoundException;
+
+ if (! $model = head($this->items)) {
+ throw $exception;
+ }
+
+ $ids = is_array($key) ? array_diff($key, $result->modelKeys()) : $key;
+
+ $exception->setModel(get_class($model), $ids);
+
+ throw $exception;
}
/**
* Load a set of relationships onto the collection.
*
- * @param array|string $relations
+ * @param array): mixed)|string>|string $relations
* @return $this
*/
public function load($relations)
@@ -64,12 +103,14 @@ public function load($relations)
}
/**
- * Load a set of relationship counts onto the collection.
+ * Load a set of aggregations over relationship's column onto the collection.
*
- * @param array|string $relations
+ * @param array): mixed)|string>|string $relations
+ * @param string $column
+ * @param string|null $function
* @return $this
*/
- public function loadCount($relations)
+ public function loadAggregate($relations, $column, $function = null)
{
if ($this->isEmpty()) {
return $this;
@@ -78,27 +119,100 @@ public function loadCount($relations)
$models = $this->first()->newModelQuery()
->whereKey($this->modelKeys())
->select($this->first()->getKeyName())
- ->withCount(...func_get_args())
- ->get();
+ ->withAggregate($relations, $column, $function)
+ ->get()
+ ->keyBy($this->first()->getKeyName());
$attributes = Arr::except(
array_keys($models->first()->getAttributes()),
$models->first()->getKeyName()
);
- $models->each(function ($model) use ($attributes) {
- $this->find($model->getKey())->forceFill(
- Arr::only($model->getAttributes(), $attributes)
- )->syncOriginalAttributes($attributes);
+ $this->each(function ($model) use ($models, $attributes) {
+ $extraAttributes = Arr::only($models->get($model->getKey())->getAttributes(), $attributes);
+
+ $model->forceFill($extraAttributes)
+ ->syncOriginalAttributes($attributes)
+ ->mergeCasts($models->get($model->getKey())->getCasts());
});
return $this;
}
+ /**
+ * Load a set of relationship counts onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @return $this
+ */
+ public function loadCount($relations)
+ {
+ return $this->loadAggregate($relations, '*', 'count');
+ }
+
+ /**
+ * Load a set of relationship's max column values onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @param string $column
+ * @return $this
+ */
+ public function loadMax($relations, $column)
+ {
+ return $this->loadAggregate($relations, $column, 'max');
+ }
+
+ /**
+ * Load a set of relationship's min column values onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @param string $column
+ * @return $this
+ */
+ public function loadMin($relations, $column)
+ {
+ return $this->loadAggregate($relations, $column, 'min');
+ }
+
+ /**
+ * Load a set of relationship's column summations onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @param string $column
+ * @return $this
+ */
+ public function loadSum($relations, $column)
+ {
+ return $this->loadAggregate($relations, $column, 'sum');
+ }
+
+ /**
+ * Load a set of relationship's average column values onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @param string $column
+ * @return $this
+ */
+ public function loadAvg($relations, $column)
+ {
+ return $this->loadAggregate($relations, $column, 'avg');
+ }
+
+ /**
+ * Load a set of related existences onto the collection.
+ *
+ * @param array): mixed)|string>|string $relations
+ * @return $this
+ */
+ public function loadExists($relations)
+ {
+ return $this->loadAggregate($relations, '*', 'exists');
+ }
+
/**
* Load a set of relationships onto the collection if they are not already eager loaded.
*
- * @param array|string $relations
+ * @param array): mixed)|string>|string $relations
* @return $this
*/
public function loadMissing($relations)
@@ -107,41 +221,70 @@ public function loadMissing($relations)
$relations = func_get_args();
}
- foreach ($relations as $key => $value) {
- if (is_numeric($key)) {
- $key = $value;
- }
+ if ($this->isNotEmpty()) {
+ $query = $this->first()->newQueryWithoutRelationships()->with($relations);
- $segments = explode('.', explode(':', $key)[0]);
+ foreach ($query->getEagerLoads() as $key => $value) {
+ $segments = explode('.', explode(':', $key)[0]);
- if (Str::contains($key, ':')) {
- $segments[count($segments) - 1] .= ':'.explode(':', $key)[1];
- }
+ if (str_contains($key, ':')) {
+ $segments[count($segments) - 1] .= ':'.explode(':', $key)[1];
+ }
- $path = [];
+ $path = [];
- foreach ($segments as $segment) {
- $path[] = [$segment => $segment];
- }
+ foreach ($segments as $segment) {
+ $path[] = [$segment => $segment];
+ }
- if (is_callable($value)) {
- $path[count($segments) - 1][end($segments)] = $value;
- }
+ if (is_callable($value)) {
+ $path[count($segments) - 1][array_last($segments)] = $value;
+ }
- $this->loadMissingRelation($this, $path);
+ $this->loadMissingRelation($this, $path);
+ }
}
return $this;
}
+ /**
+ * Load a relationship path for models of the given type if it is not already eager loaded.
+ *
+ * @param array> $tuples
+ * @return void
+ */
+ public function loadMissingRelationshipChain(array $tuples)
+ {
+ [$relation, $class] = array_shift($tuples);
+
+ $this->filter(function ($model) use ($relation, $class) {
+ return ! is_null($model) &&
+ ! $model->relationLoaded($relation) &&
+ $model::class === $class;
+ })->load($relation);
+
+ if (empty($tuples)) {
+ return;
+ }
+
+ $models = $this->pluck($relation)->whereNotNull();
+
+ if ($models->first() instanceof BaseCollection) {
+ $models = $models->collapse();
+ }
+
+ (new static($models))->loadMissingRelationshipChain($tuples);
+ }
+
/**
* Load a relationship path if it is not already eager loaded.
*
- * @param \Illuminate\Database\Eloquent\Collection $models
+ * @param \Illuminate\Database\Eloquent\Collection $models
* @param array $path
* @return void
*/
- protected function loadMissingRelation(Collection $models, array $path)
+ protected function loadMissingRelation(self $models, array $path)
{
$relation = array_shift($path);
@@ -151,15 +294,13 @@ protected function loadMissingRelation(Collection $models, array $path)
$relation = reset($relation);
}
- $models->filter(function ($model) use ($name) {
- return ! is_null($model) && ! $model->relationLoaded($name);
- })->load($relation);
+ $models->filter(fn ($model) => ! is_null($model) && ! $model->relationLoaded($name))->load($relation);
if (empty($path)) {
return;
}
- $models = $models->pluck($name);
+ $models = $models->pluck($name)->filter();
if ($models->first() instanceof BaseCollection) {
$models = $models->collapse();
@@ -172,19 +313,32 @@ protected function loadMissingRelation(Collection $models, array $path)
* Load a set of relationships onto the mixed relationship collection.
*
* @param string $relation
- * @param array $relations
+ * @param array): mixed)|string> $relations
* @return $this
*/
public function loadMorph($relation, $relations)
{
$this->pluck($relation)
->filter()
- ->groupBy(function ($model) {
- return get_class($model);
- })
- ->each(function ($models, $className) use ($relations) {
- static::make($models)->load($relations[$className] ?? []);
- });
+ ->groupBy(fn ($model) => get_class($model))
+ ->each(fn ($models, $className) => static::make($models)->load($relations[$className] ?? []));
+
+ return $this;
+ }
+
+ /**
+ * Load a set of relationship counts onto the mixed relationship collection.
+ *
+ * @param string $relation
+ * @param array): mixed)|string> $relations
+ * @return $this
+ */
+ public function loadMorphCount($relation, $relations)
+ {
+ $this->pluck($relation)
+ ->filter()
+ ->groupBy(fn ($model) => get_class($model))
+ ->each(fn ($models, $className) => static::make($models)->loadCount($relations[$className] ?? []));
return $this;
}
@@ -192,7 +346,7 @@ public function loadMorph($relation, $relations)
/**
* Determine if a key exists in the collection.
*
- * @param mixed $key
+ * @param (callable(TModel, TKey): bool)|TModel|string|int $key
* @param mixed $operator
* @param mixed $value
* @return bool
@@ -204,32 +358,39 @@ public function contains($key, $operator = null, $value = null)
}
if ($key instanceof Model) {
- return parent::contains(function ($model) use ($key) {
- return $model->is($key);
- });
+ return parent::contains(fn ($model) => $model->is($key));
}
- return parent::contains(function ($model) use ($key) {
- return $model->getKey() == $key;
- });
+ return parent::contains(fn ($model) => $model->getKey() == $key);
+ }
+
+ /**
+ * Determine if a key does not exist in the collection.
+ *
+ * @param (callable(TModel, TKey): bool)|TModel|string|int $key
+ * @param mixed $operator
+ * @param mixed $value
+ * @return bool
+ */
+ public function doesntContain($key, $operator = null, $value = null)
+ {
+ return ! $this->contains(...func_get_args());
}
/**
* Get the array of primary keys.
*
- * @return array
+ * @return array
*/
public function modelKeys()
{
- return array_map(function ($model) {
- return $model->getKey();
- }, $this->items);
+ return array_map(fn ($model) => $model->getKey(), $this->items);
}
/**
* Merge the collection with the given items.
*
- * @param \ArrayAccess|array $items
+ * @param iterable $items
* @return static
*/
public function merge($items)
@@ -237,7 +398,7 @@ public function merge($items)
$dictionary = $this->getDictionary();
foreach ($items as $item) {
- $dictionary[$item->getKey()] = $item;
+ $dictionary[$this->getDictionaryKey($item->getKey())] = $item;
}
return new static(array_values($dictionary));
@@ -246,22 +407,40 @@ public function merge($items)
/**
* Run a map over each of the items.
*
- * @param callable $callback
- * @return \Illuminate\Support\Collection|static
+ * @template TMapValue
+ *
+ * @param callable(TModel, TKey): TMapValue $callback
+ * @return \Illuminate\Support\Collection|static
*/
public function map(callable $callback)
{
$result = parent::map($callback);
- return $result->contains(function ($item) {
- return ! $item instanceof Model;
- }) ? $result->toBase() : $result;
+ return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result;
+ }
+
+ /**
+ * Run an associative map over each of the items.
+ *
+ * The callback should return an associative array with a single key / value pair.
+ *
+ * @template TMapWithKeysKey of array-key
+ * @template TMapWithKeysValue
+ *
+ * @param callable(TModel, TKey): array $callback
+ * @return \Illuminate\Support\Collection|static
+ */
+ public function mapWithKeys(callable $callback)
+ {
+ $result = parent::mapWithKeys($callback);
+
+ return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result;
}
/**
* Reload a fresh model instance from the database for all the entities.
*
- * @param array|string $with
+ * @param array|string $with
* @return static
*/
public function fresh($with = [])
@@ -278,16 +457,14 @@ public function fresh($with = [])
->get()
->getDictionary();
- return $this->map(function ($model) use ($freshModels) {
- return $model->exists && isset($freshModels[$model->getKey()])
- ? $freshModels[$model->getKey()] : null;
- });
+ return $this->filter(fn ($model) => $model->exists && isset($freshModels[$model->getKey()]))
+ ->map(fn ($model) => $freshModels[$model->getKey()]);
}
/**
* Diff the collection with the given items.
*
- * @param \ArrayAccess|array $items
+ * @param iterable $items
* @return static
*/
public function diff($items)
@@ -297,7 +474,7 @@ public function diff($items)
$dictionary = $this->getDictionary($items);
foreach ($this->items as $item) {
- if (! isset($dictionary[$item->getKey()])) {
+ if (! isset($dictionary[$this->getDictionaryKey($item->getKey())])) {
$diff->add($item);
}
}
@@ -308,17 +485,21 @@ public function diff($items)
/**
* Intersect the collection with the given items.
*
- * @param \ArrayAccess|array $items
+ * @param iterable $items
* @return static
*/
public function intersect($items)
{
$intersect = new static;
+ if (empty($items)) {
+ return $intersect;
+ }
+
$dictionary = $this->getDictionary($items);
foreach ($this->items as $item) {
- if (isset($dictionary[$item->getKey()])) {
+ if (isset($dictionary[$this->getDictionaryKey($item->getKey())])) {
$intersect->add($item);
}
}
@@ -329,9 +510,9 @@ public function intersect($items)
/**
* Return only unique items from the collection.
*
- * @param string|callable|null $key
+ * @param (callable(TModel, TKey): mixed)|string|null $key
* @param bool $strict
- * @return static|\Illuminate\Support\Collection
+ * @return static
*/
public function unique($key = null, $strict = false)
{
@@ -345,7 +526,7 @@ public function unique($key = null, $strict = false)
/**
* Returns only the models from the collection with the specified keys.
*
- * @param mixed $keys
+ * @param array|null $keys
* @return static
*/
public function only($keys)
@@ -354,7 +535,7 @@ public function only($keys)
return new static($this->items);
}
- $dictionary = Arr::only($this->getDictionary(), $keys);
+ $dictionary = Arr::only($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys));
return new static(array_values($dictionary));
}
@@ -362,12 +543,16 @@ public function only($keys)
/**
* Returns all models in the collection except the models with specified keys.
*
- * @param mixed $keys
+ * @param array|null $keys
* @return static
*/
public function except($keys)
{
- $dictionary = Arr::except($this->getDictionary(), $keys);
+ if (is_null($keys)) {
+ return new static($this->items);
+ }
+
+ $dictionary = Arr::except($this->getDictionary(), array_map($this->getDictionaryKey(...), (array) $keys));
return new static(array_values($dictionary));
}
@@ -375,18 +560,40 @@ public function except($keys)
/**
* Make the given, typically visible, attributes hidden across the entire collection.
*
- * @param array|string $attributes
+ * @param array|string $attributes
* @return $this
*/
public function makeHidden($attributes)
{
- return $this->each->addHidden($attributes);
+ return $this->each->makeHidden($attributes);
+ }
+
+ /**
+ * Merge the given, typically visible, attributes hidden across the entire collection.
+ *
+ * @param array|string $attributes
+ * @return $this
+ */
+ public function mergeHidden($attributes)
+ {
+ return $this->each->mergeHidden($attributes);
+ }
+
+ /**
+ * Set the hidden attributes across the entire collection.
+ *
+ * @param array $hidden
+ * @return $this
+ */
+ public function setHidden($hidden)
+ {
+ return $this->each->setHidden($hidden);
}
/**
* Make the given, typically hidden, attributes visible across the entire collection.
*
- * @param array|string $attributes
+ * @param array|string $attributes
* @return $this
*/
public function makeVisible($attributes)
@@ -394,11 +601,65 @@ public function makeVisible($attributes)
return $this->each->makeVisible($attributes);
}
+ /**
+ * Merge the given, typically hidden, attributes visible across the entire collection.
+ *
+ * @param array|string $attributes
+ * @return $this
+ */
+ public function mergeVisible($attributes)
+ {
+ return $this->each->mergeVisible($attributes);
+ }
+
+ /**
+ * Set the visible attributes across the entire collection.
+ *
+ * @param array $visible
+ * @return $this
+ */
+ public function setVisible($visible)
+ {
+ return $this->each->setVisible($visible);
+ }
+
+ /**
+ * Append an attribute across the entire collection.
+ *
+ * @param array|string $attributes
+ * @return $this
+ */
+ public function append($attributes)
+ {
+ return $this->each->append($attributes);
+ }
+
+ /**
+ * Sets the appends on every element of the collection, overwriting the existing appends for each.
+ *
+ * @param array $appends
+ * @return $this
+ */
+ public function setAppends(array $appends)
+ {
+ return $this->each->setAppends($appends);
+ }
+
+ /**
+ * Remove appended properties from every element in the collection.
+ *
+ * @return $this
+ */
+ public function withoutAppends()
+ {
+ return $this->setAppends([]);
+ }
+
/**
* Get a dictionary keyed by primary keys.
*
- * @param \ArrayAccess|array|null $items
- * @return array
+ * @param iterable|null $items
+ * @return array
*/
public function getDictionary($items = null)
{
@@ -407,7 +668,7 @@ public function getDictionary($items = null)
$dictionary = [];
foreach ($items as $value) {
- $dictionary[$value->getKey()] = $value;
+ $dictionary[$this->getDictionaryKey($value->getKey())] = $value;
}
return $dictionary;
@@ -418,79 +679,134 @@ public function getDictionary($items = null)
*/
/**
- * Get an array with the values of a given key.
+ * {@inheritDoc}
*
- * @param string $value
- * @param string|null $key
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection
*/
- public function pluck($value, $key = null)
+ #[\Override]
+ public function countBy($countBy = null)
{
- return $this->toBase()->pluck($value, $key);
+ return $this->toBase()->countBy($countBy);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ #[\Override]
+ public function collapse()
+ {
+ return $this->toBase()->collapse();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ #[\Override]
+ public function flatten($depth = INF)
+ {
+ return $this->toBase()->flatten($depth);
}
/**
- * Get the keys of the collection items.
+ * {@inheritDoc}
*
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection
*/
+ #[\Override]
+ public function flip()
+ {
+ return $this->toBase()->flip();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return \Illuminate\Support\Collection
+ */
+ #[\Override]
public function keys()
{
return $this->toBase()->keys();
}
/**
- * Zip the collection together with one or more arrays.
+ * {@inheritDoc}
+ *
+ * @template TPadValue
*
- * @param mixed ...$items
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection
*/
- public function zip($items)
+ #[\Override]
+ public function pad($size, $value)
{
- return call_user_func_array([$this->toBase(), 'zip'], func_get_args());
+ return $this->toBase()->pad($size, $value);
}
/**
- * Collapse the collection of items into a single array.
+ * {@inheritDoc}
*
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection, static>
*/
- public function collapse()
+ #[\Override]
+ public function partition($key, $operator = null, $value = null)
{
- return $this->toBase()->collapse();
+ return parent::partition(...func_get_args())->toBase();
}
/**
- * Get a flattened array of the items in the collection.
+ * {@inheritDoc}
*
- * @param int $depth
- * @return \Illuminate\Support\Collection
+ * @return \Illuminate\Support\Collection
*/
- public function flatten($depth = INF)
+ #[\Override]
+ public function pluck($value, $key = null)
{
- return $this->toBase()->flatten($depth);
+ return $this->toBase()->pluck($value, $key);
}
/**
- * Flip the items in the collection.
+ * {@inheritDoc}
*
- * @return \Illuminate\Support\Collection
+ * @template TZipValue
+ *
+ * @return \Illuminate\Support\Collection>
*/
- public function flip()
+ #[\Override]
+ public function zip($items)
{
- return $this->toBase()->flip();
+ return $this->toBase()->zip(...func_get_args());
}
/**
- * Pad collection to the specified length with a value.
+ * Get the comparison function to detect duplicates.
*
- * @param int $size
- * @param mixed $value
- * @return \Illuminate\Support\Collection
+ * @return callable(TModel, TModel): bool
*/
- public function pad($size, $value)
+ protected function duplicateComparator($strict)
{
- return $this->toBase()->pad($size, $value);
+ return fn ($a, $b) => $a->is($b);
+ }
+
+ /**
+ * Enable relationship autoloading for all models in this collection.
+ *
+ * @return $this
+ */
+ public function withRelationshipAutoloading()
+ {
+ $callback = fn ($tuples) => $this->loadMissingRelationshipChain($tuples);
+
+ foreach ($this as $model) {
+ if (! $model->hasRelationAutoloadCallback()) {
+ $model->autoloadRelationsUsing($callback, $this);
+ }
+ }
+
+ return $this;
}
/**
@@ -506,10 +822,10 @@ public function getQueueableClass()
return;
}
- $class = get_class($this->first());
+ $class = $this->getQueueableModelClass($this->first());
$this->each(function ($model) use ($class) {
- if (get_class($model) !== $class) {
+ if ($this->getQueueableModelClass($model) !== $class) {
throw new LogicException('Queueing collections with multiple model types is not supported.');
}
});
@@ -517,10 +833,23 @@ public function getQueueableClass()
return $class;
}
+ /**
+ * Get the queueable class name for the given model.
+ *
+ * @param \Illuminate\Database\Eloquent\Model $model
+ * @return string
+ */
+ protected function getQueueableModelClass($model)
+ {
+ return method_exists($model, 'getQueueableClassName')
+ ? $model->getQueueableClassName()
+ : get_class($model);
+ }
+
/**
* Get the identifiers for all of the entities.
*
- * @return array
+ * @return array
*/
public function getQueueableIds()
{
@@ -529,18 +858,30 @@ public function getQueueableIds()
}
return $this->first() instanceof QueueableEntity
- ? $this->map->getQueueableId()->all()
- : $this->modelKeys();
+ ? $this->map->getQueueableId()->all()
+ : $this->modelKeys();
}
/**
* Get the relationships of the entities being queued.
*
- * @return array
+ * @return array
*/
public function getQueueableRelations()
{
- return $this->isNotEmpty() ? $this->first()->getQueueableRelations() : [];
+ if ($this->isEmpty()) {
+ return [];
+ }
+
+ $relations = $this->map->getQueueableRelations()->all();
+
+ if (count($relations) === 0 || $relations === [[]]) {
+ return [];
+ } elseif (count($relations) === 1) {
+ return reset($relations);
+ } else {
+ return array_intersect(...array_values($relations));
+ }
}
/**
@@ -566,4 +907,28 @@ public function getQueueableConnection()
return $connection;
}
+
+ /**
+ * Get the Eloquent query builder from the collection.
+ *
+ * @return \Illuminate\Database\Eloquent\Builder
+ *
+ * @throws \LogicException
+ */
+ public function toQuery()
+ {
+ $model = $this->first();
+
+ if (! $model) {
+ throw new LogicException('Unable to create query for empty collection.');
+ }
+
+ $class = get_class($model);
+
+ if ($this->reject(fn ($model) => $model instanceof $class)->isNotEmpty()) {
+ throw new LogicException('Unable to create query for collection with mixed types.');
+ }
+
+ return $model->newModelQuery()->whereKey($this->modelKeys());
+ }
}
diff --git a/Eloquent/Concerns/GuardsAttributes.php b/Eloquent/Concerns/GuardsAttributes.php
index b9095c0480..aa9b2c60c9 100644
--- a/Eloquent/Concerns/GuardsAttributes.php
+++ b/Eloquent/Concerns/GuardsAttributes.php
@@ -2,21 +2,24 @@
namespace Illuminate\Database\Eloquent\Concerns;
-use Illuminate\Support\Str;
+use Illuminate\Database\Eloquent\Attributes\Fillable;
+use Illuminate\Database\Eloquent\Attributes\Guarded;
+use Illuminate\Database\Eloquent\Attributes\Initialize;
+use Illuminate\Database\Eloquent\Attributes\Unguarded;
trait GuardsAttributes
{
/**
* The attributes that are mass assignable.
*
- * @var array
+ * @var array
*/
protected $fillable = [];
/**
* The attributes that aren't mass assignable.
*
- * @var array
+ * @var array
*/
protected $guarded = ['*'];
@@ -27,10 +30,38 @@ trait GuardsAttributes
*/
protected static $unguarded = false;
+ /**
+ * The actual columns that exist on the database and can be guarded.
+ *
+ * @var array>
+ */
+ protected static $guardableColumns = [];
+
+ /**
+ * Initialize the GuardsAttributes trait.
+ *
+ * @return void
+ */
+ #[Initialize]
+ public function initializeGuardsAttributes()
+ {
+ if (empty($this->fillable)) {
+ $this->fillable = static::resolveClassAttribute(Fillable::class, 'columns') ?? [];
+ }
+
+ if ($this->guarded === ['*']) {
+ if (static::resolveClassAttribute(Unguarded::class) !== null) {
+ $this->guarded = [];
+ } else {
+ $this->guarded = static::resolveClassAttribute(Guarded::class, 'columns') ?? ['*'];
+ }
+ }
+ }
+
/**
* Get the fillable attributes for the model.
*
- * @return array
+ * @return array
*/
public function getFillable()
{
@@ -40,7 +71,7 @@ public function getFillable()
/**
* Set the fillable attributes for the model.
*
- * @param array $fillable
+ * @param array $fillable
* @return $this
*/
public function fillable(array $fillable)
@@ -50,20 +81,35 @@ public function fillable(array $fillable)
return $this;
}
+ /**
+ * Merge new fillable attributes with existing fillable attributes on the model.
+ *
+ * @param array $fillable
+ * @return $this
+ */
+ public function mergeFillable(array $fillable)
+ {
+ $this->fillable = array_values(array_unique(array_merge($this->fillable, $fillable)));
+
+ return $this;
+ }
+
/**
* Get the guarded attributes for the model.
*
- * @return array
+ * @return array
*/
public function getGuarded()
{
- return $this->guarded;
+ return self::$unguarded === true
+ ? []
+ : $this->guarded;
}
/**
* Set the guarded attributes for the model.
*
- * @param array $guarded
+ * @param array $guarded
* @return $this
*/
public function guard(array $guarded)
@@ -73,6 +119,19 @@ public function guard(array $guarded)
return $this;
}
+ /**
+ * Merge new guarded attributes with existing guarded attributes on the model.
+ *
+ * @param array $guarded
+ * @return $this
+ */
+ public function mergeGuarded(array $guarded)
+ {
+ $this->guarded = array_values(array_unique(array_merge($this->guarded, $guarded)));
+
+ return $this;
+ }
+
/**
* Disable all mass assignable restrictions.
*
@@ -95,7 +154,7 @@ public static function reguard()
}
/**
- * Determine if current state is "unguarded".
+ * Determine if the current state is "unguarded".
*
* @return bool
*/
@@ -107,8 +166,10 @@ public static function isUnguarded()
/**
* Run the given callable while being unguarded.
*
- * @param callable $callback
- * @return mixed
+ * @template TReturn
+ *
+ * @param callable(): TReturn $callback
+ * @return TReturn
*/
public static function unguarded(callable $callback)
{
@@ -152,7 +213,8 @@ public function isFillable($key)
}
return empty($this->getFillable()) &&
- ! Str::startsWith($key, '_');
+ ! str_contains($key, '.') &&
+ ! str_starts_with($key, '_');
}
/**
@@ -163,7 +225,40 @@ public function isFillable($key)
*/
public function isGuarded($key)
{
- return in_array($key, $this->getGuarded()) || $this->getGuarded() == ['*'];
+ if (empty($this->getGuarded())) {
+ return false;
+ }
+
+ return $this->getGuarded() == ['*'] ||
+ ! empty(preg_grep('/^'.preg_quote($key, '/').'$/i', $this->getGuarded())) ||
+ ! $this->isGuardableColumn($key);
+ }
+
+ /**
+ * Determine if the given column is a valid, guardable column.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function isGuardableColumn($key)
+ {
+ if ($this->hasSetMutator($key) || $this->hasAttributeSetMutator($key) || $this->isClassCastable($key)) {
+ return true;
+ }
+
+ if (! isset(static::$guardableColumns[get_class($this)])) {
+ $columns = $this->getConnection()
+ ->getSchemaBuilder()
+ ->getColumnListing($this->getTable());
+
+ if (empty($columns)) {
+ return true;
+ }
+
+ static::$guardableColumns[get_class($this)] = $columns;
+ }
+
+ return in_array($key, static::$guardableColumns[get_class($this)]);
}
/**
@@ -179,8 +274,8 @@ public function totallyGuarded()
/**
* Get the fillable attributes of a given array.
*
- * @param array $attributes
- * @return array
+ * @param array $attributes
+ * @return array
*/
protected function fillableFromArray(array $attributes)
{
diff --git a/Eloquent/Concerns/HasAttributes.php b/Eloquent/Concerns/HasAttributes.php
index c67d522f99..a5717d2c30 100644
--- a/Eloquent/Concerns/HasAttributes.php
+++ b/Eloquent/Concerns/HasAttributes.php
@@ -2,59 +2,143 @@
namespace Illuminate\Database\Eloquent\Concerns;
-use LogicException;
-use DateTimeInterface;
+use BackedEnum;
+use Brick\Math\BigDecimal;
+use Brick\Math\Exception\MathException as BrickMathException;
+use Brick\Math\RoundingMode;
+use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
-use Illuminate\Support\Arr;
-use Illuminate\Support\Str;
-use Illuminate\Support\Carbon;
-use Illuminate\Support\Facades\Date;
+use DateTimeImmutable;
+use DateTimeInterface;
+use Illuminate\Contracts\Database\Eloquent\Castable;
+use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Contracts\Support\Arrayable;
+use Illuminate\Database\Eloquent\Attributes\Appends;
+use Illuminate\Database\Eloquent\Attributes\Initialize;
+use Illuminate\Database\Eloquent\Attributes\Table;
+use Illuminate\Database\Eloquent\Casts\AsArrayObject;
+use Illuminate\Database\Eloquent\Casts\AsCollection;
+use Illuminate\Database\Eloquent\Casts\AsEncryptedArrayObject;
+use Illuminate\Database\Eloquent\Casts\AsEncryptedCollection;
+use Illuminate\Database\Eloquent\Casts\AsEnumArrayObject;
+use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
+use Illuminate\Database\Eloquent\Casts\Attribute;
+use Illuminate\Database\Eloquent\Casts\Json;
+use Illuminate\Database\Eloquent\InvalidCastException;
+use Illuminate\Database\Eloquent\JsonEncodingException;
+use Illuminate\Database\Eloquent\MissingAttributeException;
use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Database\LazyLoadingViolationException;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Collection;
use Illuminate\Support\Collection as BaseCollection;
-use Illuminate\Database\Eloquent\JsonEncodingException;
+use Illuminate\Support\Exceptions\MathException;
+use Illuminate\Support\Facades\Crypt;
+use Illuminate\Support\Facades\Date;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
+use InvalidArgumentException;
+use LogicException;
+use ReflectionClass;
+use ReflectionMethod;
+use ReflectionNamedType;
+use RuntimeException;
+use Stringable;
+use ValueError;
+
+use function Illuminate\Support\enum_value;
trait HasAttributes
{
/**
* The model's attributes.
*
- * @var array
+ * @var array
*/
protected $attributes = [];
/**
* The model attribute's original state.
*
- * @var array
+ * @var array
*/
protected $original = [];
/**
* The changed model attributes.
*
- * @var array
+ * @var array
*/
protected $changes = [];
/**
- * The attributes that should be cast to native types.
+ * The previous state of the changed model attributes.
+ *
+ * @var array
+ */
+ protected $previous = [];
+
+ /**
+ * The attributes that should be cast.
*
* @var array
*/
protected $casts = [];
/**
- * The attributes that should be mutated to dates.
+ * The attributes that have been cast using custom classes.
+ *
+ * @var array
+ */
+ protected $classCastCache = [];
+
+ /**
+ * The attributes that have been cast using "Attribute" return type mutators.
*
* @var array
*/
- protected $dates = [];
+ protected $attributeCastCache = [];
+
+ /**
+ * The built-in, primitive cast types supported by Eloquent.
+ *
+ * @var string[]
+ */
+ protected static $primitiveCastTypes = [
+ 'array',
+ 'bool',
+ 'boolean',
+ 'collection',
+ 'custom_datetime',
+ 'date',
+ 'datetime',
+ 'decimal',
+ 'double',
+ 'encrypted',
+ 'encrypted:array',
+ 'encrypted:collection',
+ 'encrypted:json',
+ 'encrypted:object',
+ 'float',
+ 'hashed',
+ 'immutable_date',
+ 'immutable_datetime',
+ 'immutable_custom_datetime',
+ 'int',
+ 'integer',
+ 'json',
+ 'json:unicode',
+ 'object',
+ 'real',
+ 'string',
+ 'timestamp',
+ ];
/**
* The storage format of the model's date columns.
*
- * @var string
+ * @var string|null
*/
protected $dateFormat;
@@ -79,10 +163,63 @@ trait HasAttributes
*/
protected static $mutatorCache = [];
+ /**
+ * The cache of the "Attribute" return type marked mutated attributes for each class.
+ *
+ * @var array
+ */
+ protected static $attributeMutatorCache = [];
+
+ /**
+ * The cache of the "Attribute" return type marked mutated, gettable attributes for each class.
+ *
+ * @var array
+ */
+ protected static $getAttributeMutatorCache = [];
+
+ /**
+ * The cache of the "Attribute" return type marked mutated, settable attributes for each class.
+ *
+ * @var array
+ */
+ protected static $setAttributeMutatorCache = [];
+
+ /**
+ * The cache of the converted cast types.
+ *
+ * @var array
+ */
+ protected static $castTypeCache = [];
+
+ /**
+ * The encrypter instance that is used to encrypt attributes.
+ *
+ * @var \Illuminate\Contracts\Encryption\Encrypter|null
+ */
+ public static $encrypter;
+
+ /**
+ * Initialize the trait.
+ *
+ * @return void
+ */
+ protected function initializeHasAttributes()
+ {
+ $this->casts = $this->ensureCastsAreStringValues(
+ array_merge($this->casts, $this->casts()),
+ );
+
+ $this->dateFormat ??= static::resolveClassAttribute(Table::class)->dateFormat ?? null;
+
+ if (empty($this->appends)) {
+ $this->appends = static::resolveClassAttribute(Appends::class, 'columns') ?? [];
+ }
+ }
+
/**
* Convert the model's attributes to an array.
*
- * @return array
+ * @return array
*/
public function attributesToArray()
{
@@ -117,13 +254,13 @@ public function attributesToArray()
/**
* Add the date attributes to the attributes array.
*
- * @param array $attributes
- * @return array
+ * @param array $attributes
+ * @return array
*/
protected function addDateAttributesToArray(array $attributes)
{
foreach ($this->getDates() as $key) {
- if (! isset($attributes[$key])) {
+ if (is_null($key) || ! isset($attributes[$key])) {
continue;
}
@@ -138,9 +275,9 @@ protected function addDateAttributesToArray(array $attributes)
/**
* Add the mutated attributes to the attributes array.
*
- * @param array $attributes
- * @param array $mutatedAttributes
- * @return array
+ * @param array $attributes
+ * @param array $mutatedAttributes
+ * @return array
*/
protected function addMutatedAttributesToArray(array $attributes, array $mutatedAttributes)
{
@@ -166,14 +303,15 @@ protected function addMutatedAttributesToArray(array $attributes, array $mutated
/**
* Add the casted attributes to the attributes array.
*
- * @param array $attributes
- * @param array $mutatedAttributes
- * @return array
+ * @param array $attributes
+ * @param array $mutatedAttributes
+ * @return array
*/
protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes)
{
foreach ($this->getCasts() as $key => $value) {
- if (! array_key_exists($key, $attributes) || in_array($key, $mutatedAttributes)) {
+ if (! array_key_exists($key, $attributes) ||
+ in_array($key, $mutatedAttributes)) {
continue;
}
@@ -187,14 +325,31 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt
// If the attribute cast was a date or a datetime, we will serialize the date as
// a string. This allows the developers to customize how dates are serialized
// into an array without affecting how they are persisted into the storage.
- if ($attributes[$key] &&
- ($value === 'date' || $value === 'datetime')) {
+ if (isset($attributes[$key]) && in_array($value, ['date', 'datetime', 'immutable_date', 'immutable_datetime'])) {
$attributes[$key] = $this->serializeDate($attributes[$key]);
}
- if ($attributes[$key] && $this->isCustomDateTimeCast($value)) {
+ if (isset($attributes[$key]) && ($this->isCustomDateTimeCast($value) ||
+ $this->isImmutableCustomDateTimeCast($value))) {
$attributes[$key] = $attributes[$key]->format(explode(':', $value, 2)[1]);
}
+
+ if ($attributes[$key] instanceof DateTimeInterface &&
+ $this->isClassCastable($key)) {
+ $attributes[$key] = $this->serializeDate($attributes[$key]);
+ }
+
+ if (isset($attributes[$key]) && $this->isClassSerializable($key)) {
+ $attributes[$key] = $this->serializeClassCastableAttribute($key, $attributes[$key]);
+ }
+
+ if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) {
+ $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($this->getCasts()[$key], $attributes[$key]) : null;
+ }
+
+ if ($attributes[$key] instanceof Arrayable) {
+ $attributes[$key] = $attributes[$key]->toArray();
+ }
}
return $attributes;
@@ -203,11 +358,11 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt
/**
* Get an attribute array of all arrayable attributes.
*
- * @return array
+ * @return array
*/
protected function getArrayableAttributes()
{
- return $this->getArrayableItems($this->attributes);
+ return $this->getArrayableItems($this->getAttributes());
}
/**
@@ -217,12 +372,14 @@ protected function getArrayableAttributes()
*/
protected function getArrayableAppends()
{
- if (! count($this->appends)) {
+ $appends = $this->getAppends();
+
+ if (! count($appends)) {
return [];
}
return $this->getArrayableItems(
- array_combine($this->appends, $this->appends)
+ array_combine($appends, $appends)
);
}
@@ -236,7 +393,7 @@ public function relationsToArray()
$attributes = [];
foreach ($this->getArrayableRelations() as $key => $value) {
- // If the values implements the Arrayable interface we can just call this
+ // If the values implement the Arrayable interface we can just call this
// toArray method on the instances which will convert both models and
// collections to their proper array form and we'll set the values.
if ($value instanceof Arrayable) {
@@ -244,8 +401,8 @@ public function relationsToArray()
}
// If the value is null, we'll still go ahead and set it in this list of
- // attributes since null is used to represent empty relationships if
- // if it a has one or belongs to type relationships on the models.
+ // attributes, since null is used to represent empty relationships if
+ // it has a has one or belongs to type relationships on the models.
elseif (is_null($value)) {
$relation = $value;
}
@@ -260,8 +417,8 @@ public function relationsToArray()
// If the relation value has been set, we will set it on this attributes
// list for returning. If it was not arrayable or null, we'll not set
// the value on the array because it is some type of invalid value.
- if (isset($relation) || is_null($value)) {
- $attributes[$key] = $relation;
+ if (array_key_exists('relation', get_defined_vars())) { // check if $relation is in scope (could be null)
+ $attributes[$key] = $relation ?? null;
}
unset($relation);
@@ -299,6 +456,25 @@ protected function getArrayableItems(array $values)
return $values;
}
+ /**
+ * Determine whether an attribute exists on the model.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function hasAttribute($key)
+ {
+ if (! $key) {
+ return false;
+ }
+
+ return array_key_exists($key, $this->attributes) ||
+ array_key_exists($key, $this->casts) ||
+ $this->hasGetMutator($key) ||
+ $this->hasAttributeMutator($key) ||
+ $this->isClassCastable($key);
+ }
+
/**
* Get an attribute from the model.
*
@@ -314,8 +490,7 @@ public function getAttribute($key)
// If the attribute exists in the attribute array or has a "get" mutator we will
// get the attribute's value. Otherwise, we will proceed as if the developers
// are asking for a relationship's value. This covers both types of values.
- if (array_key_exists($key, $this->attributes) ||
- $this->hasGetMutator($key)) {
+ if ($this->hasAttribute($key)) {
return $this->getAttributeValue($key);
}
@@ -323,45 +498,46 @@ public function getAttribute($key)
// since we don't want to treat any of those methods as relationships because
// they are all intended as helper methods and none of these are relations.
if (method_exists(self::class, $key)) {
- return;
+ return $this->throwMissingAttributeExceptionIfApplicable($key);
}
- return $this->getRelationValue($key);
+ return $this->isRelation($key) || $this->relationLoaded($key)
+ ? $this->getRelationValue($key)
+ : $this->throwMissingAttributeExceptionIfApplicable($key);
}
/**
- * Get a plain attribute (not a relationship).
+ * Either throw a missing attribute exception or return null depending on Eloquent's configuration.
*
* @param string $key
- * @return mixed
+ * @return null
+ *
+ * @throws \Illuminate\Database\Eloquent\MissingAttributeException
*/
- public function getAttributeValue($key)
+ protected function throwMissingAttributeExceptionIfApplicable($key)
{
- $value = $this->getAttributeFromArray($key);
-
- // If the attribute has a get mutator, we will call that then return what
- // it returns as the value, which is useful for transforming values on
- // retrieval from the model to a form that is more useful for usage.
- if ($this->hasGetMutator($key)) {
- return $this->mutateAttribute($key, $value);
- }
+ if ($this->exists &&
+ ! $this->wasRecentlyCreated &&
+ static::preventsAccessingMissingAttributes()) {
+ if (isset(static::$missingAttributeViolationCallback)) {
+ return call_user_func(static::$missingAttributeViolationCallback, $this, $key);
+ }
- // If the attribute exists within the cast array, we will convert it to
- // an appropriate native PHP type dependant upon the associated value
- // given with the key in the pair. Dayle made this comment line up.
- if ($this->hasCast($key)) {
- return $this->castAttribute($key, $value);
+ throw new MissingAttributeException($this, $key);
}
- // If the attribute is listed as a date, we will convert it to a DateTime
- // instance on retrieval, which makes it quite convenient to work with
- // date fields without having to create a mutator for each property.
- if (in_array($key, $this->getDates()) &&
- ! is_null($value)) {
- return $this->asDateTime($value);
- }
+ return null;
+ }
- return $value;
+ /**
+ * Get a plain attribute (not a relationship).
+ *
+ * @param string $key
+ * @return mixed
+ */
+ public function getAttributeValue($key)
+ {
+ return $this->transformModelValue($key, $this->getAttributeFromArray($key));
}
/**
@@ -372,9 +548,9 @@ public function getAttributeValue($key)
*/
protected function getAttributeFromArray($key)
{
- if (isset($this->attributes[$key])) {
- return $this->attributes[$key];
- }
+ $this->mergeAttributeFromCachedCasts($key);
+
+ return $this->attributes[$key] ?? null;
}
/**
@@ -392,12 +568,59 @@ public function getRelationValue($key)
return $this->relations[$key];
}
+ if (! $this->isRelation($key)) {
+ return;
+ }
+
+ if ($this->attemptToAutoloadRelation($key)) {
+ return $this->relations[$key];
+ }
+
+ if ($this->preventsLazyLoading) {
+ $this->handleLazyLoadingViolation($key);
+ }
+
// If the "attribute" exists as a method on the model, we will just assume
// it is a relationship and will load and return results from the query
// and hydrate the relationship's value on the "relationships" array.
- if (method_exists($this, $key)) {
- return $this->getRelationshipFromMethod($key);
+ return $this->getRelationshipFromMethod($key);
+ }
+
+ /**
+ * Determine if the given key is a relationship method on the model.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function isRelation($key)
+ {
+ if ($this->hasAttributeMutator($key)) {
+ return false;
+ }
+
+ return method_exists($this, $key) ||
+ $this->relationResolver(static::class, $key);
+ }
+
+ /**
+ * Handle a lazy loading violation.
+ *
+ * @param string $key
+ * @return mixed
+ *
+ * @throws \Illuminate\Database\LazyLoadingViolationException
+ */
+ protected function handleLazyLoadingViolation($key)
+ {
+ if (isset(static::$lazyLoadingViolationCallback)) {
+ return call_user_func(static::$lazyLoadingViolationCallback, $this, $key);
+ }
+
+ if (! $this->exists || $this->wasRecentlyCreated) {
+ return;
}
+
+ throw new LazyLoadingViolationException($this, $key);
}
/**
@@ -413,6 +636,12 @@ protected function getRelationshipFromMethod($method)
$relation = $this->$method();
if (! $relation instanceof Relation) {
+ if (is_null($relation)) {
+ throw new LogicException(sprintf(
+ '%s::%s must return a relationship instance, but "null" was returned. Was the "return" keyword used?', static::class, $method
+ ));
+ }
+
throw new LogicException(sprintf(
'%s::%s must return a relationship instance.', static::class, $method
));
@@ -434,6 +663,59 @@ public function hasGetMutator($key)
return method_exists($this, 'get'.Str::studly($key).'Attribute');
}
+ /**
+ * Determine if a "Attribute" return type marked mutator exists for an attribute.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function hasAttributeMutator($key)
+ {
+ if (isset(static::$attributeMutatorCache[get_class($this)][$key])) {
+ return static::$attributeMutatorCache[get_class($this)][$key];
+ }
+
+ if (! method_exists($this, $method = Str::camel($key))) {
+ return static::$attributeMutatorCache[get_class($this)][$key] = false;
+ }
+
+ $returnType = (new ReflectionMethod($this, $method))->getReturnType();
+
+ return static::$attributeMutatorCache[get_class($this)][$key] =
+ $returnType instanceof ReflectionNamedType &&
+ $returnType->getName() === Attribute::class;
+ }
+
+ /**
+ * Determine if a "Attribute" return type marked get mutator exists for an attribute.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function hasAttributeGetMutator($key)
+ {
+ if (isset(static::$getAttributeMutatorCache[get_class($this)][$key])) {
+ return static::$getAttributeMutatorCache[get_class($this)][$key];
+ }
+
+ if (! $this->hasAttributeMutator($key)) {
+ return static::$getAttributeMutatorCache[get_class($this)][$key] = false;
+ }
+
+ return static::$getAttributeMutatorCache[get_class($this)][$key] = is_callable($this->{Str::camel($key)}()->get);
+ }
+
+ /**
+ * Determine if any get mutator exists for an attribute.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function hasAnyGetMutator($key)
+ {
+ return $this->hasGetMutator($key) || $this->hasAttributeGetMutator($key);
+ }
+
/**
* Get the value of an attribute using its mutator.
*
@@ -443,9 +725,41 @@ public function hasGetMutator($key)
*/
protected function mutateAttribute($key, $value)
{
+ $this->mergeAttributesFromCachedCasts();
+
return $this->{'get'.Str::studly($key).'Attribute'}($value);
}
+ /**
+ * Get the value of an "Attribute" return type marked attribute using its mutator.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function mutateAttributeMarkedAttribute($key, $value)
+ {
+ if (array_key_exists($key, $this->attributeCastCache)) {
+ return $this->attributeCastCache[$key];
+ }
+
+ $this->mergeAttributesFromCachedCasts();
+
+ $attribute = $this->{Str::camel($key)}();
+
+ $value = call_user_func($attribute->get ?: function ($value) {
+ return $value;
+ }, $value, $this->attributes);
+
+ if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) {
+ $this->attributeCastCache[$key] = $value;
+ } else {
+ unset($this->attributeCastCache[$key]);
+ }
+
+ return $value;
+ }
+
/**
* Get the value of an attribute using its mutator for array conversion.
*
@@ -455,11 +769,74 @@ protected function mutateAttribute($key, $value)
*/
protected function mutateAttributeForArray($key, $value)
{
- $value = $this->mutateAttribute($key, $value);
+ if ($this->isClassCastable($key)) {
+ $value = $this->getClassCastableAttributeValue($key, $value);
+ } elseif (isset(static::$getAttributeMutatorCache[get_class($this)][$key]) &&
+ static::$getAttributeMutatorCache[get_class($this)][$key] === true) {
+ $value = $this->mutateAttributeMarkedAttribute($key, $value);
+
+ $value = $value instanceof DateTimeInterface
+ ? $this->serializeDate($value)
+ : $value;
+ } else {
+ $value = $this->mutateAttribute($key, $value);
+ }
return $value instanceof Arrayable ? $value->toArray() : $value;
}
+ /**
+ * Merge new casts with existing casts on the model.
+ *
+ * @param array $casts
+ * @return $this
+ */
+ public function mergeCasts($casts)
+ {
+ $casts = $this->ensureCastsAreStringValues($casts);
+
+ $this->casts = array_merge($this->casts, $casts);
+
+ return $this;
+ }
+
+ /**
+ * Ensure that the given casts are strings.
+ *
+ * @param array $casts
+ * @return array
+ *
+ * @throws \InvalidArgumentException
+ */
+ protected function ensureCastsAreStringValues($casts)
+ {
+ foreach ($casts as $attribute => $cast) {
+ $casts[$attribute] = match (true) {
+ is_object($cast) => value(function () use ($cast, $attribute) {
+ if ($cast instanceof Stringable) {
+ return (string) $cast;
+ }
+
+ throw new InvalidArgumentException(
+ "The cast object for the {$attribute} attribute must implement Stringable."
+ );
+ }),
+ is_array($cast) => value(function () use ($cast) {
+ if (count($cast) === 1) {
+ return $cast[0];
+ }
+
+ [$cast, $arguments] = [array_shift($cast), $cast];
+
+ return $cast.':'.implode(',', $arguments);
+ }),
+ default => $cast,
+ };
+ }
+
+ return $casts;
+ }
+
/**
* Cast an attribute to a native PHP type.
*
@@ -469,11 +846,22 @@ protected function mutateAttributeForArray($key, $value)
*/
protected function castAttribute($key, $value)
{
- if (is_null($value)) {
+ $castType = $this->getCastType($key);
+
+ if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) {
return $value;
}
- switch ($this->getCastType($key)) {
+ // If the key is one of the encrypted castable types, we'll first decrypt
+ // the value and update the cast type so we may leverage the following
+ // logic for casting this value to any additionally specified types.
+ if ($this->isEncryptedCastable($key)) {
+ $value = $this->fromEncryptedString($value);
+
+ $castType = Str::after($castType, 'encrypted:');
+ }
+
+ switch ($castType) {
case 'int':
case 'integer':
return (int) $value;
@@ -492,6 +880,7 @@ protected function castAttribute($key, $value)
return $this->fromJson($value, true);
case 'array':
case 'json':
+ case 'json:unicode':
return $this->fromJson($value);
case 'collection':
return new BaseCollection($this->fromJson($value));
@@ -500,33 +889,154 @@ protected function castAttribute($key, $value)
case 'datetime':
case 'custom_datetime':
return $this->asDateTime($value);
+ case 'immutable_date':
+ return $this->asDate($value)->toImmutable();
+ case 'immutable_custom_datetime':
+ case 'immutable_datetime':
+ return $this->asDateTime($value)->toImmutable();
case 'timestamp':
return $this->asTimestamp($value);
- default:
- return $value;
}
+
+ if ($this->isEnumCastable($key)) {
+ return $this->getEnumCastableAttributeValue($key, $value);
+ }
+
+ if ($this->isClassCastable($key)) {
+ return $this->getClassCastableAttributeValue($key, $value);
+ }
+
+ return $value;
}
/**
- * Get the type of cast for a model attribute.
+ * Cast the given attribute using a custom cast class.
*
* @param string $key
- * @return string
+ * @param mixed $value
+ * @return mixed
*/
- protected function getCastType($key)
+ protected function getClassCastableAttributeValue($key, $value)
{
- if ($this->isCustomDateTimeCast($this->getCasts()[$key])) {
- return 'custom_datetime';
- }
+ $caster = $this->resolveCasterClass($key);
- if ($this->isDecimalCast($this->getCasts()[$key])) {
- return 'decimal';
- }
+ $objectCachingDisabled = $caster->withoutObjectCaching ?? false;
- return trim(strtolower($this->getCasts()[$key]));
- }
+ if (isset($this->classCastCache[$key]) && ! $objectCachingDisabled) {
+ return $this->classCastCache[$key];
+ } else {
+ $value = $caster instanceof CastsInboundAttributes
+ ? $value
+ : $caster->get($this, $key, $value, $this->attributes);
- /**
+ if ($caster instanceof CastsInboundAttributes ||
+ ! is_object($value) ||
+ $objectCachingDisabled) {
+ unset($this->classCastCache[$key]);
+ } else {
+ $this->classCastCache[$key] = $value;
+ }
+
+ return $value;
+ }
+ }
+
+ /**
+ * Cast the given attribute to an enum.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function getEnumCastableAttributeValue($key, $value)
+ {
+ if (is_null($value)) {
+ return;
+ }
+
+ $castType = $this->getCasts()[$key];
+
+ if ($value instanceof $castType) {
+ return $value;
+ }
+
+ return $this->getEnumCaseFromValue($castType, $value);
+ }
+
+ /**
+ * Get the type of cast for a model attribute.
+ *
+ * @param string $key
+ * @return string
+ */
+ protected function getCastType($key)
+ {
+ $castType = $this->getCasts()[$key];
+
+ if (isset(static::$castTypeCache[$castType])) {
+ return static::$castTypeCache[$castType];
+ }
+
+ if ($this->isCustomDateTimeCast($castType)) {
+ $convertedCastType = 'custom_datetime';
+ } elseif ($this->isImmutableCustomDateTimeCast($castType)) {
+ $convertedCastType = 'immutable_custom_datetime';
+ } elseif ($this->isDecimalCast($castType)) {
+ $convertedCastType = 'decimal';
+ } elseif (class_exists($castType)) {
+ $convertedCastType = $castType;
+ } else {
+ $convertedCastType = trim(strtolower($castType));
+ }
+
+ return static::$castTypeCache[$castType] = $convertedCastType;
+ }
+
+ /**
+ * Increment or decrement the given attribute using the custom cast class.
+ *
+ * @param string $method
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function deviateClassCastableAttribute($method, $key, $value)
+ {
+ return $this->resolveCasterClass($key)->{$method}(
+ $this, $key, $value, $this->attributes
+ );
+ }
+
+ /**
+ * Serialize the given attribute using the custom cast class.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function serializeClassCastableAttribute($key, $value)
+ {
+ return $this->resolveCasterClass($key)->serialize(
+ $this, $key, $value, $this->attributes
+ );
+ }
+
+ /**
+ * Compare two values for the given attribute using the custom cast class.
+ *
+ * @param string $key
+ * @param mixed $original
+ * @param mixed $value
+ * @return bool
+ */
+ protected function compareClassCastableAttribute($key, $original, $value)
+ {
+ return $this->resolveCasterClass($key)->compare(
+ $this, $key, $original, $value
+ );
+ }
+
+ /**
* Determine if the cast type is a custom date time cast.
*
* @param string $cast
@@ -534,8 +1044,20 @@ protected function getCastType($key)
*/
protected function isCustomDateTimeCast($cast)
{
- return strncmp($cast, 'date:', 5) === 0 ||
- strncmp($cast, 'datetime:', 9) === 0;
+ return str_starts_with($cast, 'date:') ||
+ str_starts_with($cast, 'datetime:');
+ }
+
+ /**
+ * Determine if the cast type is an immutable custom date time cast.
+ *
+ * @param string $cast
+ * @return bool
+ */
+ protected function isImmutableCustomDateTimeCast($cast)
+ {
+ return str_starts_with($cast, 'immutable_date:') ||
+ str_starts_with($cast, 'immutable_datetime:');
}
/**
@@ -546,7 +1068,7 @@ protected function isCustomDateTimeCast($cast)
*/
protected function isDecimalCast($cast)
{
- return strncmp($cast, 'decimal:', 8) === 0;
+ return str_starts_with($cast, 'decimal:');
}
/**
@@ -560,29 +1082,51 @@ public function setAttribute($key, $value)
{
// First we will check for the presence of a mutator for the set operation
// which simply lets the developers tweak the attribute as it is set on
- // the model, such as "json_encoding" an listing of data for storage.
+ // this model, such as "json_encoding" a listing of data for storage.
if ($this->hasSetMutator($key)) {
return $this->setMutatedAttributeValue($key, $value);
+ } elseif ($this->hasAttributeSetMutator($key)) {
+ return $this->setAttributeMarkedMutatedAttributeValue($key, $value);
}
// If an attribute is listed as a "date", we'll convert it from a DateTime
// instance into a form proper for storage on the database tables using
// the connection grammar's date format. We will auto set the values.
- elseif ($value && $this->isDateAttribute($key)) {
+ elseif (! is_null($value) && $this->isDateAttribute($key)) {
$value = $this->fromDateTime($value);
}
- if ($this->isJsonCastable($key) && ! is_null($value)) {
+ if ($this->isEnumCastable($key)) {
+ $this->setEnumCastableAttribute($key, $value);
+
+ return $this;
+ }
+
+ if ($this->isClassCastable($key)) {
+ $this->setClassCastableAttribute($key, $value);
+
+ return $this;
+ }
+
+ if (! is_null($value) && $this->isJsonCastable($key)) {
$value = $this->castAttributeAsJson($key, $value);
}
// If this attribute contains a JSON ->, we'll set the proper value in the
// attribute's underlying array. This takes care of properly nesting an
// attribute in the array's value in the case of deeply nested items.
- if (Str::contains($key, '->')) {
+ if (str_contains($key, '->')) {
return $this->fillJsonAttribute($key, $value);
}
+ if (! is_null($value) && $this->isEncryptedCastable($key)) {
+ $value = $this->castAttributeAsEncryptedString($key, $value);
+ }
+
+ if (! is_null($value) && $this->hasCast($key, 'hashed')) {
+ $value = $this->castAttributeAsHashedString($key, $value);
+ }
+
$this->attributes[$key] = $value;
return $this;
@@ -599,6 +1143,32 @@ public function hasSetMutator($key)
return method_exists($this, 'set'.Str::studly($key).'Attribute');
}
+ /**
+ * Determine if an "Attribute" return type marked set mutator exists for an attribute.
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function hasAttributeSetMutator($key)
+ {
+ $class = get_class($this);
+
+ if (isset(static::$setAttributeMutatorCache[$class][$key])) {
+ return static::$setAttributeMutatorCache[$class][$key];
+ }
+
+ if (! method_exists($this, $method = Str::camel($key))) {
+ return static::$setAttributeMutatorCache[$class][$key] = false;
+ }
+
+ $returnType = (new ReflectionMethod($this, $method))->getReturnType();
+
+ return static::$setAttributeMutatorCache[$class][$key] =
+ $returnType instanceof ReflectionNamedType &&
+ $returnType->getName() === Attribute::class &&
+ is_callable($this->{$method}()->set);
+ }
+
/**
* Set the value of an attribute using its mutator.
*
@@ -608,9 +1178,44 @@ public function hasSetMutator($key)
*/
protected function setMutatedAttributeValue($key, $value)
{
+ $this->mergeAttributesFromCachedCasts();
+
return $this->{'set'.Str::studly($key).'Attribute'}($value);
}
+ /**
+ * Set the value of a "Attribute" return type marked attribute using its mutator.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function setAttributeMarkedMutatedAttributeValue($key, $value)
+ {
+ $this->mergeAttributesFromCachedCasts();
+
+ $attribute = $this->{Str::camel($key)}();
+
+ $callback = $attribute->set ?: function ($value) use ($key) {
+ $this->attributes[$key] = $value;
+ };
+
+ $this->attributes = array_merge(
+ $this->attributes,
+ $this->normalizeCastClassResponse(
+ $key, $callback($value, $this->attributes)
+ )
+ );
+
+ if ($attribute->withCaching || (is_object($value) && $attribute->withObjectCaching)) {
+ $this->attributeCastCache[$key] = $value;
+ } else {
+ unset($this->attributeCastCache[$key]);
+ }
+
+ return $this;
+ }
+
/**
* Determine if the given attribute is a date or date castable.
*
@@ -619,8 +1224,8 @@ protected function setMutatedAttributeValue($key, $value)
*/
protected function isDateAttribute($key)
{
- return in_array($key, $this->getDates()) ||
- $this->isDateCastable($key);
+ return in_array($key, $this->getDates(), true) ||
+ $this->isDateCastable($key);
}
/**
@@ -634,20 +1239,109 @@ public function fillJsonAttribute($key, $value)
{
[$key, $path] = explode('->', $key, 2);
- $this->attributes[$key] = $this->asJson($this->getArrayAttributeWithValue(
+ $value = $this->asJson($this->getArrayAttributeWithValue(
$path, $key, $value
- ));
+ ), $this->getJsonCastFlags($key));
+
+ $this->attributes[$key] = $this->isEncryptedCastable($key)
+ ? $this->castAttributeAsEncryptedString($key, $value)
+ : $value;
+
+ if ($this->isClassCastable($key)) {
+ unset($this->classCastCache[$key]);
+ }
return $this;
}
+ /**
+ * Set the value of a class castable attribute.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return void
+ */
+ protected function setClassCastableAttribute($key, $value)
+ {
+ $caster = $this->resolveCasterClass($key);
+
+ $this->attributes = array_replace(
+ $this->attributes,
+ $this->normalizeCastClassResponse($key, $caster->set(
+ $this, $key, $value, $this->attributes
+ ))
+ );
+
+ if ($caster instanceof CastsInboundAttributes ||
+ ! is_object($value) ||
+ ($caster->withoutObjectCaching ?? false)) {
+ unset($this->classCastCache[$key]);
+ } else {
+ $this->classCastCache[$key] = $value;
+ }
+ }
+
+ /**
+ * Set the value of an enum castable attribute.
+ *
+ * @param string $key
+ * @param \UnitEnum|string|int|null $value
+ * @return void
+ */
+ protected function setEnumCastableAttribute($key, $value)
+ {
+ $enumClass = $this->getCasts()[$key];
+
+ if (! isset($value)) {
+ $this->attributes[$key] = null;
+ } elseif (is_object($value)) {
+ $this->attributes[$key] = $this->getStorableEnumValue($enumClass, $value);
+ } else {
+ $this->attributes[$key] = $this->getStorableEnumValue(
+ $enumClass, $this->getEnumCaseFromValue($enumClass, $value)
+ );
+ }
+ }
+
+ /**
+ * Get an enum case instance from a given class and value.
+ *
+ * @param string $enumClass
+ * @param string|int $value
+ * @return \UnitEnum
+ */
+ protected function getEnumCaseFromValue($enumClass, $value)
+ {
+ return is_subclass_of($enumClass, BackedEnum::class)
+ ? $enumClass::from($value)
+ : constant($enumClass.'::'.$value);
+ }
+
+ /**
+ * Get the storable value from the given enum.
+ *
+ * @param string $expectedEnum
+ * @param \UnitEnum $value
+ * @return string|int
+ *
+ * @throws \ValueError
+ */
+ protected function getStorableEnumValue($expectedEnum, $value)
+ {
+ if (! $value instanceof $expectedEnum) {
+ throw new ValueError(sprintf('Value [%s] is not of the expected enum type [%s].', var_export($value, true), $expectedEnum));
+ }
+
+ return enum_value($value);
+ }
+
/**
* Get an array attribute with the given key and value set.
*
* @param string $path
* @param string $key
* @param mixed $value
- * @return $this
+ * @return array
*/
protected function getArrayAttributeWithValue($path, $key, $value)
{
@@ -664,8 +1358,15 @@ protected function getArrayAttributeWithValue($path, $key, $value)
*/
protected function getArrayAttributeByKey($key)
{
- return isset($this->attributes[$key]) ?
- $this->fromJson($this->attributes[$key]) : [];
+ if (! isset($this->attributes[$key])) {
+ return [];
+ }
+
+ return $this->fromJson(
+ $this->isEncryptedCastable($key)
+ ? $this->fromEncryptedString($this->attributes[$key])
+ : $this->attributes[$key]
+ );
}
/**
@@ -674,10 +1375,12 @@ protected function getArrayAttributeByKey($key)
* @param string $key
* @param mixed $value
* @return string
+ *
+ * @throws \Illuminate\Database\Eloquent\JsonEncodingException
*/
protected function castAttributeAsJson($key, $value)
{
- $value = $this->asJson($value);
+ $value = $this->asJson($value, $this->getJsonCastFlags($key));
if ($value === false) {
throw JsonEncodingException::forAttribute(
@@ -688,27 +1391,120 @@ protected function castAttributeAsJson($key, $value)
return $value;
}
+ /**
+ * Get the JSON casting flags for the given attribute.
+ *
+ * @param string $key
+ * @return int
+ */
+ protected function getJsonCastFlags($key)
+ {
+ $flags = 0;
+
+ if ($this->hasCast($key, ['json:unicode'])) {
+ $flags |= JSON_UNESCAPED_UNICODE;
+ }
+
+ return $flags;
+ }
+
/**
* Encode the given value as JSON.
*
* @param mixed $value
+ * @param int $flags
* @return string
*/
- protected function asJson($value)
+ protected function asJson($value, $flags = 0)
{
- return json_encode($value);
+ return Json::encode($value, $flags);
}
/**
* Decode the given JSON back into an array or object.
*
- * @param string $value
+ * @param string|null $value
* @param bool $asObject
* @return mixed
*/
public function fromJson($value, $asObject = false)
{
- return json_decode($value, ! $asObject);
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ return Json::decode($value, ! $asObject);
+ }
+
+ /**
+ * Decrypt the given encrypted string.
+ *
+ * @param string $value
+ * @return mixed
+ */
+ public function fromEncryptedString($value)
+ {
+ return static::currentEncrypter()->decrypt($value, false);
+ }
+
+ /**
+ * Cast the given attribute to an encrypted string.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return string
+ */
+ protected function castAttributeAsEncryptedString($key, #[\SensitiveParameter] $value)
+ {
+ return static::currentEncrypter()->encrypt($value, false);
+ }
+
+ /**
+ * Set the encrypter instance that will be used to encrypt attributes.
+ *
+ * @param \Illuminate\Contracts\Encryption\Encrypter|null $encrypter
+ * @return void
+ */
+ public static function encryptUsing($encrypter)
+ {
+ static::$encrypter = $encrypter;
+ }
+
+ /**
+ * Get the current encrypter being used by the model.
+ *
+ * @return \Illuminate\Contracts\Encryption\Encrypter
+ */
+ public static function currentEncrypter()
+ {
+ return static::$encrypter ?? Crypt::getFacadeRoot();
+ }
+
+ /**
+ * Cast the given attribute to a hashed string.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return string
+ *
+ * @throws \RuntimeException
+ */
+ protected function castAttributeAsHashedString($key, #[\SensitiveParameter] $value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (! Hash::isHashed($value)) {
+ return Hash::make($value);
+ }
+
+ /** @phpstan-ignore staticMethod.notFound */
+ if (! Hash::verifyConfiguration($value)) {
+ throw new RuntimeException("Could not verify the hashed value's configuration.");
+ }
+
+ return $value;
}
/**
@@ -719,28 +1515,30 @@ public function fromJson($value, $asObject = false)
*/
public function fromFloat($value)
{
- switch ((string) $value) {
- case 'Infinity':
- return INF;
- case '-Infinity':
- return -INF;
- case 'NaN':
- return NAN;
- default:
- return (float) $value;
- }
+ return match ((string) $value) {
+ 'Infinity' => INF,
+ '-Infinity' => -INF,
+ 'NaN' => NAN,
+ default => (float) $value,
+ };
}
/**
* Return a decimal as string.
*
- * @param float $value
+ * @param float|string $value
* @param int $decimals
* @return string
+ *
+ * @throws \Illuminate\Support\Exceptions\MathException
*/
protected function asDecimal($value, $decimals)
{
- return number_format($value, $decimals, '.', '');
+ try {
+ return (string) BigDecimal::of((string) $value)->toScale($decimals, RoundingMode::HALF_UP);
+ } catch (BrickMathException $e) {
+ throw new MathException('Unable to cast value to a decimal.', previous: $e);
+ }
}
/**
@@ -765,7 +1563,7 @@ protected function asDateTime($value)
// If this value is already a Carbon instance, we shall just return it as is.
// This prevents us having to re-instantiate a Carbon instance when we know
// it already is one, which wouldn't be fulfilled by the DateTime check.
- if ($value instanceof Carbon || $value instanceof CarbonInterface) {
+ if ($value instanceof CarbonInterface) {
return Date::instance($value);
}
@@ -782,7 +1580,7 @@ protected function asDateTime($value)
// and format a Carbon object from this timestamp. This allows flexibility
// when defining your date fields as they might be UNIX timestamps here.
if (is_numeric($value)) {
- return Date::createFromTimestamp($value);
+ return Date::createFromTimestamp($value, date_default_timezone_get());
}
// If the value is in simply year, month, day format, we will instantiate the
@@ -794,15 +1592,16 @@ protected function asDateTime($value)
$format = $this->getDateFormat();
- // https://bugs.php.net/bug.php?id=75577
- if (version_compare(PHP_VERSION, '7.3.0-dev', '<')) {
- $format = str_replace('.v', '.u', $format);
- }
-
// Finally, we will just assume this date is in the format used by default on
// the database connection and use that format to create the Carbon object
// that is returned back out to the developers after we convert it here.
- return Date::createFromFormat($format, $value);
+ try {
+ $date = Date::createFromFormat($format, $value);
+ } catch (InvalidArgumentException) {
+ $date = false;
+ }
+
+ return $date ?: Date::parse($value);
}
/**
@@ -848,21 +1647,22 @@ protected function asTimestamp($value)
*/
protected function serializeDate(DateTimeInterface $date)
{
- return $date->format($this->getDateFormat());
+ return $date instanceof DateTimeImmutable ?
+ CarbonImmutable::instance($date)->toJSON() :
+ Carbon::instance($date)->toJSON();
}
/**
* Get the attributes that should be converted to dates.
*
- * @return array
+ * @return array
*/
public function getDates()
{
- $defaults = [static::CREATED_AT, static::UPDATED_AT];
-
- return $this->usesTimestamps()
- ? array_unique(array_merge($this->dates, $defaults))
- : $this->dates;
+ return $this->usesTimestamps() ? [
+ $this->getCreatedAtColumn(),
+ $this->getUpdatedAtColumn(),
+ ] : [];
}
/**
@@ -905,7 +1705,7 @@ public function hasCast($key, $types = null)
}
/**
- * Get the casts array.
+ * Get the attributes that should be cast.
*
* @return array
*/
@@ -918,6 +1718,16 @@ public function getCasts()
return $this->casts;
}
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts()
+ {
+ return [];
+ }
+
/**
* Determine whether a value is Date / DateTime castable for inbound manipulation.
*
@@ -926,28 +1736,320 @@ public function getCasts()
*/
protected function isDateCastable($key)
{
- return $this->hasCast($key, ['date', 'datetime']);
+ return $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']);
}
/**
- * Determine whether a value is JSON castable for inbound manipulation.
+ * Determine whether a value is Date / DateTime custom-castable for inbound manipulation.
*
* @param string $key
* @return bool
*/
- protected function isJsonCastable($key)
+ protected function isDateCastableWithCustomFormat($key)
{
- return $this->hasCast($key, ['array', 'json', 'object', 'collection']);
+ return $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']);
}
/**
- * Get all of the current attributes on the model.
+ * Determine whether a value is JSON castable for inbound manipulation.
*
- * @return array
+ * @param string $key
+ * @return bool
*/
- public function getAttributes()
+ protected function isJsonCastable($key)
{
- return $this->attributes;
+ return $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']);
+ }
+
+ /**
+ * Determine whether a value is an encrypted castable for inbound manipulation.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function isEncryptedCastable($key)
+ {
+ return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']);
+ }
+
+ /**
+ * Determine if the given key is cast using a custom class.
+ *
+ * @param string $key
+ * @return bool
+ *
+ * @throws \Illuminate\Database\Eloquent\InvalidCastException
+ */
+ protected function isClassCastable($key)
+ {
+ $casts = $this->getCasts();
+
+ if (! array_key_exists($key, $casts)) {
+ return false;
+ }
+
+ $castType = $this->parseCasterClass($casts[$key]);
+
+ if (in_array($castType, static::$primitiveCastTypes)) {
+ return false;
+ }
+
+ if (class_exists($castType)) {
+ return true;
+ }
+
+ throw new InvalidCastException($this->getModel(), $key, $castType);
+ }
+
+ /**
+ * Determine if the given key is cast using an enum.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function isEnumCastable($key)
+ {
+ $casts = $this->getCasts();
+
+ if (! array_key_exists($key, $casts)) {
+ return false;
+ }
+
+ $castType = $casts[$key];
+
+ if (in_array($castType, static::$primitiveCastTypes)) {
+ return false;
+ }
+
+ if (is_subclass_of($castType, Castable::class)) {
+ return false;
+ }
+
+ return enum_exists($castType);
+ }
+
+ /**
+ * Determine if the key is deviable using a custom class.
+ *
+ * @param string $key
+ * @return bool
+ *
+ * @throws \Illuminate\Database\Eloquent\InvalidCastException
+ */
+ protected function isClassDeviable($key)
+ {
+ if (! $this->isClassCastable($key)) {
+ return false;
+ }
+
+ $castType = $this->resolveCasterClass($key);
+
+ return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement');
+ }
+
+ /**
+ * Determine if the key is serializable using a custom class.
+ *
+ * @param string $key
+ * @return bool
+ *
+ * @throws \Illuminate\Database\Eloquent\InvalidCastException
+ */
+ protected function isClassSerializable($key)
+ {
+ return ! $this->isEnumCastable($key) &&
+ $this->isClassCastable($key) &&
+ method_exists($this->resolveCasterClass($key), 'serialize');
+ }
+
+ /**
+ * Determine if the key is comparable using a custom class.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function isClassComparable($key)
+ {
+ return ! $this->isEnumCastable($key) &&
+ $this->isClassCastable($key) &&
+ method_exists($this->resolveCasterClass($key), 'compare');
+ }
+
+ /**
+ * Resolve the custom caster class for a given key.
+ *
+ * @param string $key
+ * @return mixed
+ */
+ protected function resolveCasterClass($key)
+ {
+ $castType = $this->getCasts()[$key];
+
+ $arguments = [];
+
+ if (is_string($castType) && str_contains($castType, ':')) {
+ $segments = explode(':', $castType, 2);
+
+ $castType = $segments[0];
+ $arguments = explode(',', $segments[1]);
+ }
+
+ if (is_subclass_of($castType, Castable::class)) {
+ $castType = $castType::castUsing($arguments);
+ }
+
+ if (is_object($castType)) {
+ return $castType;
+ }
+
+ return new $castType(...$arguments);
+ }
+
+ /**
+ * Parse the given caster class, removing any arguments.
+ *
+ * @param string $class
+ * @return string
+ */
+ protected function parseCasterClass($class)
+ {
+ return ! str_contains($class, ':')
+ ? $class
+ : explode(':', $class, 2)[0];
+ }
+
+ /**
+ * Merge the cast class and attribute cast attributes back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributesFromCachedCasts()
+ {
+ $this->mergeAttributesFromClassCasts();
+ $this->mergeAttributesFromAttributeCasts();
+ }
+
+ /**
+ * Merge the a cast class and attribute cast attribute back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributeFromCachedCasts(string $key)
+ {
+ $this->mergeAttributeFromClassCasts($key);
+ $this->mergeAttributeFromAttributeCasts($key);
+ }
+
+ /**
+ * Merge the cast class attributes back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributesFromClassCasts()
+ {
+ foreach ($this->classCastCache as $key => $value) {
+ $this->mergeAttributeFromClassCasts($key);
+ }
+ }
+
+ /**
+ * Merge the cast class attribute back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributeFromClassCasts(string $key): void
+ {
+ if (! isset($this->classCastCache[$key])) {
+ return;
+ }
+
+ $value = $this->classCastCache[$key];
+
+ $caster = $this->resolveCasterClass($key);
+
+ $this->attributes = array_merge(
+ $this->attributes,
+ $caster instanceof CastsInboundAttributes
+ ? [$key => $value]
+ : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes))
+ );
+ }
+
+ /**
+ * Merge the cast class attributes back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributesFromAttributeCasts()
+ {
+ foreach ($this->attributeCastCache as $key => $value) {
+ $this->mergeAttributeFromAttributeCasts($key);
+ }
+ }
+
+ /**
+ * Merge the cast class attribute back into the model.
+ *
+ * @return void
+ */
+ protected function mergeAttributeFromAttributeCasts(string $key): void
+ {
+ if (! isset($this->attributeCastCache[$key])) {
+ return;
+ }
+
+ $value = $this->attributeCastCache[$key];
+
+ $attribute = $this->{Str::camel($key)}();
+
+ if ($attribute->get && ! $attribute->set) {
+ return;
+ }
+
+ $callback = $attribute->set ?: function ($value) use ($key) {
+ $this->attributes[$key] = $value;
+ };
+
+ $this->attributes = array_merge(
+ $this->attributes,
+ $this->normalizeCastClassResponse(
+ $key, $callback($value, $this->attributes)
+ )
+ );
+ }
+
+ /**
+ * Normalize the response from a custom class caster.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return array
+ */
+ protected function normalizeCastClassResponse($key, $value)
+ {
+ return is_array($value) ? $value : [$key => $value];
+ }
+
+ /**
+ * Get all of the current attributes on the model.
+ *
+ * @return array
+ */
+ public function getAttributes()
+ {
+ $this->mergeAttributesFromCachedCasts();
+
+ return $this->attributes;
+ }
+
+ /**
+ * Get all of the current attributes on the model for an insert operation.
+ *
+ * @return array
+ */
+ protected function getAttributesForInsert()
+ {
+ return $this->getAttributes();
}
/**
@@ -965,6 +2067,9 @@ public function setRawAttributes(array $attributes, $sync = false)
$this->syncOriginal();
}
+ $this->classCastCache = [];
+ $this->attributeCastCache = [];
+
return $this;
}
@@ -973,9 +2078,43 @@ public function setRawAttributes(array $attributes, $sync = false)
*
* @param string|null $key
* @param mixed $default
- * @return mixed|array
+ * @return ($key is null ? array : mixed)
*/
public function getOriginal($key = null, $default = null)
+ {
+ return (new static)->setRawAttributes(
+ $this->original, $sync = true
+ )->getOriginalWithoutRewindingModel($key, $default);
+ }
+
+ /**
+ * Get the model's original attribute values.
+ *
+ * @param string|null $key
+ * @param mixed $default
+ * @return ($key is null ? array : mixed)
+ */
+ protected function getOriginalWithoutRewindingModel($key = null, $default = null)
+ {
+ if ($key) {
+ return $this->transformModelValue(
+ $key, Arr::get($this->original, $key, $default)
+ );
+ }
+
+ return (new Collection($this->original))
+ ->mapWithKeys(fn ($value, $key) => [$key => $this->transformModelValue($key, $value)])
+ ->all();
+ }
+
+ /**
+ * Get the model's raw original attribute values.
+ *
+ * @param string|null $key
+ * @param mixed $default
+ * @return ($key is null ? array : mixed)
+ */
+ public function getRawOriginal($key = null, $default = null)
{
return Arr::get($this->original, $key, $default);
}
@@ -983,8 +2122,8 @@ public function getOriginal($key = null, $default = null)
/**
* Get a subset of the model's attributes.
*
- * @param array|mixed $attributes
- * @return array
+ * @param array|mixed $attributes
+ * @return array
*/
public function only($attributes)
{
@@ -997,6 +2136,27 @@ public function only($attributes)
return $results;
}
+ /**
+ * Get all attributes except the given ones.
+ *
+ * @param array|mixed $attributes
+ * @return array
+ */
+ public function except($attributes)
+ {
+ $attributes = is_array($attributes) ? $attributes : func_get_args();
+
+ $results = [];
+
+ foreach ($this->getAttributes() as $key => $value) {
+ if (! in_array($key, $attributes)) {
+ $results[$key] = $this->getAttribute($key);
+ }
+ }
+
+ return $results;
+ }
+
/**
* Sync the original attributes with the current.
*
@@ -1004,7 +2164,7 @@ public function only($attributes)
*/
public function syncOriginal()
{
- $this->original = $this->attributes;
+ $this->original = $this->getAttributes();
return $this;
}
@@ -1023,15 +2183,17 @@ public function syncOriginalAttribute($attribute)
/**
* Sync multiple original attribute with their current values.
*
- * @param array|string $attributes
+ * @param array|string $attributes
* @return $this
*/
public function syncOriginalAttributes($attributes)
{
$attributes = is_array($attributes) ? $attributes : func_get_args();
+ $modelAttributes = $this->getAttributes();
+
foreach ($attributes as $attribute) {
- $this->original[$attribute] = $this->attributes[$attribute];
+ $this->original[$attribute] = $modelAttributes[$attribute];
}
return $this;
@@ -1045,6 +2207,7 @@ public function syncOriginalAttributes($attributes)
public function syncChanges()
{
$this->changes = $this->getDirty();
+ $this->previous = array_intersect_key($this->getRawOriginal(), $this->changes);
return $this;
}
@@ -1052,7 +2215,7 @@ public function syncChanges()
/**
* Determine if the model or any of the given attribute(s) have been modified.
*
- * @param array|string|null $attributes
+ * @param array|string|null $attributes
* @return bool
*/
public function isDirty($attributes = null)
@@ -1063,9 +2226,9 @@ public function isDirty($attributes = null)
}
/**
- * Determine if the model and all the given attribute(s) have remained the same.
+ * Determine if the model or all the given attribute(s) have remained the same.
*
- * @param array|string|null $attributes
+ * @param array|string|null $attributes
* @return bool
*/
public function isClean($attributes = null)
@@ -1074,9 +2237,24 @@ public function isClean($attributes = null)
}
/**
- * Determine if the model or any of the given attribute(s) have been modified.
+ * Discard attribute changes and reset the attributes to their original state.
+ *
+ * @return $this
+ */
+ public function discardChanges()
+ {
+ [$this->attributes, $this->changes, $this->previous] = [$this->original, [], []];
+
+ $this->classCastCache = [];
+ $this->attributeCastCache = [];
+
+ return $this;
+ }
+
+ /**
+ * Determine if the model or any of the given attribute(s) were changed when the model was last saved.
*
- * @param array|string|null $attributes
+ * @param array|string|null $attributes
* @return bool
*/
public function wasChanged($attributes = null)
@@ -1087,10 +2265,10 @@ public function wasChanged($attributes = null)
}
/**
- * Determine if any of the given attributes were changed.
+ * Determine if any of the given attributes were changed when the model was last saved.
*
- * @param array $changes
- * @param array|string|null $attributes
+ * @param array $changes
+ * @param array|string|null $attributes
* @return bool
*/
protected function hasChanges($changes, $attributes = null)
@@ -1115,16 +2293,16 @@ protected function hasChanges($changes, $attributes = null)
}
/**
- * Get the attributes that have been changed since last sync.
+ * Get the attributes that have been changed since the last sync.
*
- * @return array
+ * @return array
*/
public function getDirty()
{
$dirty = [];
foreach ($this->getAttributes() as $key => $value) {
- if (! $this->originalIsEquivalent($key, $value)) {
+ if (! $this->originalIsEquivalent($key)) {
$dirty[$key] = $value;
}
}
@@ -1133,61 +2311,157 @@ public function getDirty()
}
/**
- * Get the attributes that were changed.
+ * Get the attributes that have been changed since the last sync for an update operation.
*
- * @return array
+ * @return array
+ */
+ protected function getDirtyForUpdate()
+ {
+ return $this->getDirty();
+ }
+
+ /**
+ * Get the attributes that were changed when the model was last saved.
+ *
+ * @return array
*/
public function getChanges()
{
return $this->changes;
}
+ /**
+ * Get the attributes that were previously original before the model was last saved.
+ *
+ * @return array
+ */
+ public function getPrevious()
+ {
+ return $this->previous;
+ }
+
/**
* Determine if the new and old values for a given key are equivalent.
*
- * @param string $key
- * @param mixed $current
+ * @param string $key
* @return bool
*/
- public function originalIsEquivalent($key, $current)
+ public function originalIsEquivalent($key)
{
if (! array_key_exists($key, $this->original)) {
return false;
}
- $original = $this->getOriginal($key);
+ $attribute = Arr::get($this->attributes, $key);
+ $original = Arr::get($this->original, $key);
- if ($current === $original) {
+ if ($attribute === $original) {
return true;
- } elseif (is_null($current)) {
+ } elseif (is_null($attribute)) {
+ return false;
+ } elseif ($this->isDateAttribute($key) || $this->isDateCastableWithCustomFormat($key)) {
+ return $this->fromDateTime($attribute) ===
+ $this->fromDateTime($original);
+ } elseif ($this->hasCast($key, ['object', 'collection'])) {
+ return $this->fromJson($attribute) ===
+ $this->fromJson($original);
+ } elseif ($this->hasCast($key, ['real', 'float', 'double'])) {
+ if ($original === null) {
+ return false;
+ }
+
+ return abs($this->castAttribute($key, $attribute) - $this->castAttribute($key, $original)) < PHP_FLOAT_EPSILON * 4;
+ } elseif ($this->isEncryptedCastable($key) && ! empty(static::currentEncrypter()->getPreviousKeys())) {
return false;
- } elseif ($this->isDateAttribute($key)) {
- return $this->fromDateTime($current) ===
- $this->fromDateTime($original);
- } elseif ($this->hasCast($key)) {
- return $this->castAttribute($key, $current) ===
- $this->castAttribute($key, $original);
+ } elseif ($this->hasCast($key, static::$primitiveCastTypes)) {
+ return $this->castAttribute($key, $attribute) ===
+ $this->castAttribute($key, $original);
+ } elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) {
+ return $this->fromJson($attribute) === $this->fromJson($original);
+ } elseif ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsEnumArrayObject::class, AsEnumCollection::class])) {
+ return $this->fromJson($attribute) === $this->fromJson($original);
+ } elseif ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) {
+ if (empty(static::currentEncrypter()->getPreviousKeys())) {
+ return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($original);
+ }
+
+ return false;
+ } elseif ($this->isClassComparable($key)) {
+ return $this->compareClassCastableAttribute($key, $original, $attribute);
}
- return is_numeric($current) && is_numeric($original)
- && strcmp((string) $current, (string) $original) === 0;
+ return is_numeric($attribute) && is_numeric($original)
+ && strcmp((string) $attribute, (string) $original) === 0;
+ }
+
+ /**
+ * Transform a raw model value using mutators, casts, etc.
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ protected function transformModelValue($key, $value)
+ {
+ // If the attribute has a get mutator, we will call that then return what
+ // it returns as the value, which is useful for transforming values on
+ // retrieval from the model to a form that is more useful for usage.
+ if ($this->hasGetMutator($key)) {
+ return $this->mutateAttribute($key, $value);
+ } elseif ($this->hasAttributeGetMutator($key)) {
+ return $this->mutateAttributeMarkedAttribute($key, $value);
+ }
+
+ // If the attribute exists within the cast array, we will convert it to
+ // an appropriate native PHP type dependent upon the associated value
+ // given with the key in the pair. Dayle made this comment line up.
+ if ($this->hasCast($key)) {
+ if (static::preventsAccessingMissingAttributes() &&
+ ! array_key_exists($key, $this->attributes) &&
+ ($this->isEnumCastable($key) ||
+ in_array($this->getCastType($key), static::$primitiveCastTypes))) {
+ $this->throwMissingAttributeExceptionIfApplicable($key);
+ }
+
+ return $this->castAttribute($key, $value);
+ }
+
+ // If the attribute is listed as a date, we will convert it to a DateTime
+ // instance on retrieval, which makes it quite convenient to work with
+ // date fields without having to create a mutator for each property.
+ if ($value !== null
+ && \in_array($key, $this->getDates(), false)) {
+ return $this->asDateTime($value);
+ }
+
+ return $value;
}
/**
* Append attributes to query when building a query.
*
- * @param array|string $attributes
+ * @param array|string $attributes
* @return $this
*/
public function append($attributes)
{
- $this->appends = array_unique(
+ $this->appends = array_values(array_unique(
array_merge($this->appends, is_string($attributes) ? func_get_args() : $attributes)
- );
+ ));
return $this;
}
+ /**
+ * Get the accessors that are being appended to model arrays.
+ *
+ * @return array
+ */
+ public function getAppends()
+ {
+ return $this->appends;
+ }
+
/**
* Set the accessors to append to model arrays.
*
@@ -1201,6 +2475,40 @@ public function setAppends(array $appends)
return $this;
}
+ /**
+ * Merge new appended attributes with existing appended attributes on the model.
+ *
+ * @param array $appends
+ * @return $this
+ */
+ public function mergeAppends(array $appends)
+ {
+ $this->appends = array_values(array_unique(array_merge($this->appends, $appends)));
+
+ return $this;
+ }
+
+ /**
+ * Return whether the accessor attribute has been appended.
+ *
+ * @param string $attribute
+ * @return bool
+ */
+ public function hasAppended($attribute)
+ {
+ return in_array($attribute, $this->getAppends());
+ }
+
+ /**
+ * Remove all appended properties from the model.
+ *
+ * @return $this
+ */
+ public function withoutAppends()
+ {
+ return $this->setAppends([]);
+ }
+
/**
* Get the mutated attributes for a given instance.
*
@@ -1208,26 +2516,33 @@ public function setAppends(array $appends)
*/
public function getMutatedAttributes()
{
- $class = static::class;
-
- if (! isset(static::$mutatorCache[$class])) {
- static::cacheMutatedAttributes($class);
+ if (! isset(static::$mutatorCache[static::class])) {
+ static::cacheMutatedAttributes($this);
}
- return static::$mutatorCache[$class];
+ return static::$mutatorCache[static::class];
}
/**
* Extract and cache all the mutated attributes of a class.
*
- * @param string $class
+ * @param object|string $classOrInstance
* @return void
*/
- public static function cacheMutatedAttributes($class)
+ public static function cacheMutatedAttributes($classOrInstance)
{
- static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) {
- return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match);
- })->all();
+ $reflection = new ReflectionClass($classOrInstance);
+
+ $class = $reflection->getName();
+
+ static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance)))
+ ->mapWithKeys(fn ($match) => [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true])
+ ->all();
+
+ static::$mutatorCache[$class] = (new Collection(static::getMutatorMethods($class)))
+ ->merge($attributeMutatorMethods)
+ ->map(fn ($match) => lcfirst(static::$snakeAttributes ? Str::snake($match) : $match))
+ ->all();
}
/**
@@ -1242,4 +2557,28 @@ protected static function getMutatorMethods($class)
return $matches[1];
}
+
+ /**
+ * Get all of the "Attribute" return typed attribute mutator methods.
+ *
+ * @param mixed $class
+ * @return array
+ */
+ protected static function getAttributeMarkedMutatorMethods($class)
+ {
+ $instance = is_object($class) ? $class : new $class;
+
+ return (new Collection((new ReflectionClass($instance))->getMethods()))->filter(function ($method) use ($instance) {
+ $returnType = $method->getReturnType();
+
+ if ($returnType instanceof ReflectionNamedType &&
+ $returnType->getName() === Attribute::class) {
+ if (is_callable($method->invoke($instance)->get)) {
+ return true;
+ }
+ }
+
+ return false;
+ })->map->name->values()->all();
+ }
}
diff --git a/Eloquent/Concerns/HasEvents.php b/Eloquent/Concerns/HasEvents.php
index a8d65f7344..cc0368e67d 100644
--- a/Eloquent/Concerns/HasEvents.php
+++ b/Eloquent/Concerns/HasEvents.php
@@ -2,9 +2,14 @@
namespace Illuminate\Database\Eloquent\Concerns;
+use Illuminate\Contracts\Events\Dispatcher;
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Events\NullDispatcher;
use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
use InvalidArgumentException;
-use Illuminate\Contracts\Events\Dispatcher;
+use ReflectionClass;
trait HasEvents
{
@@ -13,7 +18,7 @@ trait HasEvents
*
* Allows for object-based events for native Eloquent events.
*
- * @var array
+ * @var array
*/
protected $dispatchesEvents = [];
@@ -22,14 +27,46 @@ trait HasEvents
*
* These are extra user-defined events observers may subscribe to.
*
- * @var array
+ * @var string[]
*/
protected $observables = [];
+ /**
+ * Boot the has event trait for a model.
+ *
+ * @return void
+ */
+ public static function bootHasEvents()
+ {
+ static::whenBooted(fn () => static::observe(static::resolveObserveAttributes()));
+ }
+
+ /**
+ * Resolve the observe class names from the attributes.
+ *
+ * @return array
+ */
+ public static function resolveObserveAttributes()
+ {
+ $reflectionClass = new ReflectionClass(static::class);
+
+ $isEloquentGrandchild = is_subclass_of(static::class, Model::class)
+ && get_parent_class(static::class) !== Model::class;
+
+ return (new Collection($reflectionClass->getAttributes(ObservedBy::class)))
+ ->map(fn ($attribute) => $attribute->getArguments())
+ ->flatten()
+ ->when($isEloquentGrandchild, function (Collection $attributes) {
+ return (new Collection(get_parent_class(static::class)::resolveObserveAttributes()))
+ ->merge($attributes);
+ })
+ ->all();
+ }
+
/**
* Register observers with the model.
*
- * @param object|array|string $classes
+ * @param object|string[]|string $classes
* @return void
*
* @throws \RuntimeException
@@ -46,7 +83,7 @@ public static function observe($classes)
/**
* Register a single observer with the model.
*
- * @param object|string $class
+ * @param object|string $class
* @return void
*
* @throws \RuntimeException
@@ -68,8 +105,8 @@ protected function registerObserver($class)
/**
* Resolve the observer's class name from an object or string.
*
- * @param object|string $class
- * @return string
+ * @param object|string $class
+ * @return class-string
*
* @throws \InvalidArgumentException
*/
@@ -89,15 +126,15 @@ private function resolveObserverClassName($class)
/**
* Get the observable event names.
*
- * @return array
+ * @return string[]
*/
public function getObservableEvents()
{
return array_merge(
[
'retrieved', 'creating', 'created', 'updating', 'updated',
- 'saving', 'saved', 'restoring', 'restored',
- 'deleting', 'deleted', 'forceDeleted',
+ 'saving', 'saved', 'restoring', 'restored', 'replicating',
+ 'trashed', 'deleting', 'deleted', 'forceDeleting', 'forceDeleted',
],
$this->observables
);
@@ -106,7 +143,7 @@ public function getObservableEvents()
/**
* Set the observable event names.
*
- * @param array $observables
+ * @param string[] $observables
* @return $this
*/
public function setObservableEvents(array $observables)
@@ -119,7 +156,7 @@ public function setObservableEvents(array $observables)
/**
* Add an observable event name.
*
- * @param array|mixed $observables
+ * @param string|string[] $observables
* @return void
*/
public function addObservableEvents($observables)
@@ -132,7 +169,7 @@ public function addObservableEvents($observables)
/**
* Remove an observable event name.
*
- * @param array|mixed $observables
+ * @param string|string[] $observables
* @return void
*/
public function removeObservableEvents($observables)
@@ -146,7 +183,7 @@ public function removeObservableEvents($observables)
* Register a model event with the dispatcher.
*
* @param string $event
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
protected static function registerModelEvent($event, $callback)
@@ -193,8 +230,8 @@ protected function fireModelEvent($event, $halt = true)
* Fire a custom model event for the given event.
*
* @param string $event
- * @param string $method
- * @return mixed|null
+ * @param 'until'|'dispatch' $method
+ * @return array|null|void
*/
protected function fireCustomModelEvent($event, $method)
{
@@ -229,7 +266,7 @@ protected function filterModelEventResults($result)
/**
* Register a retrieved model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function retrieved($callback)
@@ -240,7 +277,7 @@ public static function retrieved($callback)
/**
* Register a saving model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function saving($callback)
@@ -251,7 +288,7 @@ public static function saving($callback)
/**
* Register a saved model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function saved($callback)
@@ -262,7 +299,7 @@ public static function saved($callback)
/**
* Register an updating model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function updating($callback)
@@ -273,7 +310,7 @@ public static function updating($callback)
/**
* Register an updated model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function updated($callback)
@@ -284,7 +321,7 @@ public static function updated($callback)
/**
* Register a creating model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function creating($callback)
@@ -295,7 +332,7 @@ public static function creating($callback)
/**
* Register a created model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function created($callback)
@@ -303,10 +340,21 @@ public static function created($callback)
static::registerModelEvent('created', $callback);
}
+ /**
+ * Register a replicating model event with the dispatcher.
+ *
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
+ * @return void
+ */
+ public static function replicating($callback)
+ {
+ static::registerModelEvent('replicating', $callback);
+ }
+
/**
* Register a deleting model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function deleting($callback)
@@ -317,7 +365,7 @@ public static function deleting($callback)
/**
* Register a deleted model event with the dispatcher.
*
- * @param \Closure|string $callback
+ * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback
* @return void
*/
public static function deleted($callback)
@@ -326,7 +374,7 @@ public static function deleted($callback)
}
/**
- * Remove all of the event listeners for the model.
+ * Remove all the event listeners for the model.
*
* @return void
*/
@@ -342,15 +390,25 @@ public static function flushEventListeners()
static::$dispatcher->forget("eloquent.{$event}: ".static::class);
}
- foreach (array_values($instance->dispatchesEvents) as $event) {
+ foreach ($instance->dispatchesEvents as $event) {
static::$dispatcher->forget($event);
}
}
+ /**
+ * Get the event map for the model.
+ *
+ * @return array
+ */
+ public function dispatchesEvents()
+ {
+ return $this->dispatchesEvents;
+ }
+
/**
* Get the event dispatcher instance.
*
- * @return \Illuminate\Contracts\Events\Dispatcher
+ * @return \Illuminate\Contracts\Events\Dispatcher|null
*/
public static function getEventDispatcher()
{
@@ -388,7 +446,9 @@ public static function withoutEvents(callable $callback)
{
$dispatcher = static::getEventDispatcher();
- static::unsetEventDispatcher();
+ if ($dispatcher) {
+ static::setEventDispatcher(new NullDispatcher($dispatcher));
+ }
try {
return $callback();
diff --git a/Eloquent/Concerns/HasGlobalScopes.php b/Eloquent/Concerns/HasGlobalScopes.php
index 97a549f6c7..635ac8d1fe 100644
--- a/Eloquent/Concerns/HasGlobalScopes.php
+++ b/Eloquent/Concerns/HasGlobalScopes.php
@@ -3,32 +3,85 @@
namespace Illuminate\Database\Eloquent\Concerns;
use Closure;
+use Illuminate\Database\Eloquent\Attributes\ScopedBy;
+use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Arr;
+use Illuminate\Support\Collection;
use InvalidArgumentException;
-use Illuminate\Database\Eloquent\Scope;
+use ReflectionAttribute;
+use ReflectionClass;
trait HasGlobalScopes
{
+ /**
+ * Boot the has global scopes trait for a model.
+ *
+ * @return void
+ */
+ public static function bootHasGlobalScopes()
+ {
+ static::addGlobalScopes(static::resolveGlobalScopeAttributes());
+ }
+
+ /**
+ * Resolve the global scope class names from the attributes.
+ *
+ * @return array
+ */
+ public static function resolveGlobalScopeAttributes()
+ {
+ $reflectionClass = new ReflectionClass(static::class);
+
+ $attributes = (new Collection($reflectionClass->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF)));
+
+ foreach ($reflectionClass->getTraits() as $trait) {
+ $attributes->push(...$trait->getAttributes(ScopedBy::class, ReflectionAttribute::IS_INSTANCEOF));
+ }
+
+ return $attributes->map(fn ($attribute) => $attribute->getArguments())
+ ->flatten()
+ ->all();
+ }
+
/**
* Register a new global scope on the model.
*
- * @param \Illuminate\Database\Eloquent\Scope|\Closure|string $scope
- * @param \Closure|null $implementation
+ * @param \Illuminate\Database\Eloquent\Scope|(\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string $scope
+ * @param \Illuminate\Database\Eloquent\Scope|(\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $implementation
* @return mixed
*
* @throws \InvalidArgumentException
*/
- public static function addGlobalScope($scope, Closure $implementation = null)
+ public static function addGlobalScope($scope, $implementation = null)
{
- if (is_string($scope) && ! is_null($implementation)) {
+ if (is_string($scope) && ($implementation instanceof Closure || $implementation instanceof Scope)) {
return static::$globalScopes[static::class][$scope] = $implementation;
} elseif ($scope instanceof Closure) {
return static::$globalScopes[static::class][spl_object_hash($scope)] = $scope;
} elseif ($scope instanceof Scope) {
return static::$globalScopes[static::class][get_class($scope)] = $scope;
+ } elseif (is_string($scope) && class_exists($scope) && is_subclass_of($scope, Scope::class)) {
+ return static::$globalScopes[static::class][$scope] = new $scope;
}
- throw new InvalidArgumentException('Global scope must be an instance of Closure or Scope.');
+ throw new InvalidArgumentException('Global scope must be an instance of Closure or Scope or be a class name of a class extending '.Scope::class);
+ }
+
+ /**
+ * Register multiple global scopes on the model.
+ *
+ * @param array $scopes
+ * @return void
+ */
+ public static function addGlobalScopes(array $scopes)
+ {
+ foreach ($scopes as $key => $scope) {
+ if (is_string($key)) {
+ static::addGlobalScope($key, $scope);
+ } else {
+ static::addGlobalScope($scope);
+ }
+ }
}
/**
@@ -46,7 +99,7 @@ public static function hasGlobalScope($scope)
* Get a global scope registered with the model.
*
* @param \Illuminate\Database\Eloquent\Scope|string $scope
- * @return \Illuminate\Database\Eloquent\Scope|\Closure|null
+ * @return \Illuminate\Database\Eloquent\Scope|(\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null
*/
public static function getGlobalScope($scope)
{
@@ -59,6 +112,27 @@ public static function getGlobalScope($scope)
);
}
+ /**
+ * Get all of the global scopes that are currently registered.
+ *
+ * @return array
+ */
+ public static function getAllGlobalScopes()
+ {
+ return static::$globalScopes;
+ }
+
+ /**
+ * Set the current global scopes.
+ *
+ * @param array $scopes
+ * @return void
+ */
+ public static function setAllGlobalScopes($scopes)
+ {
+ static::$globalScopes = $scopes;
+ }
+
/**
* Get the global scopes for this class instance.
*
diff --git a/Eloquent/Concerns/HasRelationships.php b/Eloquent/Concerns/HasRelationships.php
index cb202f999a..9380eb10e0 100644
--- a/Eloquent/Concerns/HasRelationships.php
+++ b/Eloquent/Concerns/HasRelationships.php
@@ -2,22 +2,28 @@
namespace Illuminate\Database\Eloquent\Concerns;
-use Illuminate\Support\Arr;
-use Illuminate\Support\Str;
-use Illuminate\Database\Eloquent\Model;
+use Closure;
+use Illuminate\Database\ClassMorphViolationException;
+use Illuminate\Database\Eloquent\Attributes\Initialize;
+use Illuminate\Database\Eloquent\Attributes\Touches;
use Illuminate\Database\Eloquent\Builder;
-use Illuminate\Database\Eloquent\Collection;
-use Illuminate\Database\Eloquent\Relations\HasOne;
-use Illuminate\Database\Eloquent\Relations\HasMany;
-use Illuminate\Database\Eloquent\Relations\MorphTo;
-use Illuminate\Database\Eloquent\Relations\MorphOne;
-use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Database\Eloquent\Collection as EloquentCollection;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\PendingHasThroughRelationship;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
-use Illuminate\Database\Eloquent\Relations\MorphMany;
-use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
-use Illuminate\Database\Eloquent\Relations\HasOneThrough;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
+use Illuminate\Database\Eloquent\Relations\HasOne;
+use Illuminate\Database\Eloquent\Relations\HasOneThrough;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
+use Illuminate\Database\Eloquent\Relations\MorphOne;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
+use Illuminate\Database\Eloquent\Relations\Pivot;
+use Illuminate\Database\Eloquent\Relations\Relation;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Str;
trait HasRelationships
{
@@ -35,22 +41,188 @@ trait HasRelationships
*/
protected $touches = [];
+ /**
+ * The relationship autoloader callback.
+ *
+ * @var \Closure|null
+ */
+ protected $relationAutoloadCallback = null;
+
+ /**
+ * The relationship autoloader callback context.
+ *
+ * @var mixed
+ */
+ protected $relationAutoloadContext = null;
+
/**
* The many to many relationship methods.
*
- * @var array
+ * @var string[]
*/
public static $manyMethods = [
'belongsToMany', 'morphToMany', 'morphedByMany',
];
+ /**
+ * The relation resolver callbacks.
+ *
+ * @var array
+ */
+ protected static $relationResolvers = [];
+
+ /**
+ * Initialize the HasRelationships trait.
+ *
+ * @return void
+ */
+ #[Initialize]
+ public function initializeHasRelationships()
+ {
+ if (empty($this->touches)) {
+ $this->touches = static::resolveClassAttribute(Touches::class, 'relations') ?? [];
+ }
+ }
+
+ /**
+ * Get the dynamic relation resolver if defined or inherited, or return null.
+ *
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $class
+ * @param string $key
+ * @return Closure|null
+ */
+ public function relationResolver($class, $key)
+ {
+ if ($resolver = static::$relationResolvers[$class][$key] ?? null) {
+ return $resolver;
+ }
+
+ if ($parent = get_parent_class($class)) {
+ return $this->relationResolver($parent, $key);
+ }
+
+ return null;
+ }
+
+ /**
+ * Define a dynamic relation resolver.
+ *
+ * @param string $name
+ * @param \Closure $callback
+ * @return void
+ */
+ public static function resolveRelationUsing($name, Closure $callback)
+ {
+ static::$relationResolvers = array_replace_recursive(
+ static::$relationResolvers,
+ [static::class => [$name => $callback]]
+ );
+ }
+
+ /**
+ * Determine if a relationship autoloader callback has been defined.
+ *
+ * @return bool
+ */
+ public function hasRelationAutoloadCallback()
+ {
+ return ! is_null($this->relationAutoloadCallback);
+ }
+
+ /**
+ * Define an automatic relationship autoloader callback for this model and its relations.
+ *
+ * @param \Closure $callback
+ * @param mixed $context
+ * @return $this
+ */
+ public function autoloadRelationsUsing(Closure $callback, $context = null)
+ {
+ // Prevent circular relation autoloading...
+ if ($context && $this->relationAutoloadContext === $context) {
+ return $this;
+ }
+
+ $this->relationAutoloadCallback = $callback;
+ $this->relationAutoloadContext = $context;
+
+ foreach ($this->relations as $key => $value) {
+ $this->propagateRelationAutoloadCallbackToRelation($key, $value);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Attempt to autoload the given relationship using the autoload callback.
+ *
+ * @param string $key
+ * @return bool
+ */
+ protected function attemptToAutoloadRelation($key)
+ {
+ if (! $this->hasRelationAutoloadCallback()) {
+ return false;
+ }
+
+ $this->invokeRelationAutoloadCallbackFor($key, []);
+
+ return $this->relationLoaded($key);
+ }
+
+ /**
+ * Invoke the relationship autoloader callback for the given relationships.
+ *
+ * @param string $key
+ * @param array $tuples
+ * @return void
+ */
+ protected function invokeRelationAutoloadCallbackFor($key, $tuples)
+ {
+ $tuples = array_merge([[$key, get_class($this)]], $tuples);
+
+ call_user_func($this->relationAutoloadCallback, $tuples);
+ }
+
+ /**
+ * Propagate the relationship autoloader callback to the given related models.
+ *
+ * @param string $key
+ * @param mixed $models
+ * @return void
+ */
+ protected function propagateRelationAutoloadCallbackToRelation($key, $models)
+ {
+ if (! $this->hasRelationAutoloadCallback() || ! $models) {
+ return;
+ }
+
+ if ($models instanceof Model) {
+ $models = [$models];
+ }
+
+ if (! is_iterable($models)) {
+ return;
+ }
+
+ $callback = fn (array $tuples) => $this->invokeRelationAutoloadCallbackFor($key, $tuples);
+
+ foreach ($models as $model) {
+ $model->autoloadRelationsUsing($callback, $this->relationAutoloadContext);
+ }
+ }
+
/**
* Define a one-to-one relationship.
*
- * @param string $related
- * @param string $foreignKey
- * @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\HasOne
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param string|null $foreignKey
+ * @param string|null $localKey
+ * @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function hasOne($related, $foreignKey = null, $localKey = null)
{
@@ -60,17 +232,20 @@ public function hasOne($related, $foreignKey = null, $localKey = null)
$localKey = $localKey ?: $this->getKeyName();
- return $this->newHasOne($instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey);
+ return $this->newHasOne($instance->newQuery(), $this, $instance->qualifyColumn($foreignKey), $localKey);
}
/**
* Instantiate a new HasOne relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $parent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $parent
* @param string $foreignKey
* @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\HasOne
+ * @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey)
{
@@ -80,40 +255,51 @@ protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localK
/**
* Define a has-one-through relationship.
*
- * @param string $related
- * @param string $through
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param class-string $through
* @param string|null $firstKey
* @param string|null $secondKey
* @param string|null $localKey
* @param string|null $secondLocalKey
- * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough
+ * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough
*/
public function hasOneThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null)
{
- $through = new $through;
+ $through = $this->newRelatedThroughInstance($through);
$firstKey = $firstKey ?: $this->getForeignKey();
$secondKey = $secondKey ?: $through->getForeignKey();
return $this->newHasOneThrough(
- $this->newRelatedInstance($related)->newQuery(), $this, $through,
- $firstKey, $secondKey, $localKey ?: $this->getKeyName(),
- $secondLocalKey ?: $through->getKeyName()
+ $this->newRelatedInstance($related)->newQuery(),
+ $this,
+ $through,
+ $firstKey,
+ $secondKey,
+ $localKey ?: $this->getKeyName(),
+ $secondLocalKey ?: $through->getKeyName(),
);
}
/**
* Instantiate a new HasOneThrough relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $farParent
- * @param \Illuminate\Database\Eloquent\Model $throughParent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $farParent
+ * @param TIntermediateModel $throughParent
* @param string $firstKey
* @param string $secondKey
* @param string $localKey
* @param string $secondLocalKey
- * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough
+ * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough
*/
protected function newHasOneThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey)
{
@@ -123,12 +309,14 @@ protected function newHasOneThrough(Builder $query, Model $farParent, Model $thr
/**
* Define a polymorphic one-to-one relationship.
*
- * @param string $related
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
* @param string $name
- * @param string $type
- * @param string $id
- * @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphOne
+ * @param string|null $type
+ * @param string|null $id
+ * @param string|null $localKey
+ * @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function morphOne($related, $name, $type = null, $id = null, $localKey = null)
{
@@ -136,22 +324,23 @@ public function morphOne($related, $name, $type = null, $id = null, $localKey =
[$type, $id] = $this->getMorphs($name, $type, $id);
- $table = $instance->getTable();
-
$localKey = $localKey ?: $this->getKeyName();
- return $this->newMorphOne($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey);
+ return $this->newMorphOne($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey);
}
/**
* Instantiate a new MorphOne relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $parent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $parent
* @param string $type
* @param string $id
* @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphOne
+ * @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
protected function newMorphOne(Builder $query, Model $parent, $type, $id, $localKey)
{
@@ -161,11 +350,13 @@ protected function newMorphOne(Builder $query, Model $parent, $type, $id, $local
/**
* Define an inverse one-to-one or many relationship.
*
- * @param string $related
- * @param string $foreignKey
- * @param string $ownerKey
- * @param string $relation
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param string|null $foreignKey
+ * @param string|null $ownerKey
+ * @param string|null $relation
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)
{
@@ -185,9 +376,9 @@ public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relat
$foreignKey = Str::snake($relation).'_'.$instance->getKeyName();
}
- // Once we have the foreign key names, we'll just create a new Eloquent query
- // for the related models and returns the relationship instance which will
- // actually be responsible for retrieving and hydrating every relations.
+ // Once we have the foreign key names we'll just create a new Eloquent query
+ // for the related models and return the relationship instance which will
+ // actually be responsible for retrieving and hydrating every relation.
$ownerKey = $ownerKey ?: $instance->getKeyName();
return $this->newBelongsTo(
@@ -198,12 +389,15 @@ public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relat
/**
* Instantiate a new BelongsTo relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $child
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $child
* @param string $foreignKey
* @param string $ownerKey
* @param string $relation
- * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation)
{
@@ -213,11 +407,11 @@ protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $owne
/**
* Define a polymorphic, inverse one-to-one or many relationship.
*
- * @param string $name
- * @param string $type
- * @param string $id
- * @param string $ownerKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+ * @param string|null $name
+ * @param string|null $type
+ * @param string|null $id
+ * @param string|null $ownerKey
+ * @return \Illuminate\Database\Eloquent\Relations\MorphTo<\Illuminate\Database\Eloquent\Model, $this>
*/
public function morphTo($name = null, $type = null, $id = null, $ownerKey = null)
{
@@ -233,9 +427,9 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null
// If the type value is null it is probably safe to assume we're eager loading
// the relationship. In this case we'll just pass in a dummy query where we
// need to remove any eager loads that may already be defined on a model.
- return empty($class = $this->{$type})
- ? $this->morphEagerTo($name, $type, $id, $ownerKey)
- : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey);
+ return is_null($class = $this->getAttributeFromArray($type)) || $class === ''
+ ? $this->morphEagerTo($name, $type, $id, $ownerKey)
+ : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey);
}
/**
@@ -244,8 +438,8 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null
* @param string $name
* @param string $type
* @param string $id
- * @param string $ownerKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+ * @param string|null $ownerKey
+ * @return \Illuminate\Database\Eloquent\Relations\MorphTo<\Illuminate\Database\Eloquent\Model, $this>
*/
protected function morphEagerTo($name, $type, $id, $ownerKey)
{
@@ -261,8 +455,8 @@ protected function morphEagerTo($name, $type, $id, $ownerKey)
* @param string $name
* @param string $type
* @param string $id
- * @param string $ownerKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+ * @param string|null $ownerKey
+ * @return \Illuminate\Database\Eloquent\Relations\MorphTo<\Illuminate\Database\Eloquent\Model, $this>
*/
protected function morphInstanceTo($target, $name, $type, $id, $ownerKey)
{
@@ -278,13 +472,16 @@ protected function morphInstanceTo($target, $name, $type, $id, $ownerKey)
/**
* Instantiate a new MorphTo relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $parent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $parent
* @param string $foreignKey
- * @param string $ownerKey
+ * @param string|null $ownerKey
* @param string $type
* @param string $relation
- * @return \Illuminate\Database\Eloquent\Relations\MorphTo
+ * @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
protected function newMorphTo(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation)
{
@@ -309,18 +506,45 @@ public static function getActualClassNameForMorph($class)
*/
protected function guessBelongsToRelation()
{
- [$one, $two, $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
+ [, , $caller] = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
return $caller['function'];
}
+ /**
+ * Create a pending has-many-through or has-one-through relationship.
+ *
+ * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param string|\Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\HasOne $relationship
+ * @return (
+ * $relationship is string
+ * ? \Illuminate\Database\Eloquent\PendingHasThroughRelationship<\Illuminate\Database\Eloquent\Model, $this>
+ * : (
+ * $relationship is \Illuminate\Database\Eloquent\Relations\HasMany
+ * ? \Illuminate\Database\Eloquent\PendingHasThroughRelationship>
+ * : \Illuminate\Database\Eloquent\PendingHasThroughRelationship>
+ * )
+ * )
+ */
+ public function through($relationship)
+ {
+ if (is_string($relationship)) {
+ $relationship = $this->{$relationship}();
+ }
+
+ return new PendingHasThroughRelationship($this, $relationship);
+ }
+
/**
* Define a one-to-many relationship.
*
- * @param string $related
- * @param string $foreignKey
- * @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param string|null $foreignKey
+ * @param string|null $localKey
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function hasMany($related, $foreignKey = null, $localKey = null)
{
@@ -331,18 +555,21 @@ public function hasMany($related, $foreignKey = null, $localKey = null)
$localKey = $localKey ?: $this->getKeyName();
return $this->newHasMany(
- $instance->newQuery(), $this, $instance->getTable().'.'.$foreignKey, $localKey
+ $instance->newQuery(), $this, $instance->qualifyColumn($foreignKey), $localKey
);
}
/**
* Instantiate a new HasMany relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $parent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $parent
* @param string $foreignKey
* @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
protected function newHasMany(Builder $query, Model $parent, $foreignKey, $localKey)
{
@@ -352,25 +579,32 @@ protected function newHasMany(Builder $query, Model $parent, $foreignKey, $local
/**
* Define a has-many-through relationship.
*
- * @param string $related
- * @param string $through
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param class-string $through
* @param string|null $firstKey
* @param string|null $secondKey
* @param string|null $localKey
* @param string|null $secondLocalKey
- * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
+ * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
public function hasManyThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null)
{
- $through = new $through;
+ $through = $this->newRelatedThroughInstance($through);
$firstKey = $firstKey ?: $this->getForeignKey();
$secondKey = $secondKey ?: $through->getForeignKey();
return $this->newHasManyThrough(
- $this->newRelatedInstance($related)->newQuery(), $this, $through,
- $firstKey, $secondKey, $localKey ?: $this->getKeyName(),
+ $this->newRelatedInstance($related)->newQuery(),
+ $this,
+ $through,
+ $firstKey,
+ $secondKey,
+ $localKey ?: $this->getKeyName(),
$secondLocalKey ?: $through->getKeyName()
);
}
@@ -378,14 +612,18 @@ public function hasManyThrough($related, $through, $firstKey = null, $secondKey
/**
* Instantiate a new HasManyThrough relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $farParent
- * @param \Illuminate\Database\Eloquent\Model $throughParent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $farParent
+ * @param TIntermediateModel $throughParent
* @param string $firstKey
* @param string $secondKey
* @param string $localKey
* @param string $secondLocalKey
- * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
+ * @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
protected function newHasManyThrough(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey)
{
@@ -395,12 +633,14 @@ protected function newHasManyThrough(Builder $query, Model $farParent, Model $th
/**
* Define a polymorphic one-to-many relationship.
*
- * @param string $related
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
* @param string $name
- * @param string $type
- * @param string $id
- * @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+ * @param string|null $type
+ * @param string|null $id
+ * @param string|null $localKey
+ * @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function morphMany($related, $name, $type = null, $id = null, $localKey = null)
{
@@ -411,22 +651,23 @@ public function morphMany($related, $name, $type = null, $id = null, $localKey =
// get the table and create the relationship instances for the developers.
[$type, $id] = $this->getMorphs($name, $type, $id);
- $table = $instance->getTable();
-
$localKey = $localKey ?: $this->getKeyName();
- return $this->newMorphMany($instance->newQuery(), $this, $table.'.'.$type, $table.'.'.$id, $localKey);
+ return $this->newMorphMany($instance->newQuery(), $this, $instance->qualifyColumn($type), $instance->qualifyColumn($id), $localKey);
}
/**
* Instantiate a new MorphMany relationship.
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @param \Illuminate\Database\Eloquent\Model $parent
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param TDeclaringModel $parent
* @param string $type
* @param string $id
* @param string $localKey
- * @return \Illuminate\Database\Eloquent\Relations\MorphMany
+ * @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
protected function newMorphMany(Builder $query, Model $parent, $type, $id, $localKey)
{
@@ -436,18 +677,26 @@ protected function newMorphMany(Builder $query, Model $parent, $type, $id, $loca
/**
* Define a many-to-many relationship.
*
- * @param string $related
- * @param string $table
- * @param string $foreignPivotKey
- * @param string $relatedPivotKey
- * @param string $parentKey
- * @param string $relatedKey
- * @param string $relation
- * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+ * @template TRelatedModel of \Illuminate\Database\Eloquent\Model
+ *
+ * @param class-string $related
+ * @param string|class-string<\Illuminate\Database\Eloquent\Model>|null $table
+ * @param string|null $foreignPivotKey
+ * @param string|null $relatedPivotKey
+ * @param string|null $parentKey
+ * @param string|null $relatedKey
+ * @param string|null $relation
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany