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 */ - public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, - $parentKey = null, $relatedKey = null, $relation = null) - { + public function belongsToMany( + $related, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null, + $relation = null, + ) { // If no relationship name was passed, we will pull backtraces to get the // name of the calling function. We will use that function name as the // title of this relation since that is a great convention to apply. @@ -472,49 +721,74 @@ public function belongsToMany($related, $table = null, $foreignPivotKey = null, } return $this->newBelongsToMany( - $instance->newQuery(), $this, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), $relation + $instance->newQuery(), + $this, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey ?: $this->getKeyName(), + $relatedKey ?: $instance->getKeyName(), + $relation, ); } /** * Instantiate a new BelongsToMany relationship. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent - * @param string $table + * @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|class-string<\Illuminate\Database\Eloquent\Model> $table * @param string $foreignPivotKey * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @param string|null $relationName + * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ - protected function newBelongsToMany(Builder $query, Model $parent, $table, $foreignPivotKey, $relatedPivotKey, - $parentKey, $relatedKey, $relationName = null) - { + protected function newBelongsToMany( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + ) { return new BelongsToMany($query, $parent, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, $relationName); } /** * Define a polymorphic many-to-many relationship. * - * @param string $related + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $related * @param string $name - * @param string $table - * @param string $foreignPivotKey - * @param string $relatedPivotKey - * @param string $parentKey - * @param string $relatedKey + * @param string|null $table + * @param string|null $foreignPivotKey + * @param string|null $relatedPivotKey + * @param string|null $parentKey + * @param string|null $relatedKey + * @param string|null $relation * @param bool $inverse - * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany */ - public function morphToMany($related, $name, $table = null, $foreignPivotKey = null, - $relatedPivotKey = null, $parentKey = null, - $relatedKey = null, $inverse = false) - { - $caller = $this->guessBelongsToManyRelation(); + public function morphToMany( + $related, + $name, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null, + $relation = null, + $inverse = false, + ) { + $relation = $relation ?: $this->guessBelongsToManyRelation(); // First, we will need to determine the foreign key and "other key" for the // relationship. Once we have determined the keys we will make the query @@ -525,9 +799,9 @@ public function morphToMany($related, $name, $table = null, $foreignPivotKey = n $relatedPivotKey = $relatedPivotKey ?: $instance->getForeignKey(); - // Now we're ready to create a new query builder for this related model and - // the relationship instances for this relation. This relations will set - // appropriate query constraints then entirely manages the hydrations. + // Now we're ready to create a new query builder for the related model and + // the relationship instances for this relation. This relation will set + // appropriate query constraints then entirely manage the hydrations. if (! $table) { $words = preg_split('/(_)/u', $name, -1, PREG_SPLIT_DELIM_CAPTURE); @@ -537,50 +811,88 @@ public function morphToMany($related, $name, $table = null, $foreignPivotKey = n } return $this->newMorphToMany( - $instance->newQuery(), $this, $name, $table, - $foreignPivotKey, $relatedPivotKey, $parentKey ?: $this->getKeyName(), - $relatedKey ?: $instance->getKeyName(), $caller, $inverse + $instance->newQuery(), + $this, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey ?: $this->getKeyName(), + $relatedKey ?: $instance->getKeyName(), + $relation, + $inverse, ); } /** * Instantiate a new MorphToMany 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 $name * @param string $table * @param string $foreignPivotKey * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName + * @param string|null $relationName * @param bool $inverse - * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany */ - protected function newMorphToMany(Builder $query, Model $parent, $name, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey, $relatedKey, - $relationName = null, $inverse = false) - { - return new MorphToMany($query, $parent, $name, $table, $foreignPivotKey, $relatedPivotKey, $parentKey, $relatedKey, - $relationName, $inverse); + protected function newMorphToMany( + Builder $query, + Model $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + $inverse = false, + ) { + return new MorphToMany( + $query, + $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName, + $inverse, + ); } /** * Define a polymorphic, inverse many-to-many relationship. * - * @param string $related + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $related * @param string $name - * @param string $table - * @param string $foreignPivotKey - * @param string $relatedPivotKey - * @param string $parentKey - * @param string $relatedKey - * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + * @param string|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\MorphToMany */ - public function morphedByMany($related, $name, $table = null, $foreignPivotKey = null, - $relatedPivotKey = null, $parentKey = null, $relatedKey = null) - { + public function morphedByMany( + $related, + $name, + $table = null, + $foreignPivotKey = null, + $relatedPivotKey = null, + $parentKey = null, + $relatedKey = null, + $relation = null, + ) { $foreignPivotKey = $foreignPivotKey ?: $this->getForeignKey(); // For the inverse of the polymorphic many-to-many relations, we will change @@ -589,8 +901,15 @@ public function morphedByMany($related, $name, $table = null, $foreignPivotKey = $relatedPivotKey = $relatedPivotKey ?: $name.'_id'; return $this->morphToMany( - $related, $name, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey, $relatedKey, true + $related, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relation, + true, ); } @@ -608,7 +927,7 @@ protected function guessBelongsToManyRelation() ); }); - return ! is_null($caller) ? $caller['function'] : null; + return $caller['function'] ?? null; } /** @@ -624,8 +943,9 @@ public function joiningTable($related, $instance = null) // sorted alphabetically and concatenated with an underscore, so we can // just sort the models and join them together to get the table name. $segments = [ - $instance ? $instance->joiningTableSegment() - : Str::snake(class_basename($related)), + $instance + ? $instance->joiningTableSegment() + : Str::snake(class_basename($related)), $this->joiningTableSegment(), ]; @@ -655,7 +975,7 @@ public function joiningTableSegment() */ public function touches($relation) { - return in_array($relation, $this->touches); + return in_array($relation, $this->getTouchedRelations()); } /** @@ -665,19 +985,19 @@ public function touches($relation) */ public function touchOwners() { - foreach ($this->touches as $relation) { - $this->$relation()->touch(); + $this->withoutRecursion(function () { + foreach ($this->getTouchedRelations() as $relation) { + $this->$relation()->touch(); - if ($this->$relation instanceof self) { - $this->$relation->fireModelEvent('saved', false); + if ($this->$relation instanceof self) { + $this->$relation->fireModelEvent('saved', false); - $this->$relation->touchOwners(); - } elseif ($this->$relation instanceof Collection) { - $this->$relation->each(function (Model $relation) { - $relation->touchOwners(); - }); + $this->$relation->touchOwners(); + } elseif ($this->$relation instanceof EloquentCollection) { + $this->$relation->each->touchOwners(); + } } - } + }); } /** @@ -697,6 +1017,8 @@ protected function getMorphs($name, $type, $id) * Get the class name for polymorphic relations. * * @return string + * + * @throws \Illuminate\Database\ClassMorphViolationException */ public function getMorphClass() { @@ -706,24 +1028,47 @@ public function getMorphClass() return array_search(static::class, $morphMap, true); } + if (static::class === Pivot::class) { + return static::class; + } + + if (Relation::requiresMorphMap()) { + throw new ClassMorphViolationException($this); + } + return static::class; } /** * Create a new model instance for a related model. * - * @param string $class - * @return mixed + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $class + * @return TRelatedModel */ protected function newRelatedInstance($class) { return tap(new $class, function ($instance) { if (! $instance->getConnectionName()) { - $instance->setConnection($this->connection); + $instance->setConnection($this->getConnectionName()); } }); } + /** + * Create a new model instance for a related "through" model. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $class + * @return TRelatedModel + */ + protected function newRelatedThroughInstance($class) + { + return new $class; + } + /** * Get all the loaded relations for the instance. * @@ -767,6 +1112,8 @@ public function setRelation($relation, $value) { $this->relations[$relation] = $value; + $this->propagateRelationAutoloadCallbackToRelation($relation, $value); + return $this; } @@ -796,6 +1143,42 @@ public function setRelations(array $relations) return $this; } + /** + * Enable relationship autoloading for this model. + * + * @return $this + */ + public function withRelationshipAutoloading() + { + $this->newCollection([$this])->withRelationshipAutoloading(); + + return $this; + } + + /** + * Duplicate the instance and unset all the loaded relations. + * + * @return $this + */ + public function withoutRelations() + { + $model = clone $this; + + return $model->unsetRelations(); + } + + /** + * Unset all the loaded relations for the instance. + * + * @return $this + */ + public function unsetRelations() + { + $this->relations = []; + + return $this; + } + /** * Get the relationships that are touched on save. * diff --git a/Eloquent/Concerns/HasTimestamps.php b/Eloquent/Concerns/HasTimestamps.php index 6c44a73d6d..b16fe685c6 100644 --- a/Eloquent/Concerns/HasTimestamps.php +++ b/Eloquent/Concerns/HasTimestamps.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Eloquent\Concerns; +use Illuminate\Database\Eloquent\Attributes\Initialize; +use Illuminate\Database\Eloquent\Attributes\Table; use Illuminate\Support\Facades\Date; trait HasTimestamps @@ -13,13 +15,42 @@ trait HasTimestamps */ public $timestamps = true; + /** + * The list of models classes that have timestamps temporarily disabled. + * + * @var array + */ + protected static $ignoreTimestampsOn = []; + + /** + * Initialize the HasTimestamps trait. + * + * @return void + */ + #[Initialize] + public function initializeHasTimestamps() + { + if ($this->timestamps === true) { + if (($table = static::resolveClassAttribute(Table::class)) && $table->timestamps !== null) { + $this->timestamps = $table->timestamps; + } + } + } + /** * Update the model's update timestamp. * + * @param string|null $attribute * @return bool */ - public function touch() + public function touch($attribute = null) { + if ($attribute) { + $this->$attribute = $this->freshTimestamp(); + + return $this->save(); + } + if (! $this->usesTimestamps()) { return false; } @@ -29,23 +60,39 @@ public function touch() return $this->save(); } + /** + * Update the model's update timestamp without raising any events. + * + * @param string|null $attribute + * @return bool + */ + public function touchQuietly($attribute = null) + { + return static::withoutEvents(fn () => $this->touch($attribute)); + } + /** * Update the creation and update timestamps. * - * @return void + * @return $this */ - protected function updateTimestamps() + public function updateTimestamps() { $time = $this->freshTimestamp(); - if (! is_null(static::UPDATED_AT) && ! $this->isDirty(static::UPDATED_AT)) { + $updatedAtColumn = $this->getUpdatedAtColumn(); + + if (! is_null($updatedAtColumn) && ! $this->isDirty($updatedAtColumn)) { $this->setUpdatedAt($time); } - if (! $this->exists && ! is_null(static::CREATED_AT) && - ! $this->isDirty(static::CREATED_AT)) { + $createdAtColumn = $this->getCreatedAtColumn(); + + if (! $this->exists && ! is_null($createdAtColumn) && ! $this->isDirty($createdAtColumn)) { $this->setCreatedAt($time); } + + return $this; } /** @@ -56,7 +103,7 @@ protected function updateTimestamps() */ public function setCreatedAt($value) { - $this->{static::CREATED_AT} = $value; + $this->{$this->getCreatedAtColumn()} = $value; return $this; } @@ -69,7 +116,7 @@ public function setCreatedAt($value) */ public function setUpdatedAt($value) { - $this->{static::UPDATED_AT} = $value; + $this->{$this->getUpdatedAtColumn()} = $value; return $this; } @@ -101,13 +148,13 @@ public function freshTimestampString() */ public function usesTimestamps() { - return $this->timestamps; + return $this->timestamps && ! static::isIgnoringTimestamps($this::class); } /** * Get the name of the "created at" column. * - * @return string + * @return string|null */ public function getCreatedAtColumn() { @@ -117,10 +164,86 @@ public function getCreatedAtColumn() /** * Get the name of the "updated at" column. * - * @return string + * @return string|null */ public function getUpdatedAtColumn() { return static::UPDATED_AT; } + + /** + * Get the fully-qualified "created at" column. + * + * @return string|null + */ + public function getQualifiedCreatedAtColumn() + { + $column = $this->getCreatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; + } + + /** + * Get the fully-qualified "updated at" column. + * + * @return string|null + */ + public function getQualifiedUpdatedAtColumn() + { + $column = $this->getUpdatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; + } + + /** + * Disable timestamps for the current class during the given callback scope. + * + * @param callable $callback + * @return mixed + */ + public static function withoutTimestamps(callable $callback) + { + return static::withoutTimestampsOn([static::class], $callback); + } + + /** + * Disable timestamps for the given model classes during the given callback scope. + * + * @param array $models + * @param callable $callback + * @return mixed + */ + public static function withoutTimestampsOn($models, $callback) + { + static::$ignoreTimestampsOn = array_values(array_merge(static::$ignoreTimestampsOn, $models)); + + try { + return $callback(); + } finally { + foreach ($models as $model) { + if (($key = array_search($model, static::$ignoreTimestampsOn, true)) !== false) { + unset(static::$ignoreTimestampsOn[$key]); + } + } + } + } + + /** + * Determine if the given model is ignoring timestamps / touches. + * + * @param string|null $class + * @return bool + */ + public static function isIgnoringTimestamps($class = null) + { + $class ??= static::class; + + foreach (static::$ignoreTimestampsOn as $ignoredClass) { + if ($class === $ignoredClass || is_subclass_of($class, $ignoredClass)) { + return true; + } + } + + return false; + } } diff --git a/Eloquent/Concerns/HasUlids.php b/Eloquent/Concerns/HasUlids.php new file mode 100644 index 0000000000..344f97338a --- /dev/null +++ b/Eloquent/Concerns/HasUlids.php @@ -0,0 +1,31 @@ +usesUniqueIds; + } + + /** + * Generate unique keys for the model. + * + * @return void + */ + public function setUniqueIds() + { + foreach ($this->uniqueIds() as $column) { + if (empty($this->{$column})) { + $this->{$column} = $this->newUniqueId(); + } + } + } + + /** + * Generate a new key for the model. + * + * @return string + */ + public function newUniqueId() + { + return null; + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds() + { + return []; + } +} diff --git a/Eloquent/Concerns/HasUniqueStringIds.php b/Eloquent/Concerns/HasUniqueStringIds.php new file mode 100644 index 0000000000..324961f2b2 --- /dev/null +++ b/Eloquent/Concerns/HasUniqueStringIds.php @@ -0,0 +1,108 @@ +usesUniqueIds = true; + } + + /** + * Get the columns that should receive a unique identifier. + * + * @return array + */ + public function uniqueIds() + { + return $this->usesUniqueIds() ? [$this->getKeyName()] : parent::uniqueIds(); + } + + /** + * Retrieve the model for a bound value. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $query + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Contracts\Database\Eloquent\Builder + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function resolveRouteBindingQuery($query, $value, $field = null) + { + if ($field && in_array($field, $this->uniqueIds()) && ! $this->isValidUniqueId($value)) { + $this->handleInvalidUniqueId($value, $field); + } + + if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! $this->isValidUniqueId($value)) { + $this->handleInvalidUniqueId($value, $field); + } + + return parent::resolveRouteBindingQuery($query, $value, $field); + } + + /** + * Get the auto-incrementing key type. + * + * @return string + */ + public function getKeyType() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return 'string'; + } + + return parent::getKeyType(); + } + + /** + * Get the value indicating whether the IDs are incrementing. + * + * @return bool + */ + public function getIncrementing() + { + if (in_array($this->getKeyName(), $this->uniqueIds())) { + return false; + } + + return parent::getIncrementing(); + } + + /** + * Throw an exception for the given invalid unique ID. + * + * @param mixed $value + * @param string|null $field + * @return never + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + protected function handleInvalidUniqueId($value, $field) + { + throw (new ModelNotFoundException)->setModel(get_class($this), $value); + } +} diff --git a/Eloquent/Concerns/HasUuids.php b/Eloquent/Concerns/HasUuids.php new file mode 100644 index 0000000000..89d40f829a --- /dev/null +++ b/Eloquent/Concerns/HasUuids.php @@ -0,0 +1,31 @@ + */ protected $hidden = []; /** * The attributes that should be visible in serialization. * - * @var array + * @var array */ protected $visible = []; + /** + * Initialize the HidesAttributes trait. + * + * @return void + */ + #[Initialize] + public function initializeHidesAttributes() + { + if (empty($this->hidden)) { + $this->hidden = static::resolveClassAttribute(Hidden::class, 'columns') ?? []; + } + + if (empty($this->visible)) { + $this->visible = static::resolveClassAttribute(Visible::class, 'columns') ?? []; + } + } + /** * Get the hidden attributes for the model. * - * @return array + * @return array */ public function getHidden() { @@ -31,7 +52,7 @@ public function getHidden() /** * Set the hidden attributes for the model. * - * @param array $hidden + * @param array $hidden * @return $this */ public function setHidden(array $hidden) @@ -42,22 +63,22 @@ public function setHidden(array $hidden) } /** - * Add hidden attributes for the model. + * Merge new hidden attributes with existing hidden attributes on the model. * - * @param array|string|null $attributes - * @return void + * @param array $hidden + * @return $this */ - public function addHidden($attributes = null) + public function mergeHidden(array $hidden) { - $this->hidden = array_merge( - $this->hidden, is_array($attributes) ? $attributes : func_get_args() - ); + $this->hidden = array_values(array_unique(array_merge($this->hidden, $hidden))); + + return $this; } /** * Get the visible attributes for the model. * - * @return array + * @return array */ public function getVisible() { @@ -67,7 +88,7 @@ public function getVisible() /** * Set the visible attributes for the model. * - * @param array $visible + * @param array $visible * @return $this */ public function setVisible(array $visible) @@ -78,49 +99,73 @@ public function setVisible(array $visible) } /** - * Add visible attributes for the model. + * Merge new visible attributes with existing visible attributes on the model. * - * @param array|string|null $attributes - * @return void + * @param array $visible + * @return $this */ - public function addVisible($attributes = null) + public function mergeVisible(array $visible) { - $this->visible = array_merge( - $this->visible, is_array($attributes) ? $attributes : func_get_args() - ); + $this->visible = array_values(array_unique(array_merge($this->visible, $visible))); + + return $this; } /** * Make the given, typically hidden, attributes visible. * - * @param array|string $attributes + * @param array|string|null $attributes * @return $this */ public function makeVisible($attributes) { - $this->hidden = array_diff($this->hidden, (array) $attributes); + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + $this->hidden = array_diff($this->hidden, $attributes); if (! empty($this->visible)) { - $this->addVisible($attributes); + $this->visible = array_values(array_unique(array_merge($this->visible, $attributes))); } return $this; } + /** + * Make the given, typically hidden, attributes visible if the given truth test passes. + * + * @param bool|\Closure $condition + * @param array|string|null $attributes + * @return $this + */ + public function makeVisibleIf($condition, $attributes) + { + return value($condition, $this) ? $this->makeVisible($attributes) : $this; + } + /** * Make the given, typically visible, attributes hidden. * - * @param array|string $attributes + * @param array|string|null $attributes * @return $this */ public function makeHidden($attributes) { - $attributes = (array) $attributes; - - $this->visible = array_diff($this->visible, $attributes); - - $this->hidden = array_unique(array_merge($this->hidden, $attributes)); + $this->hidden = array_values(array_unique(array_merge( + $this->hidden, is_array($attributes) ? $attributes : func_get_args() + ))); return $this; } + + /** + * Make the given, typically visible, attributes hidden if the given truth test passes. + * + * @param bool|\Closure $condition + * @param array|string|null $attributes + * @return $this + */ + public function makeHiddenIf($condition, $attributes) + { + return value($condition, $this) ? $this->makeHidden($attributes) : $this; + } } diff --git a/Eloquent/Concerns/PreventsCircularRecursion.php b/Eloquent/Concerns/PreventsCircularRecursion.php new file mode 100644 index 0000000000..85aa66d9ce --- /dev/null +++ b/Eloquent/Concerns/PreventsCircularRecursion.php @@ -0,0 +1,107 @@ +> + */ + protected static $recursionCache; + + /** + * Prevent a method from being called multiple times on the same object within the same call stack. + * + * @param callable $callback + * @param mixed $default + * @return mixed + */ + protected function withoutRecursion($callback, $default = null) + { + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 2); + + $onceable = Onceable::tryFromTrace($trace, $callback); + + if (is_null($onceable)) { + return call_user_func($callback); + } + + $stack = static::getRecursiveCallStack($this); + + if (array_key_exists($onceable->hash, $stack)) { + return is_callable($stack[$onceable->hash]) + ? static::setRecursiveCallValue($this, $onceable->hash, call_user_func($stack[$onceable->hash])) + : $stack[$onceable->hash]; + } + + try { + static::setRecursiveCallValue($this, $onceable->hash, $default); + + return call_user_func($onceable->callable); + } finally { + static::clearRecursiveCallValue($this, $onceable->hash); + } + } + + /** + * Remove an entry from the recursion cache for an object. + * + * @param object $object + * @param string $hash + */ + protected static function clearRecursiveCallValue($object, string $hash) + { + if ($stack = Arr::except(static::getRecursiveCallStack($object), $hash)) { + static::getRecursionCache()->offsetSet($object, $stack); + } elseif (static::getRecursionCache()->offsetExists($object)) { + static::getRecursionCache()->offsetUnset($object); + } + } + + /** + * Get the stack of methods being called recursively for the current object. + * + * @param object $object + * @return array + */ + protected static function getRecursiveCallStack($object): array + { + return static::getRecursionCache()->offsetExists($object) + ? static::getRecursionCache()->offsetGet($object) + : []; + } + + /** + * Get the current recursion cache being used by the model. + * + * @return WeakMap + */ + protected static function getRecursionCache() + { + return static::$recursionCache ??= new WeakMap(); + } + + /** + * Set a value in the recursion cache for the given object and method. + * + * @param object $object + * @param string $hash + * @param mixed $value + * @return mixed + */ + protected static function setRecursiveCallValue($object, string $hash, $value) + { + static::getRecursionCache()->offsetSet( + $object, + tap(static::getRecursiveCallStack($object), fn (&$stack) => $stack[$hash] = $value), + ); + + return static::getRecursiveCallStack($object)[$hash]; + } +} diff --git a/Eloquent/Concerns/QueriesRelationships.php b/Eloquent/Concerns/QueriesRelationships.php index be143214a1..6072dcff3d 100644 --- a/Eloquent/Concerns/QueriesRelationships.php +++ b/Eloquent/Concerns/QueriesRelationships.php @@ -2,45 +2,60 @@ namespace Illuminate\Database\Eloquent\Concerns; +use BadMethodCallException; use Closure; -use RuntimeException; -use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Query\Expression; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\RelationNotFoundException; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Database\Query\Expression; +use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Str; +use InvalidArgumentException; +use function Illuminate\Support\enum_value; + +/** @mixin \Illuminate\Database\Eloquent\Builder */ trait QueriesRelationships { /** * Add a relationship count / exists condition to the query. * - * @param string $relation + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation * @param string $operator - * @param int $count + * @param \Illuminate\Contracts\Database\Query\Expression|int $count * @param string $boolean - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback + * @return $this + * + * @throws \RuntimeException */ - public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) + public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { - if (strpos($relation, '.') !== false) { - return $this->hasNested($relation, $operator, $count, $boolean, $callback); - } + if (is_string($relation)) { + if (str_contains($relation, '.')) { + return $this->hasNested($relation, $operator, $count, $boolean, $callback); + } - $relation = $this->getRelationWithoutConstraints($relation); + $relation = $this->getRelationWithoutConstraints($relation); + } if ($relation instanceof MorphTo) { - throw new RuntimeException('has() and whereHas() do not support MorphTo relationships.'); + return $this->hasMorph($relation, ['*'], $operator, $count, $boolean, $callback); } // If we only need to check for the existence of the relation, then we can optimize // the subquery to only run a "where exists" clause instead of this full "count" // clause. This will make these queries run much faster compared with a count. $method = $this->canUseExistsForExistenceCheck($operator, $count) - ? 'getRelationExistenceQuery' - : 'getRelationExistenceCountQuery'; + ? 'getRelationExistenceQuery' + : 'getRelationExistenceCountQuery'; $hasQuery = $relation->{$method}( $relation->getRelated()->newQueryWithoutRelationships(), $this @@ -65,15 +80,17 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', C * * @param string $relations * @param string $operator - * @param int $count + * @param \Illuminate\Contracts\Database\Query\Expression|int $count * @param string $boolean - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @param (\Closure(\Illuminate\Database\Eloquent\Builder<*>): mixed)|null $callback + * @return $this */ protected function hasNested($relations, $operator = '>=', $count = 1, $boolean = 'and', $callback = null) { $relations = explode('.', $relations); + $initialRelations = [...$relations]; + $doesntHave = $operator === '<' && $count === 1; if ($doesntHave) { @@ -81,7 +98,14 @@ protected function hasNested($relations, $operator = '>=', $count = 1, $boolean $count = 1; } - $closure = function ($q) use (&$closure, &$relations, $operator, $count, $callback) { + $closure = function ($q) use (&$closure, &$relations, $operator, $count, $callback, $initialRelations) { + // If the same closure is called multiple times, reset the relation array to loop through them again... + if ($count === 1 && empty($relations)) { + $relations = [...$initialRelations]; + + array_shift($relations); + } + // In order to nest "has", we need to add count relation constraints on the // callback Closure. We'll do this by simply passing the Closure its own // reference to itself so it calls itself recursively on each segment. @@ -96,10 +120,10 @@ protected function hasNested($relations, $operator = '>=', $count = 1, $boolean /** * Add a relationship count / exists condition to the query with an "or". * - * @param string $relation + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *>|string $relation * @param string $operator - * @param int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ public function orHas($relation, $operator = '>=', $count = 1) { @@ -109,12 +133,14 @@ public function orHas($relation, $operator = '>=', $count = 1) /** * Add a relationship count / exists condition to the query. * - * @param string $relation + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation * @param string $boolean - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback + * @return $this */ - public function doesntHave($relation, $boolean = 'and', Closure $callback = null) + public function doesntHave($relation, $boolean = 'and', ?Closure $callback = null) { return $this->has($relation, '<', 1, $boolean, $callback); } @@ -122,8 +148,8 @@ public function doesntHave($relation, $boolean = 'and', Closure $callback = null /** * Add a relationship count / exists condition to the query with an "or". * - * @param string $relation - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *>|string $relation + * @return $this */ public function orDoesntHave($relation) { @@ -133,27 +159,48 @@ public function orDoesntHave($relation) /** * Add a relationship count / exists condition to the query with where clauses. * - * @param string $relation - * @param \Closure|null $callback + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback * @param string $operator - * @param int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ - public function whereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function whereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->has($relation, $operator, $count, 'and', $callback); } + /** + * Add a relationship count / exists condition to the query with where clauses. + * + * Also load the relationship with the same condition. + * + * @param string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>): mixed)|null $callback + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this + */ + public function withWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) + { + return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count) + ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation); + } + /** * Add a relationship count / exists condition to the query with where clauses and an "or". * - * @param string $relation - * @param \Closure $callback - * @param string $operator - * @param int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ - public function orWhereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function orWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->has($relation, $operator, $count, 'or', $callback); } @@ -161,11 +208,13 @@ public function orWhereHas($relation, Closure $callback = null, $operator = '>=' /** * Add a relationship count / exists condition to the query with where clauses. * - * @param string $relation - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback + * @return $this */ - public function whereDoesntHave($relation, Closure $callback = null) + public function whereDoesntHave($relation, ?Closure $callback = null) { return $this->doesntHave($relation, 'and', $callback); } @@ -173,22 +222,630 @@ public function whereDoesntHave($relation, Closure $callback = null) /** * Add a relationship count / exists condition to the query with where clauses and an "or". * - * @param string $relation - * @param \Closure $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|null $callback + * @return $this */ - public function orWhereDoesntHave($relation, Closure $callback = null) + public function orWhereDoesntHave($relation, ?Closure $callback = null) { return $this->doesntHave($relation, 'or', $callback); } /** - * Add subselect queries to count the relations. + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @param string $boolean + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @return $this + */ + public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + $types = (array) $types; + + $checkMorphNull = $types === ['*'] + && (($operator === '<' && $count >= 1) + || ($operator === '<=' && $count >= 0) + || ($operator === '=' && $count === 0) + || ($operator === '!=' && $count >= 1)); + + if ($types === ['*']) { + $types = $this->model->newModelQuery()->distinct()->pluck($relation->getMorphType()) + ->filter() + ->map(fn ($item) => enum_value($item)) + ->all(); + } + + if (empty($types)) { + return $this->where(new Expression('0'), $operator, $count, $boolean); + } + + foreach ($types as &$type) { + $type = Relation::getMorphedModel($type) ?? $type; + } + + return $this->where(function ($query) use ($relation, $callback, $operator, $count, $types, $checkMorphNull) { + foreach ($types as $type) { + $query->orWhere(function ($query) use ($relation, $callback, $operator, $count, $type) { + $belongsTo = $this->getBelongsToRelation($relation, $type); + + if ($callback) { + $callback = function ($query) use ($callback, $type) { + return $callback($query, $type); + }; + } + + $query->where($this->qualifyColumn($relation->getMorphType()), '=', (new $type)->getMorphClass()) + ->whereHas($belongsTo, $callback, $operator, $count); + }); + } + + $query->when($checkMorphNull, fn (self $query) => $query->orWhereMorphedTo($relation, null)); + }, null, null, $boolean); + } + + /** + * Get the BelongsTo relationship for a single polymorphic type. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, TDeclaringModel> $relation + * @param class-string $type + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + protected function getBelongsToRelation(MorphTo $relation, $type) + { + $belongsTo = Relation::noConstraints(function () use ($relation, $type) { + return $this->model->belongsTo( + $type, + $relation->getForeignKeyName(), + $relation->getOwnerKeyName() + ); + }); + + $belongsTo->getQuery()->mergeConstraintsFrom($relation->getQuery()); + + return $belongsTo; + } + + /** + * Add a polymorphic relationship count / exists condition to the query with an "or". + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param string|array $types + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this + */ + public function orHasMorph($relation, $types, $operator = '>=', $count = 1) + { + return $this->hasMorph($relation, $types, $operator, $count, 'or'); + } + + /** + * Add a polymorphic relationship count / exists condition to the query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param string $boolean + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @return $this + */ + public function doesntHaveMorph($relation, $types, $boolean = 'and', ?Closure $callback = null) + { + return $this->hasMorph($relation, $types, '<', 1, $boolean, $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with an "or". + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param string|array $types + * @return $this + */ + public function orDoesntHaveMorph($relation, $types) + { + return $this->doesntHaveMorph($relation, $types, 'or'); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this + */ + public function whereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) + { + return $this->hasMorph($relation, $types, $operator, $count, 'and', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this + */ + public function orWhereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) + { + return $this->hasMorph($relation, $types, $operator, $count, 'or', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @return $this + */ + public function whereDoesntHaveMorph($relation, $types, ?Closure $callback = null) + { + return $this->doesntHaveMorph($relation, $types, 'and', $callback); + } + + /** + * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @return $this + */ + public function orWhereDoesntHaveMorph($relation, $types, ?Closure $callback = null) + { + return $this->doesntHaveMorph($relation, $types, 'or', $callback); + } + + /** + * Add a basic where clause to a relationship query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereRelation($relation, $column, $operator = null, $value = null) + { + return $this->whereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a basic where clause to a relationship query and eager-load the relationship with the same conditions. + * + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *>|string $relation + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function withWhereRelation($relation, $column, $operator = null, $value = null) + { + return $this->whereRelation($relation, $column, $operator, $value) + ->with([ + $relation => fn ($query) => $column instanceof Closure + ? $column($query) + : $query->where($column, $operator, $value), + ]); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereRelation($relation, $column, $operator = null, $value = null) + { + return $this->orWhereHas($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a basic count / exists condition to a relationship query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereDoesntHaveRelation($relation, $column, $operator = null, $value = null) + { + return $this->whereDoesntHave($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add an "or where" clause to a relationship query. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\Relation|string $relation + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereDoesntHaveRelation($relation, $column, $operator = null, $value = null) + { + return $this->orWhereDoesntHave($relation, function ($query) use ($column, $operator, $value) { + if ($column instanceof Closure) { + $column($query); + } else { + $query->where($column, $operator, $value); + } + }); + } + + /** + * Add a polymorphic relationship condition to the query with a where clause. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereMorphRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->whereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with an "or where" clause. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereMorphRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->orWhereHasMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with a doesn't have clause. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function whereMorphDoesntHaveRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->whereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a polymorphic relationship condition to the query with an "or doesn't have" clause. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types + * @param (\Closure(\Illuminate\Database\Eloquent\Builder): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereMorphDoesntHaveRelation($relation, $types, $column, $operator = null, $value = null) + { + return $this->orWhereDoesntHaveMorph($relation, $types, function ($query) use ($column, $operator, $value) { + $query->where($column, $operator, $value); + }); + } + + /** + * Add a morph-to relationship condition to the query. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string|null $model + * @return $this + * + * @throws \InvalidArgumentException + */ + public function whereMorphedTo($relation, $model, $boolean = 'and') + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + if (is_null($model)) { + return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean); + } + + if (is_string($model)) { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array($model, $morphMap)) { + $model = array_search($model, $morphMap, true); + } + + return $this->where($relation->qualifyColumn($relation->getMorphType()), $model, null, $boolean); + } + + $models = BaseCollection::wrap($model); + + if ($models->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereMorphedTo method may not be empty.'); + } + + return $this->where(function ($query) use ($relation, $models) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { + $query->orWhere(function ($query) use ($relation, $models) { + $query->where($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + }); + }); + }, null, null, $boolean); + } + + /** + * Add a not morph-to relationship condition to the query. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string $model + * @return $this + * + * @throws \InvalidArgumentException + */ + public function whereNotMorphedTo($relation, $model, $boolean = 'and') + { + if (is_string($relation)) { + $relation = $this->getRelationWithoutConstraints($relation); + } + + if (is_string($model)) { + $morphMap = Relation::morphMap(); + + if (! empty($morphMap) && in_array($model, $morphMap)) { + $model = array_search($model, $morphMap, true); + } + + return $this->whereNot($relation->qualifyColumn($relation->getMorphType()), '<=>', $model, $boolean); + } + + $models = BaseCollection::wrap($model); + + if ($models->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereNotMorphedTo method may not be empty.'); + } + + return $this->whereNot(function ($query) use ($relation, $models) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { + $query->orWhere(function ($query) use ($relation, $models) { + $query->where($relation->qualifyColumn($relation->getMorphType()), '<=>', $models->first()->getMorphClass()) + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + }); + }); + }, null, null, $boolean); + } + + /** + * Add a morph-to relationship condition to the query with an "or where" clause. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string|null $model + * @return $this + */ + public function orWhereMorphedTo($relation, $model) + { + return $this->whereMorphedTo($relation, $model, 'or'); + } + + /** + * Add a not morph-to relationship condition to the query with an "or where" clause. + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string $model + * @return $this + */ + public function orWhereNotMorphedTo($relation, $model) + { + return $this->whereNotMorphedTo($relation, $model, 'or'); + } + + /** + * Add a "belongs to" relationship where clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection $related + * @param string|null $relationshipName + * @param string $boolean + * @return $this + * + * @throws \InvalidArgumentException + * @throws \Illuminate\Database\Eloquent\RelationNotFoundException + */ + public function whereBelongsTo($related, $relationshipName = null, $boolean = 'and') + { + if (! $related instanceof EloquentCollection) { + $relatedCollection = $related->newCollection([$related]); + } else { + $relatedCollection = $related; + + $related = $relatedCollection->first(); + } + + if ($relatedCollection->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereBelongsTo method may not be empty.'); + } + + if ($relationshipName === null) { + $relationshipName = Str::camel(class_basename($related)); + } + + try { + $relationship = $this->model->{$relationshipName}(); + } catch (BadMethodCallException) { + throw RelationNotFoundException::make($this->model, $relationshipName); + } + + if (! $relationship instanceof BelongsTo) { + throw RelationNotFoundException::make($this->model, $relationshipName, BelongsTo::class); + } + + $this->whereIn( + $relationship->getQualifiedForeignKeyName(), + $relatedCollection->pluck($relationship->getOwnerKeyName())->toArray(), + $boolean, + ); + + return $this; + } + + /** + * Add a "BelongsTo" relationship with an "or where" clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model $related + * @param string|null $relationshipName + * @return $this + */ + public function orWhereBelongsTo($related, $relationshipName = null) + { + return $this->whereBelongsTo($related, $relationshipName, 'or'); + } + + /** + * Add a "belongs to many" relationship where clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection $related + * @param string|null $relationshipName + * @param string $boolean + * @return $this + * + * @throws \InvalidArgumentException + * @throws \Illuminate\Database\Eloquent\RelationNotFoundException + */ + public function whereAttachedTo($related, $relationshipName = null, $boolean = 'and') + { + $relatedCollection = $related instanceof EloquentCollection ? $related : $related->newCollection([$related]); + + $related = $relatedCollection->first(); + + if ($relatedCollection->isEmpty()) { + throw new InvalidArgumentException('Collection given to whereAttachedTo method may not be empty.'); + } + + if ($relationshipName === null) { + $relationshipName = Str::plural(Str::camel(class_basename($related))); + } + + try { + $relationship = $this->model->{$relationshipName}(); + } catch (BadMethodCallException) { + throw RelationNotFoundException::make($this->model, $relationshipName); + } + + if (! $relationship instanceof BelongsToMany) { + throw RelationNotFoundException::make($this->model, $relationshipName, BelongsToMany::class); + } + + $this->has( + $relationshipName, + boolean: $boolean, + callback: fn (Builder $query) => $query->whereKey($relatedCollection->pluck($related->getKeyName())), + ); + + return $this; + } + + /** + * Add a "belongs to many" relationship with an "or where" clause to the query. + * + * @param \Illuminate\Database\Eloquent\Model $related + * @param string|null $relationshipName + * @return $this + * + * @throws \RuntimeException + */ + public function orWhereAttachedTo($related, $relationshipName = null) + { + return $this->whereAttachedTo($related, $relationshipName, 'or'); + } + + /** + * Add subselect queries to include an aggregate value for a relationship. * * @param mixed $relations + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string|null $function * @return $this */ - public function withCount($relations) + public function withAggregate($relations, $column, $function = null) { if (empty($relations)) { return $this; @@ -198,12 +855,12 @@ public function withCount($relations) $this->query->select([$this->query->from.'.*']); } - $relations = is_array($relations) ? $relations : func_get_args(); + $relations = is_array($relations) ? $relations : [$relations]; foreach ($this->parseWithRelations($relations) as $name => $constraints) { // First we will determine if the name has been aliased using an "as" clause on the name // and if it has we will extract the actual relationship name and the desired name of - // the resulting column. This allows multiple counts on the same relationship name. + // the resulting column. This allows multiple aggregates on the same relationships. $segments = explode(' ', $name); unset($alias); @@ -214,77 +871,230 @@ public function withCount($relations) $relation = $this->getRelationWithoutConstraints($name); - // Here we will get the relationship count query and prepare to add it to the main query + if ($function) { + if ($this->getQuery()->getGrammar()->isExpression($column)) { + $aggregateColumn = $this->getQuery()->getGrammar()->getValue($column); + } else { + $hashedColumn = $this->getRelationHashedColumn($column, $relation); + + $aggregateColumn = $this->getQuery()->getGrammar()->wrap( + $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) + ); + } + + $expression = $function === 'exists' ? $aggregateColumn : sprintf('%s(%s)', $function, $aggregateColumn); + } else { + $expression = $this->getQuery()->getGrammar()->getValue($column); + } + + // Here, we will grab the relationship sub-query and prepare to add it to the main query // as a sub-select. First, we'll get the "has" query and use that to get the relation - // count query. We will normalize the relation name then append _count as the name. - $query = $relation->getRelationExistenceCountQuery( - $relation->getRelated()->newQuery(), $this - ); + // sub-query. We'll format this relationship name and append this column if needed. + $query = $relation->getRelationExistenceQuery( + $relation->getRelated()->newQuery(), $this, new Expression($expression) + )->setBindings([], 'select'); $query->callScope($constraints); $query = $query->mergeConstraintsFrom($relation->getQuery())->toBase(); + // If the query contains certain elements like orderings / more than one column selected + // then we will remove those elements from the query so that it will execute properly + // when given to the database. Otherwise, we may receive SQL errors or poor syntax. + $query->orders = null; + $query->setBindings([], 'order'); + if (count($query->columns) > 1) { $query->columns = [$query->columns[0]]; + $query->bindings['select'] = []; } - // Finally we will add the proper result column alias to the query and run the subselect - // statement against the query builder. Then we will return the builder instance back - // to the developer for further constraint chaining that needs to take place on it. - $column = $alias ?? Str::snake($name.'_count'); + // Finally, we will make the proper column alias to the query and run this sub-select on + // the query builder. Then, we will return the builder instance back to the developer + // for further constraint chaining that needs to take place on the query as needed. + $alias ??= Str::snake( + preg_replace( + '/[^[:alnum:][:space:]_]/u', + '', + sprintf('%s %s %s', $name, $function, strtolower($this->getQuery()->getGrammar()->getValue($column))) + ) + ); - $this->selectSub($query, $column); + if ($function === 'exists') { + $this->selectRaw( + sprintf('exists(%s) as %s', $query->toSql(), $this->getQuery()->grammar->wrap($alias)), + $query->getBindings() + )->withCasts([$alias => 'bool']); + } else { + $this->selectSub( + $function ? $query : $query->limit(1), + $alias + ); + } } return $this; } + /** + * Get the relation hashed column name for the given column and relation. + * + * @param string $column + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation + * @return string + */ + protected function getRelationHashedColumn($column, $relation) + { + if (str_contains($column, '.')) { + return $column; + } + + return $this->getQuery()->from === $relation->getQuery()->getQuery()->from + ? "{$relation->getRelationCountHash(false)}.$column" + : $column; + } + + /** + * Add subselect queries to count the relations. + * + * @param mixed $relations + * @return $this + */ + public function withCount($relations) + { + return $this->withAggregate(is_array($relations) ? $relations : func_get_args(), '*', 'count'); + } + + /** + * Add subselect queries to include the max of the relation's column. + * + * @param string|array $relation + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function withMax($relation, $column) + { + return $this->withAggregate($relation, $column, 'max'); + } + + /** + * Add subselect queries to include the min of the relation's column. + * + * @param string|array $relation + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function withMin($relation, $column) + { + return $this->withAggregate($relation, $column, 'min'); + } + + /** + * Add subselect queries to include the sum of the relation's column. + * + * @param string|array $relation + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function withSum($relation, $column) + { + return $this->withAggregate($relation, $column, 'sum'); + } + + /** + * Add subselect queries to include the average of the relation's column. + * + * @param string|array $relation + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function withAvg($relation, $column) + { + return $this->withAggregate($relation, $column, 'avg'); + } + + /** + * Add subselect queries to include the existence of related models. + * + * @param string|array $relation + * @return $this + */ + public function withExists($relation) + { + return $this->withAggregate($relation, '*', 'exists'); + } + /** * Add the "has" condition where clause to the query. * - * @param \Illuminate\Database\Eloquent\Builder $hasQuery - * @param \Illuminate\Database\Eloquent\Relations\Relation $relation + * @param \Illuminate\Database\Eloquent\Builder<*> $hasQuery + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation * @param string $operator - * @param int $count + * @param \Illuminate\Contracts\Database\Query\Expression|int $count * @param string $boolean - * @return \Illuminate\Database\Eloquent\Builder|static + * @return $this */ protected function addHasWhere(Builder $hasQuery, Relation $relation, $operator, $count, $boolean) { $hasQuery->mergeConstraintsFrom($relation->getQuery()); return $this->canUseExistsForExistenceCheck($operator, $count) - ? $this->addWhereExistsQuery($hasQuery->toBase(), $boolean, $operator === '<' && $count === 1) - : $this->addWhereCountQuery($hasQuery->toBase(), $operator, $count, $boolean); + ? $this->addWhereExistsQuery($hasQuery->toBase(), $boolean, $operator === '<' && $count === 1) + : $this->addWhereCountQuery($hasQuery->toBase(), $operator, $count, $boolean); } /** * Merge the where constraints from another query to the current query. * - * @param \Illuminate\Database\Eloquent\Builder $from - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Database\Eloquent\Builder<*> $from + * @return $this */ public function mergeConstraintsFrom(Builder $from) { $whereBindings = $from->getQuery()->getRawBindings()['where'] ?? []; + $wheres = $from->getQuery()->from !== $this->getQuery()->from + ? $this->requalifyWhereTables( + $from->getQuery()->wheres, + $from->getQuery()->grammar->getValue($from->getQuery()->from), + $this->getModel()->getTable() + ) : $from->getQuery()->wheres; + // Here we have some other query that we want to merge the where constraints from. We will // copy over any where constraints on the query as well as remove any global scopes the // query might have removed. Then we will return ourselves with the finished merging. return $this->withoutGlobalScopes( $from->removedScopes() )->mergeWheres( - $from->getQuery()->wheres, $whereBindings + $wheres, $whereBindings ); } + /** + * Updates the table name for any columns with a new qualified name. + * + * @param array $wheres + * @param string $from + * @param string $to + * @return array + */ + protected function requalifyWhereTables(array $wheres, string $from, string $to): array + { + return (new BaseCollection($wheres))->map(function ($where) use ($from, $to) { + return (new BaseCollection($where))->map(function ($value) use ($from, $to) { + return is_string($value) && str_starts_with($value, $from.'.') + ? $to.'.'.Str::afterLast($value, '.') + : $value; + }); + })->toArray(); + } + /** * Add a sub-query count clause to this query. * - * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\Builder $query * @param string $operator - * @param int $count + * @param \Illuminate\Contracts\Database\Query\Expression|int $count * @param string $boolean * @return $this */ @@ -304,7 +1114,7 @@ protected function addWhereCountQuery(QueryBuilder $query, $operator = '>=', $co * Get the "has relation" base query instance. * * @param string $relation - * @return \Illuminate\Database\Eloquent\Relations\Relation + * @return \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> */ protected function getRelationWithoutConstraints($relation) { @@ -317,7 +1127,7 @@ protected function getRelationWithoutConstraints($relation) * Check if we can run an "exists" query to optimize performance. * * @param string $operator - * @param int $count + * @param \Illuminate\Contracts\Database\Query\Expression|int $count * @return bool */ protected function canUseExistsForExistenceCheck($operator, $count) diff --git a/Eloquent/Concerns/TransformsToResource.php b/Eloquent/Concerns/TransformsToResource.php new file mode 100644 index 0000000000..6006c35509 --- /dev/null +++ b/Eloquent/Concerns/TransformsToResource.php @@ -0,0 +1,99 @@ +|null $resourceClass + * @return \Illuminate\Http\Resources\Json\JsonResource + */ + public function toResource(?string $resourceClass = null): JsonResource + { + if ($resourceClass === null) { + return $this->guessResource(); + } + + return $resourceClass::make($this); + } + + /** + * Guess the resource class for the model. + * + * @return \Illuminate\Http\Resources\Json\JsonResource + * + * @throws \LogicException + */ + protected function guessResource(): JsonResource + { + $resourceClass = $this->resolveResourceFromAttribute(static::class); + + if ($resourceClass !== null && class_exists($resourceClass)) { + return $resourceClass::make($this); + } + + foreach (static::guessResourceName() as $resourceClass) { + if (is_string($resourceClass) && class_exists($resourceClass)) { + return $resourceClass::make($this); + } + } + + throw new LogicException(sprintf('Failed to find resource class for model [%s].', get_class($this))); + } + + /** + * Guess the resource class name for the model. + * + * @return array{class-string<\Illuminate\Http\Resources\Json\JsonResource>, class-string<\Illuminate\Http\Resources\Json\JsonResource>} + */ + public static function guessResourceName(): array + { + $modelClass = static::class; + + if (! Str::contains($modelClass, '\\Models\\')) { + return []; + } + + $relativeNamespace = Str::after($modelClass, '\\Models\\'); + + $relativeNamespace = Str::contains($relativeNamespace, '\\') + ? Str::before($relativeNamespace, '\\'.class_basename($modelClass)) + : ''; + + $potentialResource = sprintf( + '%s\\Http\\Resources\\%s%s', + Str::before($modelClass, '\\Models'), + strlen($relativeNamespace) > 0 ? $relativeNamespace.'\\' : '', + class_basename($modelClass) + ); + + return [$potentialResource.'Resource', $potentialResource]; + } + + /** + * Get the resource class from the class attribute. + * + * @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $class + * @return class-string<*>|null + */ + protected function resolveResourceFromAttribute(string $class): ?string + { + if (! class_exists($class)) { + return null; + } + + $attributes = (new ReflectionClass($class))->getAttributes(UseResource::class); + + return $attributes !== [] + ? $attributes[0]->newInstance()->class + : null; + } +} diff --git a/Eloquent/Factories/Attributes/UseModel.php b/Eloquent/Factories/Attributes/UseModel.php new file mode 100644 index 0000000000..0f7c10db10 --- /dev/null +++ b/Eloquent/Factories/Attributes/UseModel.php @@ -0,0 +1,19 @@ +factory = $factory; + $this->pivot = $pivot; + $this->relationship = $relationship; + } + + /** + * Create the attached relationship for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + public function createFor(Model $model) + { + $factoryInstance = $this->factory instanceof Factory; + + if ($factoryInstance) { + $relationship = $model->{$this->relationship}(); + } + + Collection::wrap($factoryInstance ? $this->factory->prependState($relationship->getQuery()->pendingAttributes)->create([], $model) : $this->factory)->each(function ($attachable) use ($model) { + $model->{$this->relationship}()->attach( + $attachable, + is_callable($this->pivot) ? call_user_func($this->pivot, $model) : $this->pivot + ); + }); + } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + if ($this->factory instanceof Factory) { + $this->factory = $this->factory->recycle($recycle); + } + + return $this; + } +} diff --git a/Eloquent/Factories/BelongsToRelationship.php b/Eloquent/Factories/BelongsToRelationship.php new file mode 100644 index 0000000000..5979183d92 --- /dev/null +++ b/Eloquent/Factories/BelongsToRelationship.php @@ -0,0 +1,96 @@ +factory = $factory; + $this->relationship = $relationship; + } + + /** + * Get the parent model attributes and resolvers for the given child model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return array + */ + public function attributesFor(Model $model) + { + $relationship = $model->{$this->relationship}(); + + return $relationship instanceof MorphTo ? [ + $relationship->getMorphType() => $this->factory instanceof Factory ? $this->factory->newModel()->getMorphClass() : $this->factory->getMorphClass(), + $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + ] : [ + $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + ]; + } + + /** + * Get the deferred resolver for this relationship's parent ID. + * + * @param string|null $key + * @return \Closure + */ + protected function resolver($key) + { + return function () use ($key) { + if (! $this->resolved) { + $instance = $this->factory instanceof Factory + ? ($this->factory->getRandomRecycledModel($this->factory->modelName()) ?? $this->factory->create()) + : $this->factory; + + return $this->resolved = $key ? $instance->{$key} : $instance->getKey(); + } + + return $this->resolved; + }; + } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + if ($this->factory instanceof Factory) { + $this->factory = $this->factory->recycle($recycle); + } + + return $this; + } +} diff --git a/Eloquent/Factories/CrossJoinSequence.php b/Eloquent/Factories/CrossJoinSequence.php new file mode 100644 index 0000000000..594120b385 --- /dev/null +++ b/Eloquent/Factories/CrossJoinSequence.php @@ -0,0 +1,25 @@ + + */ + protected $model; + + /** + * The number of models that should be generated. + * + * @var int|null + */ + protected $count; + + /** + * The state transformations that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $states; + + /** + * The parent relationships that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $has; + + /** + * The child relationships that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $for; + + /** + * The model instances to always use when creating relationships. + * + * @var \Illuminate\Support\Collection + */ + protected $recycle; + + /** + * The "after making" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterMaking; + + /** + * The "after creating" callbacks that will be applied to the model. + * + * @var \Illuminate\Support\Collection + */ + protected $afterCreating; + + /** + * Whether relationships should not be automatically created. + * + * @var bool + */ + protected $expandRelationships = true; + + /** + * The relationships that should not be automatically created. + * + * @var array + */ + protected $excludeRelationships = []; + + /** + * The name of the database connection that will be used to create the models. + * + * @var \UnitEnum|string|null + */ + protected $connection; + + /** + * The current Faker instance. + * + * @var \Faker\Generator + */ + protected $faker; + + /** + * The default namespace where factories reside. + * + * @var string + */ + public static $namespace = 'Database\\Factories\\'; + + /** + * @deprecated use $modelNameResolvers + * + * @var callable(self): class-string + */ + protected static $modelNameResolver; + + /** + * The default model name resolvers. + * + * @var array> + */ + protected static $modelNameResolvers = []; + + /** + * The factory name resolver. + * + * @var callable + */ + protected static $factoryNameResolver; + + /** + * Whether to expand relationships by default. + * + * @var bool + */ + protected static $expandRelationshipsByDefault = true; + + /** + * The cached model class names resolved from attributes. + * + * @var array|false> + */ + protected static $cachedModelAttributes = []; + + /** + * Create a new factory instance. + * + * @param int|null $count + * @param \Illuminate\Support\Collection|null $states + * @param \Illuminate\Support\Collection|null $has + * @param \Illuminate\Support\Collection|null $for + * @param \Illuminate\Support\Collection|null $afterMaking + * @param \Illuminate\Support\Collection|null $afterCreating + * @param \UnitEnum|string|null $connection + * @param \Illuminate\Support\Collection|null $recycle + * @param bool|null $expandRelationships + * @param array $excludeRelationships + */ + public function __construct( + $count = null, + ?Collection $states = null, + ?Collection $has = null, + ?Collection $for = null, + ?Collection $afterMaking = null, + ?Collection $afterCreating = null, + $connection = null, + ?Collection $recycle = null, + ?bool $expandRelationships = null, + array $excludeRelationships = [], + ) { + $this->count = $count; + $this->states = $states ?? new Collection; + $this->has = $has ?? new Collection; + $this->for = $for ?? new Collection; + $this->afterMaking = $afterMaking ?? new Collection; + $this->afterCreating = $afterCreating ?? new Collection; + $this->connection = $connection; + $this->recycle = $recycle ?? new Collection; + $this->faker = $this->withFaker(); + $this->expandRelationships = $expandRelationships ?? self::$expandRelationshipsByDefault; + $this->excludeRelationships = $excludeRelationships; + } + + /** + * Define the model's default state. + * + * @return array + */ + abstract public function definition(); + + /** + * Get a new factory instance for the given attributes. + * + * @param (callable(array): array)|array $attributes + * @return static + */ + public static function new($attributes = []) + { + return (new static)->state($attributes)->configure(); + } + + /** + * Get a new factory instance for the given number of models. + * + * @param int $count + * @return static + */ + public static function times(int $count) + { + return static::new()->count($count); + } + + /** + * Configure the factory. + * + * @return static + */ + public function configure() + { + return $this; + } + + /** + * Get the raw attributes generated by the factory. + * + * @param (callable(array): array)|array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return array + */ + public function raw($attributes = [], ?Model $parent = null) + { + if ($this->count === null) { + return $this->state($attributes)->getExpandedAttributes($parent); + } + + return array_map(function () use ($attributes, $parent) { + return $this->state($attributes)->getExpandedAttributes($parent); + }, range(1, $this->count)); + } + + /** + * Create a single model and persist it to the database. + * + * @param (callable(array): array)|array $attributes + * @return TModel + */ + public function createOne($attributes = []) + { + return $this->count(null)->create($attributes); + } + + /** + * Create a single model and persist it to the database without dispatching any model events. + * + * @param (callable(array): array)|array $attributes + * @return TModel + */ + public function createOneQuietly($attributes = []) + { + return $this->count(null)->createQuietly($attributes); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param int|null|iterable> $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createMany(int|iterable|null $records = null) + { + $records ??= ($this->count ?? 1); + + $this->count = null; + + if (is_numeric($records)) { + $records = array_fill(0, $records, []); + } + + return new EloquentCollection( + (new Collection($records))->map(function ($record) { + return $this->state($record)->create(); + }) + ); + } + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param int|null|iterable> $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createManyQuietly(int|iterable|null $records = null) + { + return Model::withoutEvents(fn () => $this->createMany($records)); + } + + /** + * Create a collection of models and persist them to the database. + * + * @param (callable(array): array)|array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|TModel + */ + public function create($attributes = [], ?Model $parent = null) + { + if (! empty($attributes)) { + return $this->state($attributes)->create([], $parent); + } + + $results = $this->make($attributes, $parent); + + if ($results instanceof Model) { + $this->store(new Collection([$results])); + + $this->callAfterCreating(new Collection([$results]), $parent); + } else { + $this->store($results); + + $this->callAfterCreating($results, $parent); + } + + return $results; + } + + /** + * Create a collection of models and persist them to the database without dispatching any model events. + * + * @param (callable(array): array)|array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|TModel + */ + public function createQuietly($attributes = [], ?Model $parent = null) + { + return Model::withoutEvents(fn () => $this->create($attributes, $parent)); + } + + /** + * Create a callback that persists a model in the database when invoked. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Closure(): (\Illuminate\Database\Eloquent\Collection|TModel) + */ + public function lazy(array $attributes = [], ?Model $parent = null) + { + return fn () => $this->create($attributes, $parent); + } + + /** + * Set the connection name on the results and store them. + * + * @param \Illuminate\Support\Collection $results + * @return void + */ + protected function store(Collection $results) + { + $results->each(function ($model) { + if (! isset($this->connection)) { + $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName()); + } + + $model->save(); + + foreach ($model->getRelations() as $name => $items) { + if ($items instanceof Enumerable && $items->isEmpty()) { + $model->unsetRelation($name); + } + } + + $this->createChildren($model); + }); + } + + /** + * Create the children for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return void + */ + protected function createChildren(Model $model) + { + Model::unguarded(function () use ($model) { + $this->has->each(function ($has) use ($model) { + $has->recycle($this->recycle)->createFor($model); + }); + }); + } + + /** + * Make a single instance of the model. + * + * @param (callable(array): array)|array $attributes + * @return TModel + */ + public function makeOne($attributes = []) + { + return $this->count(null)->make($attributes); + } + + /** + * Create a collection of models. + * + * @param (callable(array): array)|array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection|TModel + */ + public function make($attributes = [], ?Model $parent = null) + { + $autoEagerLoadingEnabled = Model::isAutomaticallyEagerLoadingRelationships(); + + if ($autoEagerLoadingEnabled) { + Model::automaticallyEagerLoadRelationships(false); + } + + try { + if (! empty($attributes)) { + return $this->state($attributes)->make([], $parent); + } + + if ($this->count === null) { + return tap($this->makeInstance($parent), function ($instance) { + $this->callAfterMaking(new Collection([$instance])); + }); + } + + if ($this->count < 1) { + return $this->newModel()->newCollection(); + } + + $instances = $this->newModel()->newCollection(array_map(function () use ($parent) { + return $this->makeInstance($parent); + }, range(1, $this->count))); + + $this->callAfterMaking($instances); + + return $instances; + } finally { + Model::automaticallyEagerLoadRelationships($autoEagerLoadingEnabled); + } + } + + /** + * Create a collection of models. + * + * @param iterable>|int|null $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function makeMany(iterable|int|null $records = null) + { + $records ??= ($this->count ?? 1); + + $this->count = null; + + if (is_numeric($records)) { + $records = array_fill(0, $records, []); + } + + return new EloquentCollection( + (new Collection($records))->map(function ($record) { + return $this->state($record)->make(); + }) + ); + } + + /** + * Insert the model records in bulk. No model events are emitted. + * + * @param array $attributes + * @param Model|null $parent + * @return void + */ + public function insert(array $attributes = [], ?Model $parent = null): void + { + $made = $this->make($attributes, $parent); + + $madeCollection = $made instanceof Collection + ? $made + : $this->newModel()->newCollection([$made]); + + $model = $madeCollection->first(); + + if (isset($this->connection)) { + $model->setConnection($this->connection); + } + + $query = $model->newQueryWithoutScopes(); + + $query->fillAndInsert( + $madeCollection->withoutAppends() + ->setHidden([]) + ->map(static fn (Model $model) => $model->attributesToArray()) + ->all() + ); + } + + /** + * Make an instance of the model with the given attributes. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Model + */ + protected function makeInstance(?Model $parent) + { + return Model::unguarded(function () use ($parent) { + return tap($this->newModel($this->getExpandedAttributes($parent)), function ($instance) { + if (isset($this->connection)) { + $instance->setConnection($this->connection); + } + }); + }); + } + + /** + * Get a raw attributes array for the model. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return mixed + */ + protected function getExpandedAttributes(?Model $parent) + { + return $this->expandAttributes($this->getRawAttributes($parent)); + } + + /** + * Get the raw attributes for the model as an array. + * + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return array + */ + protected function getRawAttributes(?Model $parent) + { + return $this->states->pipe(function ($states) { + return $this->for->isEmpty() ? $states : new Collection(array_merge([function () { + return $this->parentResolvers(); + }], $states->all())); + })->reduce(function ($carry, $state) use ($parent) { + if ($state instanceof Closure) { + $state = $state->bindTo($this); + } + + return array_merge($carry, $state($carry, $parent)); + }, $this->definition()); + } + + /** + * Create the parent relationship resolvers (as deferred Closures). + * + * @return array + */ + protected function parentResolvers() + { + return $this->for + ->map(fn (BelongsToRelationship $for) => $for->recycle($this->recycle)->attributesFor($this->newModel())) + ->collapse() + ->all(); + } + + /** + * Expand all attributes to their underlying values. + * + * @param array $definition + * @return array + */ + protected function expandAttributes(array $definition) + { + return (new Collection($definition)) + ->map($evaluateRelations = function ($attribute, $key) { + if (! $this->expandRelationships && $attribute instanceof self) { + $attribute = null; + } elseif ($attribute instanceof self && + array_intersect([$attribute->modelName(), $key], $this->excludeRelationships)) { + $attribute = null; + } elseif ($attribute instanceof self) { + $attribute = $this->getRandomRecycledModel($attribute->modelName())?->getKey() + ?? $attribute->recycle($this->recycle)->create()->getKey(); + } elseif ($attribute instanceof Model) { + $attribute = $attribute->getKey(); + } + + return $attribute; + }) + ->map(function ($attribute, $key) use (&$definition, $evaluateRelations) { + if (is_callable($attribute) && ! is_string($attribute) && ! is_array($attribute)) { + $attribute = $attribute($definition); + } + + $attribute = $evaluateRelations($attribute, $key); + + $definition[$key] = $attribute; + + return $attribute; + }) + ->all(); + } + + /** + * Add a new state transformation to the model definition. + * + * @param (callable(array, Model|null): array)|array $state + * @return static + */ + public function state($state) + { + return $this->newInstance([ + 'states' => $this->states->concat([ + is_callable($state) ? $state : fn () => $state, + ]), + ]); + } + + /** + * Prepend a new state transformation to the model definition. + * + * @param (callable(array, Model|null): array)|array $state + * @return static + */ + public function prependState($state) + { + return $this->newInstance([ + 'states' => $this->states->prepend( + is_callable($state) ? $state : fn () => $state, + ), + ]); + } + + /** + * Set a single model attribute. + * + * @param string|int $key + * @param mixed $value + * @return static + */ + public function set($key, $value) + { + return $this->state([$key => $value]); + } + + /** + * Add a new sequenced state transformation to the model definition. + * + * @param mixed ...$sequence + * @return static + */ + public function sequence(...$sequence) + { + return $this->state(new Sequence(...$sequence)); + } + + /** + * Add a new sequenced state transformation to the model definition and update the pending creation count to the size of the sequence. + * + * @param array ...$sequence + * @return static + */ + public function forEachSequence(...$sequence) + { + return $this->state(new Sequence(...$sequence))->count(count($sequence)); + } + + /** + * Add a new cross joined sequenced state transformation to the model definition. + * + * @param array ...$sequence + * @return static + */ + public function crossJoinSequence(...$sequence) + { + return $this->state(new CrossJoinSequence(...$sequence)); + } + + /** + * Define a child relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory $factory + * @param string|null $relationship + * @return static + */ + public function has(self $factory, $relationship = null) + { + return $this->newInstance([ + 'has' => $this->has->concat([new Relationship( + $factory, $relationship ?? $this->guessRelationship($factory->modelName()) + )]), + ]); + } + + /** + * Attempt to guess the relationship name for a "has" relationship. + * + * @param string $related + * @return string + */ + protected function guessRelationship(string $related) + { + $guess = Str::camel(Str::plural(class_basename($related))); + + return method_exists($this->modelName(), $guess) ? $guess : Str::singular($guess); + } + + /** + * Define an attached relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $factory + * @param (callable(): array)|array $pivot + * @param string|null $relationship + * @return static + */ + public function hasAttached($factory, $pivot = [], $relationship = null) + { + return $this->newInstance([ + 'has' => $this->has->concat([new BelongsToManyRelationship( + $factory, + $pivot, + $relationship ?? Str::camel(Str::plural(class_basename( + $factory instanceof Factory + ? $factory->modelName() + : Collection::wrap($factory)->first() + ))) + )]), + ]); + } + + /** + * Define a parent relationship for the model. + * + * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Database\Eloquent\Model $factory + * @param string|null $relationship + * @return static + */ + public function for($factory, $relationship = null) + { + return $this->newInstance(['for' => $this->for->concat([new BelongsToRelationship( + $factory, + $relationship ?? Str::camel(class_basename( + $factory instanceof Factory ? $factory->modelName() : $factory + )) + )])]); + } + + /** + * Provide model instances to use instead of any nested factory calls when creating relationships. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Support\Collection|array $model + * @return static + */ + public function recycle($model) + { + // Group provided models by the type and merge them into existing recycle collection + return $this->newInstance([ + 'recycle' => $this->recycle + ->flatten() + ->merge( + Collection::wrap($model instanceof Model ? func_get_args() : $model) + ->flatten() + )->groupBy(fn ($model) => get_class($model)), + ]); + } + + /** + * Retrieve a random model of a given type from previously provided models to recycle. + * + * @template TClass of \Illuminate\Database\Eloquent\Model + * + * @param class-string $modelClassName + * @return TClass|null + */ + public function getRandomRecycledModel($modelClassName) + { + return $this->recycle->get($modelClassName)?->random(); + } + + /** + * Add a new "after making" callback to the model definition. + * + * @param \Closure(TModel): mixed $callback + * @return static + */ + public function afterMaking(Closure $callback) + { + return $this->newInstance(['afterMaking' => $this->afterMaking->concat([$callback])]); + } + + /** + * Add a new "after creating" callback to the model definition. + * + * @param \Closure(TModel, \Illuminate\Database\Eloquent\Model|null): mixed $callback + * @return static + */ + public function afterCreating(Closure $callback) + { + return $this->newInstance(['afterCreating' => $this->afterCreating->concat([$callback])]); + } + + /** + * Remove the "after making" callbacks from the factory. + * + * @return static + */ + public function withoutAfterMaking() + { + return $this->newInstance(['afterMaking' => new Collection]); + } + + /** + * Remove the "after creating" callbacks from the factory. + * + * @return static + */ + public function withoutAfterCreating() + { + return $this->newInstance(['afterCreating' => new Collection]); + } + + /** + * Call the "after making" callbacks for the given model instances. + * + * @param \Illuminate\Support\Collection $instances + * @return void + */ + protected function callAfterMaking(Collection $instances) + { + $instances->each(function ($model) { + $this->afterMaking->each(function ($callback) use ($model) { + $callback($model); + }); + }); + } + + /** + * Call the "after creating" callbacks for the given model instances. + * + * @param \Illuminate\Support\Collection $instances + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return void + */ + protected function callAfterCreating(Collection $instances, ?Model $parent = null) + { + $instances->each(function ($model) use ($parent) { + $this->afterCreating->each(function ($callback) use ($model, $parent) { + $callback($model, $parent); + }); + }); + } + + /** + * Specify how many models should be generated. + * + * @param int|null $count + * @return static + */ + public function count(?int $count) + { + return $this->newInstance(['count' => $count]); + } + + /** + * Indicate that related parent models should not be created. + * + * @param array> $parents + * @return static + */ + public function withoutParents($parents = []) + { + return $this->newInstance(! $parents ? ['expandRelationships' => false] : ['excludeRelationships' => $parents]); + } + + /** + * Get the name of the database connection that is used to generate models. + * + * @return string + */ + public function getConnectionName() + { + return enum_value($this->connection); + } + + /** + * Specify the database connection that should be used to generate models. + * + * @param \UnitEnum|string|null $connection + * @return static + */ + public function connection(UnitEnum|string|null $connection) + { + return $this->newInstance(['connection' => $connection]); + } + + /** + * Create a new instance of the factory builder with the given mutated properties. + * + * @param array $arguments + * @return static + */ + protected function newInstance(array $arguments = []) + { + return new static(...array_values(array_merge([ + 'count' => $this->count, + 'states' => $this->states, + 'has' => $this->has, + 'for' => $this->for, + 'afterMaking' => $this->afterMaking, + 'afterCreating' => $this->afterCreating, + 'connection' => $this->connection, + 'recycle' => $this->recycle, + 'expandRelationships' => $this->expandRelationships, + 'excludeRelationships' => $this->excludeRelationships, + ], $arguments))); + } + + /** + * Get a new model instance. + * + * @param array $attributes + * @return TModel + */ + public function newModel(array $attributes = []) + { + $model = $this->modelName(); + + return new $model($attributes); + } + + /** + * Get the name of the model that is generated by the factory. + * + * @return class-string + */ + public function modelName() + { + if (! array_key_exists(static::class, static::$cachedModelAttributes)) { + $attribute = (new ReflectionClass($this))->getAttributes(UseModel::class); + + static::$cachedModelAttributes[static::class] = count($attribute) > 0 + ? $attribute[0]->newInstance()->class + : false; + } + + if (static::$cachedModelAttributes[static::class]) { + return static::$cachedModelAttributes[static::class]; + } + + if ($this->model !== null) { + return $this->model; + } + + $resolver = static::$modelNameResolvers[static::class] ?? static::$modelNameResolvers[self::class] ?? static::$modelNameResolver ?? function (self $factory) { + $namespacedFactoryBasename = Str::replaceLast( + 'Factory', '', Str::replaceFirst(static::$namespace, '', $factory::class) + ); + + $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); + + $appNamespace = static::appNamespace(); + + return class_exists($appNamespace.'Models\\'.$namespacedFactoryBasename) + ? $appNamespace.'Models\\'.$namespacedFactoryBasename + : $appNamespace.$factoryBasename; + }; + + return $resolver($this); + } + + /** + * Specify the callback that should be invoked to guess model names based on factory names. + * + * @param callable(self): class-string $callback + * @return void + */ + public static function guessModelNamesUsing(callable $callback) + { + static::$modelNameResolvers[static::class] = $callback; + } + + /** + * Specify the default namespace that contains the application's model factories. + * + * @param string $namespace + * @return void + */ + public static function useNamespace(string $namespace) + { + static::$namespace = $namespace; + } + + /** + * Get a new factory instance for the given model name. + * + * @template TClass of \Illuminate\Database\Eloquent\Model + * + * @param class-string $modelName + * @return \Illuminate\Database\Eloquent\Factories\Factory + */ + public static function factoryForModel(string $modelName) + { + $factory = static::resolveFactoryName($modelName); + + return $factory::new(); + } + + /** + * Specify the callback that should be invoked to guess factory names based on dynamic relationship names. + * + * @param callable(class-string<\Illuminate\Database\Eloquent\Model>): class-string<\Illuminate\Database\Eloquent\Factories\Factory> $callback + * @return void + */ + public static function guessFactoryNamesUsing(callable $callback) + { + static::$factoryNameResolver = $callback; + } + + /** + * Specify that relationships should create parent relationships by default. + * + * @return void + */ + public static function expandRelationshipsByDefault() + { + static::$expandRelationshipsByDefault = true; + } + + /** + * Specify that relationships should not create parent relationships by default. + * + * @return void + */ + public static function dontExpandRelationshipsByDefault() + { + static::$expandRelationshipsByDefault = false; + } + + /** + * Get a new Faker instance. + * + * @return \Faker\Generator|null + */ + protected function withFaker() + { + if (! class_exists(Generator::class)) { + return; + } + + return Container::getInstance()->make(Generator::class); + } + + /** + * Get the factory name for the given model name. + * + * @template TClass of \Illuminate\Database\Eloquent\Model + * + * @param class-string $modelName + * @return class-string<\Illuminate\Database\Eloquent\Factories\Factory> + */ + public static function resolveFactoryName(string $modelName) + { + $resolver = static::$factoryNameResolver ?? function (string $modelName) { + $appNamespace = static::appNamespace(); + + $modelName = Str::startsWith($modelName, $appNamespace.'Models\\') + ? Str::after($modelName, $appNamespace.'Models\\') + : Str::after($modelName, $appNamespace); + + return static::$namespace.$modelName.'Factory'; + }; + + return $resolver($modelName); + } + + /** + * Get the application namespace for the application. + * + * @return string + */ + protected static function appNamespace() + { + try { + return Container::getInstance() + ->make(Application::class) + ->getNamespace(); + } catch (Throwable) { + return 'App\\'; + } + } + + /** + * Flush the factory's global state. + * + * @return void + */ + public static function flushState() + { + static::$modelNameResolver = null; + static::$modelNameResolvers = []; + static::$factoryNameResolver = null; + static::$namespace = 'Database\\Factories\\'; + static::$expandRelationshipsByDefault = true; + } + + /** + * Proxy dynamic factory methods onto their proper methods. + * + * @param string $method + * @param array $parameters + * @return mixed + */ + public function __call($method, $parameters) + { + if (static::hasMacro($method)) { + return $this->macroCall($method, $parameters); + } + + if ($method === 'trashed' && $this->modelName()::isSoftDeletable()) { + return $this->state([ + $this->newModel()->getDeletedAtColumn() => $parameters[0] ?? Carbon::now()->subDay(), + ]); + } + + if (! Str::startsWith($method, ['for', 'has'])) { + static::throwBadMethodCallException($method); + } + + $relationship = Str::camel(Str::substr($method, 3)); + + $relatedModel = get_class($this->newModel()->{$relationship}()->getRelated()); + + if (method_exists($relatedModel, 'newFactory')) { + $factory = $relatedModel::newFactory() ?? static::factoryForModel($relatedModel); + } else { + $factory = static::factoryForModel($relatedModel); + } + + if (str_starts_with($method, 'for')) { + return $this->for($factory->state($parameters[0] ?? []), $relationship); + } elseif (str_starts_with($method, 'has')) { + return $this->has( + $factory + ->count(is_numeric($parameters[0] ?? null) ? $parameters[0] : 1) + ->state((is_callable($parameters[0] ?? null) || is_array($parameters[0] ?? null)) ? $parameters[0] : ($parameters[1] ?? [])), + $relationship + ); + } + } +} diff --git a/Eloquent/Factories/HasFactory.php b/Eloquent/Factories/HasFactory.php new file mode 100644 index 0000000000..d2747cc93c --- /dev/null +++ b/Eloquent/Factories/HasFactory.php @@ -0,0 +1,62 @@ +, static|null): array)|array|int|null $count + * @param (callable(array, static|null): array)|array $state + * @return TFactory + */ + public static function factory($count = null, $state = []) + { + $factory = static::newFactory() ?? Factory::factoryForModel(static::class); + + return $factory + ->count(is_numeric($count) ? $count : null) + ->state(is_callable($count) || is_array($count) ? $count : $state); + } + + /** + * Create a new factory instance for the model. + * + * @return TFactory|null + */ + protected static function newFactory() + { + if (isset(static::$factory)) { + return static::$factory::new(); + } + + return static::getUseFactoryAttribute() ?? null; + } + + /** + * Get the factory from the UseFactory class attribute. + * + * @return TFactory|null + */ + protected static function getUseFactoryAttribute() + { + $attributes = (new \ReflectionClass(static::class)) + ->getAttributes(UseFactory::class); + + if ($attributes !== []) { + $useFactory = $attributes[0]->newInstance(); + + $factory = $useFactory->factoryClass::new(); + + $factory->guessModelNamesUsing(fn () => static::class); + + return $factory; + } + } +} diff --git a/Eloquent/Factories/Relationship.php b/Eloquent/Factories/Relationship.php new file mode 100644 index 0000000000..e23bc99d78 --- /dev/null +++ b/Eloquent/Factories/Relationship.php @@ -0,0 +1,76 @@ +factory = $factory; + $this->relationship = $relationship; + } + + /** + * Create the child relationship for the given parent model. + * + * @param \Illuminate\Database\Eloquent\Model $parent + * @return void + */ + public function createFor(Model $parent) + { + $relationship = $parent->{$this->relationship}(); + + if ($relationship instanceof MorphOneOrMany) { + $this->factory->state([ + $relationship->getMorphType() => $relationship->getMorphClass(), + $relationship->getForeignKeyName() => $relationship->getParentKey(), + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); + } elseif ($relationship instanceof HasOneOrMany) { + $this->factory->state([ + $relationship->getForeignKeyName() => $relationship->getParentKey(), + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); + } elseif ($relationship instanceof BelongsToMany) { + $relationship->attach( + $this->factory->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent) + ); + } + } + + /** + * Specify the model instances to always use when creating relationships. + * + * @param \Illuminate\Support\Collection $recycle + * @return $this + */ + public function recycle($recycle) + { + $this->factory = $this->factory->recycle($recycle); + + return $this; + } +} diff --git a/Eloquent/Factories/Sequence.php b/Eloquent/Factories/Sequence.php new file mode 100644 index 0000000000..4d350d2ad1 --- /dev/null +++ b/Eloquent/Factories/Sequence.php @@ -0,0 +1,64 @@ +sequence = $sequence; + $this->count = count($sequence); + } + + /** + * Get the current count of the sequence items. + * + * @return int + */ + public function count(): int + { + return $this->count; + } + + /** + * Get the next value in the sequence. + * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return mixed + */ + public function __invoke($attributes = [], $parent = null) + { + return tap(value($this->sequence[$this->index % $this->count], $this, $attributes, $parent), function () { + $this->index = $this->index + 1; + }); + } +} diff --git a/Eloquent/Factory.php b/Eloquent/Factory.php deleted file mode 100644 index 4ca3d62fe7..0000000000 --- a/Eloquent/Factory.php +++ /dev/null @@ -1,326 +0,0 @@ -faker = $faker; - } - - /** - * Create a new factory container. - * - * @param \Faker\Generator $faker - * @param string|null $pathToFactories - * @return static - */ - public static function construct(Faker $faker, $pathToFactories = null) - { - $pathToFactories = $pathToFactories ?: database_path('factories'); - - return (new static($faker))->load($pathToFactories); - } - - /** - * Define a class with a given short-name. - * - * @param string $class - * @param string $name - * @param callable $attributes - * @return $this - */ - public function defineAs($class, $name, callable $attributes) - { - return $this->define($class, $attributes, $name); - } - - /** - * Define a class with a given set of attributes. - * - * @param string $class - * @param callable $attributes - * @param string $name - * @return $this - */ - public function define($class, callable $attributes, $name = 'default') - { - $this->definitions[$class][$name] = $attributes; - - return $this; - } - - /** - * Define a state with a given set of attributes. - * - * @param string $class - * @param string $state - * @param callable|array $attributes - * @return $this - */ - public function state($class, $state, $attributes) - { - $this->states[$class][$state] = $attributes; - - return $this; - } - - /** - * Define a callback to run after making a model. - * - * @param string $class - * @param callable $callback - * @param string $name - * @return $this - */ - public function afterMaking($class, callable $callback, $name = 'default') - { - $this->afterMaking[$class][$name][] = $callback; - - return $this; - } - - /** - * Define a callback to run after making a model with given state. - * - * @param string $class - * @param string $state - * @param callable $callback - * @return $this - */ - public function afterMakingState($class, $state, callable $callback) - { - return $this->afterMaking($class, $callback, $state); - } - - /** - * Define a callback to run after creating a model. - * - * @param string $class - * @param callable $callback - * @param string $name - * @return $this - */ - public function afterCreating($class, callable $callback, $name = 'default') - { - $this->afterCreating[$class][$name][] = $callback; - - return $this; - } - - /** - * Define a callback to run after creating a model with given state. - * - * @param string $class - * @param string $state - * @param callable $callback - * @return $this - */ - public function afterCreatingState($class, $state, callable $callback) - { - return $this->afterCreating($class, $callback, $state); - } - - /** - * Create an instance of the given model and persist it to the database. - * - * @param string $class - * @param array $attributes - * @return mixed - */ - public function create($class, array $attributes = []) - { - return $this->of($class)->create($attributes); - } - - /** - * Create an instance of the given model and type and persist it to the database. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return mixed - */ - public function createAs($class, $name, array $attributes = []) - { - return $this->of($class, $name)->create($attributes); - } - - /** - * Create an instance of the given model. - * - * @param string $class - * @param array $attributes - * @return mixed - */ - public function make($class, array $attributes = []) - { - return $this->of($class)->make($attributes); - } - - /** - * Create an instance of the given model and type. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return mixed - */ - public function makeAs($class, $name, array $attributes = []) - { - return $this->of($class, $name)->make($attributes); - } - - /** - * Get the raw attribute array for a given named model. - * - * @param string $class - * @param string $name - * @param array $attributes - * @return array - */ - public function rawOf($class, $name, array $attributes = []) - { - return $this->raw($class, $attributes, $name); - } - - /** - * Get the raw attribute array for a given model. - * - * @param string $class - * @param array $attributes - * @param string $name - * @return array - */ - public function raw($class, array $attributes = [], $name = 'default') - { - return array_merge( - call_user_func($this->definitions[$class][$name], $this->faker), $attributes - ); - } - - /** - * Create a builder for the given model. - * - * @param string $class - * @param string $name - * @return \Illuminate\Database\Eloquent\FactoryBuilder - */ - public function of($class, $name = 'default') - { - return new FactoryBuilder( - $class, $name, $this->definitions, $this->states, - $this->afterMaking, $this->afterCreating, $this->faker - ); - } - - /** - * Load factories from path. - * - * @param string $path - * @return $this - */ - public function load($path) - { - $factory = $this; - - if (is_dir($path)) { - foreach (Finder::create()->files()->name('*.php')->in($path) as $file) { - require $file->getRealPath(); - } - } - - return $factory; - } - - /** - * Determine if the given offset exists. - * - * @param string $offset - * @return bool - */ - public function offsetExists($offset) - { - return isset($this->definitions[$offset]); - } - - /** - * Get the value of the given offset. - * - * @param string $offset - * @return mixed - */ - public function offsetGet($offset) - { - return $this->make($offset); - } - - /** - * Set the given offset to the given value. - * - * @param string $offset - * @param callable $value - * @return void - */ - public function offsetSet($offset, $value) - { - $this->define($offset, $value); - } - - /** - * Unset the value at the given offset. - * - * @param string $offset - * @return void - */ - public function offsetUnset($offset) - { - unset($this->definitions[$offset]); - } -} diff --git a/Eloquent/FactoryBuilder.php b/Eloquent/FactoryBuilder.php deleted file mode 100644 index 87bb0d878b..0000000000 --- a/Eloquent/FactoryBuilder.php +++ /dev/null @@ -1,448 +0,0 @@ -name = $name; - $this->class = $class; - $this->faker = $faker; - $this->states = $states; - $this->definitions = $definitions; - $this->afterMaking = $afterMaking; - $this->afterCreating = $afterCreating; - } - - /** - * Set the amount of models you wish to create / make. - * - * @param int $amount - * @return $this - */ - public function times($amount) - { - $this->amount = $amount; - - return $this; - } - - /** - * Set the state to be applied to the model. - * - * @param string $state - * @return $this - */ - public function state($state) - { - return $this->states([$state]); - } - - /** - * Set the states to be applied to the model. - * - * @param array|mixed $states - * @return $this - */ - public function states($states) - { - $this->activeStates = is_array($states) ? $states : func_get_args(); - - return $this; - } - - /** - * Set the database connection on which the model instance should be persisted. - * - * @param string $name - * @return $this - */ - public function connection($name) - { - $this->connection = $name; - - return $this; - } - - /** - * Create a model and persist it in the database if requested. - * - * @param array $attributes - * @return \Closure - */ - public function lazy(array $attributes = []) - { - return function () use ($attributes) { - return $this->create($attributes); - }; - } - - /** - * Create a collection of models and persist them to the database. - * - * @param array $attributes - * @return mixed - */ - public function create(array $attributes = []) - { - $results = $this->make($attributes); - - if ($results instanceof Model) { - $this->store(collect([$results])); - - $this->callAfterCreating(collect([$results])); - } else { - $this->store($results); - - $this->callAfterCreating($results); - } - - return $results; - } - - /** - * Set the connection name on the results and store them. - * - * @param \Illuminate\Support\Collection $results - * @return void - */ - protected function store($results) - { - $results->each(function ($model) { - if (! isset($this->connection)) { - $model->setConnection($model->newQueryWithoutScopes()->getConnection()->getName()); - } - - $model->save(); - }); - } - - /** - * Create a collection of models. - * - * @param array $attributes - * @return mixed - */ - public function make(array $attributes = []) - { - if ($this->amount === null) { - return tap($this->makeInstance($attributes), function ($instance) { - $this->callAfterMaking(collect([$instance])); - }); - } - - if ($this->amount < 1) { - return (new $this->class)->newCollection(); - } - - $instances = (new $this->class)->newCollection(array_map(function () use ($attributes) { - return $this->makeInstance($attributes); - }, range(1, $this->amount))); - - $this->callAfterMaking($instances); - - return $instances; - } - - /** - * Create an array of raw attribute arrays. - * - * @param array $attributes - * @return mixed - */ - public function raw(array $attributes = []) - { - if ($this->amount === null) { - return $this->getRawAttributes($attributes); - } - - if ($this->amount < 1) { - return []; - } - - return array_map(function () use ($attributes) { - return $this->getRawAttributes($attributes); - }, range(1, $this->amount)); - } - - /** - * Get a raw attributes array for the model. - * - * @param array $attributes - * @return mixed - * - * @throws \InvalidArgumentException - */ - protected function getRawAttributes(array $attributes = []) - { - if (! isset($this->definitions[$this->class][$this->name])) { - throw new InvalidArgumentException("Unable to locate factory with name [{$this->name}] [{$this->class}]."); - } - - $definition = call_user_func( - $this->definitions[$this->class][$this->name], - $this->faker, $attributes - ); - - return $this->expandAttributes( - array_merge($this->applyStates($definition, $attributes), $attributes) - ); - } - - /** - * Make an instance of the model with the given attributes. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model - */ - protected function makeInstance(array $attributes = []) - { - return Model::unguarded(function () use ($attributes) { - $instance = new $this->class( - $this->getRawAttributes($attributes) - ); - - if (isset($this->connection)) { - $instance->setConnection($this->connection); - } - - return $instance; - }); - } - - /** - * Apply the active states to the model definition array. - * - * @param array $definition - * @param array $attributes - * @return array - * - * @throws \InvalidArgumentException - */ - protected function applyStates(array $definition, array $attributes = []) - { - foreach ($this->activeStates as $state) { - if (! isset($this->states[$this->class][$state])) { - if ($this->stateHasAfterCallback($state)) { - continue; - } - - throw new InvalidArgumentException("Unable to locate [{$state}] state for [{$this->class}]."); - } - - $definition = array_merge( - $definition, - $this->stateAttributes($state, $attributes) - ); - } - - return $definition; - } - - /** - * Get the state attributes. - * - * @param string $state - * @param array $attributes - * @return array - */ - protected function stateAttributes($state, array $attributes) - { - $stateAttributes = $this->states[$this->class][$state]; - - if (! is_callable($stateAttributes)) { - return $stateAttributes; - } - - return call_user_func( - $stateAttributes, - $this->faker, $attributes - ); - } - - /** - * Expand all attributes to their underlying values. - * - * @param array $attributes - * @return array - */ - protected function expandAttributes(array $attributes) - { - foreach ($attributes as &$attribute) { - if (is_callable($attribute) && ! is_string($attribute) && ! is_array($attribute)) { - $attribute = $attribute($attributes); - } - - if ($attribute instanceof static) { - $attribute = $attribute->create()->getKey(); - } - - if ($attribute instanceof Model) { - $attribute = $attribute->getKey(); - } - } - - return $attributes; - } - - /** - * Run after making callbacks on a collection of models. - * - * @param \Illuminate\Support\Collection $models - * @return void - */ - public function callAfterMaking($models) - { - $this->callAfter($this->afterMaking, $models); - } - - /** - * Run after creating callbacks on a collection of models. - * - * @param \Illuminate\Support\Collection $models - * @return void - */ - public function callAfterCreating($models) - { - $this->callAfter($this->afterCreating, $models); - } - - /** - * Call after callbacks for each model and state. - * - * @param array $afterCallbacks - * @param \Illuminate\Support\Collection $models - * @return void - */ - protected function callAfter(array $afterCallbacks, $models) - { - $states = array_merge([$this->name], $this->activeStates); - - $models->each(function ($model) use ($states, $afterCallbacks) { - foreach ($states as $state) { - $this->callAfterCallbacks($afterCallbacks, $model, $state); - } - }); - } - - /** - * Call after callbacks for each model and state. - * - * @param array $afterCallbacks - * @param \Illuminate\Database\Eloquent\Model $model - * @param string $state - * @return void - */ - protected function callAfterCallbacks(array $afterCallbacks, $model, $state) - { - if (! isset($afterCallbacks[$this->class][$state])) { - return; - } - - foreach ($afterCallbacks[$this->class][$state] as $callback) { - $callback($model, $this->faker); - } - } - - /** - * Determine if the given state has an "after" callback. - * - * @param string $state - * @return bool - */ - protected function stateHasAfterCallback($state) - { - return isset($this->afterMaking[$this->class][$state]) || - isset($this->afterCreating[$this->class][$state]); - } -} diff --git a/Eloquent/HasBuilder.php b/Eloquent/HasBuilder.php new file mode 100644 index 0000000000..9431bb46f7 --- /dev/null +++ b/Eloquent/HasBuilder.php @@ -0,0 +1,124 @@ +, class-string> + */ + protected static array $resolvedCollectionClasses = []; + + /** + * Create a new Eloquent Collection instance. + * + * @param array $models + * @return TCollection + */ + public function newCollection(array $models = []) + { + static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); + + $collection = new static::$resolvedCollectionClasses[static::class]($models); + + if (Model::isAutomaticallyEagerLoadingRelationships()) { + $collection->withRelationshipAutoloading(); + } + + return $collection; + } + + /** + * Resolve the collection class name from the CollectedBy attribute. + * + * @return class-string|null + */ + public function resolveCollectionFromAttribute() + { + $reflectionClass = new ReflectionClass(static::class); + + $attributes = $reflectionClass->getAttributes(CollectedBy::class); + + if (! isset($attributes[0]) || ! isset($attributes[0]->getArguments()[0])) { + return; + } + + return $attributes[0]->getArguments()[0]; + } +} diff --git a/Eloquent/HigherOrderBuilderProxy.php b/Eloquent/HigherOrderBuilderProxy.php index 179d4a38e0..dfcbbd6774 100644 --- a/Eloquent/HigherOrderBuilderProxy.php +++ b/Eloquent/HigherOrderBuilderProxy.php @@ -10,7 +10,7 @@ class HigherOrderBuilderProxy /** * The collection being operated on. * - * @var \Illuminate\Database\Eloquent\Builder + * @var \Illuminate\Database\Eloquent\Builder<*> */ protected $builder; @@ -24,8 +24,8 @@ class HigherOrderBuilderProxy /** * Create a new proxy instance. * - * @param Builder $builder - * @param string $method + * @param \Illuminate\Database\Eloquent\Builder<*> $builder + * @param string $method */ public function __construct(Builder $builder, $method) { diff --git a/Eloquent/InvalidCastException.php b/Eloquent/InvalidCastException.php new file mode 100644 index 0000000000..f37672c0b9 --- /dev/null +++ b/Eloquent/InvalidCastException.php @@ -0,0 +1,47 @@ +model = $class; + $this->column = $column; + $this->castType = $castType; + } +} diff --git a/Eloquent/JsonEncodingException.php b/Eloquent/JsonEncodingException.php index 5878b0f7b7..f62abd4695 100644 --- a/Eloquent/JsonEncodingException.php +++ b/Eloquent/JsonEncodingException.php @@ -18,12 +18,26 @@ public static function forModel($model, $message) return new static('Error encoding model ['.get_class($model).'] with ID ['.$model->getKey().'] to JSON: '.$message); } + /** + * Create a new JSON encoding exception for the resource. + * + * @param \Illuminate\Http\Resources\Json\JsonResource $resource + * @param string $message + * @return static + */ + public static function forResource($resource, $message) + { + $model = $resource->resource; + + return new static('Error encoding resource ['.get_class($resource).'] with model ['.get_class($model).'] with ID ['.$model->getKey().'] to JSON: '.$message); + } + /** * Create a new JSON encoding exception for an attribute. * * @param mixed $model * @param mixed $key - * @param string $message + * @param string $message * @return static */ public static function forAttribute($model, $key, $message) diff --git a/Eloquent/MassPrunable.php b/Eloquent/MassPrunable.php new file mode 100644 index 0000000000..3edb605ace --- /dev/null +++ b/Eloquent/MassPrunable.php @@ -0,0 +1,52 @@ +prunable(), function ($query) use ($chunkSize) { + $query->when(! $query->getQuery()->limit, function ($query) use ($chunkSize) { + $query->limit($chunkSize); + }); + }); + + $total = 0; + + $softDeletable = static::isSoftDeletable(); + + do { + $total += $count = $softDeletable + ? $query->forceDelete() + : $query->delete(); + + if ($count > 0) { + event(new ModelsPruned(static::class, $total)); + } + } while ($count > 0); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + * + * @throws \LogicException + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } +} diff --git a/Eloquent/MissingAttributeException.php b/Eloquent/MissingAttributeException.php new file mode 100755 index 0000000000..ef05109927 --- /dev/null +++ b/Eloquent/MissingAttributeException.php @@ -0,0 +1,22 @@ +> */ + use HasCollection; /** * The connection name for the model. * - * @var string + * @var \UnitEnum|string|null */ protected $connection; /** * The table associated with the model. * - * @var string + * @var string|null */ protected $table; @@ -50,7 +76,7 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab protected $primaryKey = 'id'; /** - * The "type" of the auto-incrementing ID. + * The "type" of the primary key ID. * * @var string */ @@ -77,6 +103,13 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ protected $withCount = []; + /** + * Indicates whether lazy loading will be prevented on this model. + * + * @var bool + */ + public $preventsLazyLoading = false; + /** * The number of models to return for pagination. * @@ -92,12 +125,19 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab public $exists = false; /** - * Indicates if the model was inserted during the current request lifecycle. + * Indicates if the model was inserted during the object's lifecycle. * * @var bool */ public $wasRecentlyCreated = false; + /** + * Indicates that the object's string representation should be escaped when __toString is invoked. + * + * @var bool + */ + protected $escapeWhenCastingToString = false; + /** * The connection resolver instance. * @@ -108,10 +148,17 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab /** * The event dispatcher instance. * - * @var \Illuminate\Contracts\Events\Dispatcher + * @var \Illuminate\Contracts\Events\Dispatcher|null */ protected static $dispatcher; + /** + * The models that are currently being booted. + * + * @var array + */ + protected static $booting = []; + /** * The array of booted models. * @@ -119,6 +166,13 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ protected static $booted = []; + /** + * The callbacks that should be executed after the model has booted. + * + * @var array + */ + protected static $bootedCallbacks = []; + /** * The array of trait initializers that will be called on each new instance. * @@ -140,34 +194,129 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ protected static $ignoreOnTouch = []; + /** + * Indicates whether lazy loading should be restricted on all models. + * + * @var bool + */ + protected static $modelsShouldPreventLazyLoading = false; + + /** + * Indicates whether relations should be automatically loaded on all models when they are accessed. + * + * @var bool + */ + protected static $modelsShouldAutomaticallyEagerLoadRelationships = false; + + /** + * The callback that is responsible for handling lazy loading violations. + * + * @var (callable(self, string))|null + */ + protected static $lazyLoadingViolationCallback; + + /** + * Indicates if an exception should be thrown instead of silently discarding non-fillable attributes. + * + * @var bool + */ + protected static $modelsShouldPreventSilentlyDiscardingAttributes = false; + + /** + * The callback that is responsible for handling discarded attribute violations. + * + * @var (callable(self, array))|null + */ + protected static $discardedAttributeViolationCallback; + + /** + * Indicates if an exception should be thrown when trying to access a missing attribute on a retrieved model. + * + * @var bool + */ + protected static $modelsShouldPreventAccessingMissingAttributes = false; + + /** + * The callback that is responsible for handling missing attribute violations. + * + * @var (callable(self, string))|null + */ + protected static $missingAttributeViolationCallback; + + /** + * Indicates if broadcasting is currently enabled. + * + * @var bool + */ + protected static $isBroadcasting = true; + + /** + * The Eloquent query builder class to use for the model. + * + * @var class-string<\Illuminate\Database\Eloquent\Builder<*>> + */ + protected static string $builder = Builder::class; + + /** + * The Eloquent collection class to use for the model. + * + * @var class-string<\Illuminate\Database\Eloquent\Collection<*, *>> + */ + protected static string $collectionClass = Collection::class; + + /** + * Cache of soft deletable models. + * + * @var array, bool> + */ + protected static array $isSoftDeletable; + + /** + * Cache of prunable models. + * + * @var array, bool> + */ + protected static array $isPrunable; + + /** + * Cache of mass prunable models. + * + * @var array, bool> + */ + protected static array $isMassPrunable; + + /** + * Cache of resolved class attributes. + * + * @var array, array> + */ + protected static array $classAttributes = []; + /** * The name of the "created at" column. * - * @var string + * @var string|null */ const CREATED_AT = 'created_at'; /** * The name of the "updated at" column. * - * @var string + * @var string|null */ const UPDATED_AT = 'updated_at'; /** * Create a new Eloquent model instance. * - * @param array $attributes - * @return void + * @param array $attributes */ public function __construct(array $attributes = []) { $this->bootIfNotBooted(); - $this->initializeTraits(); - + $this->initializeModelAttributes(); $this->syncOriginal(); - $this->fill($attributes); } @@ -175,22 +324,50 @@ public function __construct(array $attributes = []) * Check if the model needs to be booted and if so, do it. * * @return void + * + * @throws \LogicException */ protected function bootIfNotBooted() { if (! isset(static::$booted[static::class])) { - static::$booted[static::class] = true; + if (isset(static::$booting[static::class])) { + throw new LogicException('The ['.__METHOD__.'] method may not be called on model ['.static::class.'] while it is being booted.'); + } + + static::$booting[static::class] = true; $this->fireModelEvent('booting', false); + static::booting(); static::boot(); + static::$booted[static::class] = true; + unset(static::$booting[static::class]); + + static::booted(); + + static::$bootedCallbacks[static::class] ??= []; + + foreach (static::$bootedCallbacks[static::class] as $callback) { + $callback(); + } + $this->fireModelEvent('booted', false); } } /** - * The "booting" method of the model. + * Perform any actions required before the model boots. + * + * @return void + */ + protected static function booting() + { + // + } + + /** + * Bootstrap the model and its traits. * * @return void */ @@ -212,23 +389,28 @@ protected static function bootTraits() static::$traitInitializers[$class] = []; - foreach (class_uses_recursive($class) as $trait) { - $method = 'boot'.class_basename($trait); + $uses = class_uses_recursive($class); - if (method_exists($class, $method) && ! in_array($method, $booted)) { - forward_static_call([$class, $method]); + $conventionalBootMethods = array_map(static fn ($trait) => 'boot'.class_basename($trait), $uses); + $conventionalInitMethods = array_map(static fn ($trait) => 'initialize'.class_basename($trait), $uses); - $booted[] = $method; - } + foreach ((new ReflectionClass($class))->getMethods() as $method) { + if (! in_array($method->getName(), $booted) && + $method->isStatic() && + (in_array($method->getName(), $conventionalBootMethods) || + $method->getAttributes(Boot::class) !== [])) { + $method->invoke(null); - if (method_exists($class, $method = 'initialize'.class_basename($trait))) { - static::$traitInitializers[$class][] = $method; + $booted[] = $method->getName(); + } - static::$traitInitializers[$class] = array_unique( - static::$traitInitializers[$class] - ); + if (in_array($method->getName(), $conventionalInitMethods) || + $method->getAttributes(Initialize::class) !== []) { + static::$traitInitializers[$class][] = $method->getName(); } } + + static::$traitInitializers[$class] = array_unique(static::$traitInitializers[$class]); } /** @@ -243,6 +425,54 @@ protected function initializeTraits() } } + /** + * Initialize the model attributes from class attributes. + * + * @return void + */ + public function initializeModelAttributes() + { + $table = static::resolveClassAttribute(Table::class); + + $this->table ??= $table->name ?? null; + $this->connection ??= static::resolveClassAttribute(Connection::class, 'name'); + + if ($this->primaryKey === 'id' && $table && $table->key !== null) { + $this->primaryKey = $table->key; + } + + if ($this->keyType === 'int' && $table && $table->keyType !== null) { + $this->keyType = $table->keyType; + } + + if ($this->incrementing === true && $table && $table->incrementing !== null) { + $this->incrementing = $table->incrementing; + } + } + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted() + { + // + } + + /** + * Register a closure to be executed after the model has booted. + * + * @param \Closure $callback + * @return void + */ + protected static function whenBooted(Closure $callback) + { + static::$bootedCallbacks[static::class] ??= []; + + static::$bootedCallbacks[static::class][] = $callback; + } + /** * Clear the list of booted models so they will be re-booted. * @@ -251,7 +481,8 @@ protected function initializeTraits() public static function clearBootedModels() { static::$booted = []; - + static::$bootedCallbacks = []; + static::$classAttributes = []; static::$globalScopes = []; } @@ -278,7 +509,7 @@ public static function withoutTouchingOn(array $models, callable $callback) static::$ignoreOnTouch = array_values(array_merge(static::$ignoreOnTouch, $models)); try { - call_user_func($callback); + $callback(); } finally { static::$ignoreOnTouch = array_values(array_diff(static::$ignoreOnTouch, $models)); } @@ -294,6 +525,17 @@ public static function isIgnoringTouch($class = null) { $class = $class ?: static::class; + if (! $class::UPDATED_AT) { + return true; + } + + $timestamps = static::resolveClassAttribute(Table::class, 'timestamps', $class) + ?? get_class_vars($class)['timestamps']; + + if (! $timestamps) { + return true; + } + foreach (static::$ignoreOnTouch as $ignoredClass) { if ($class === $ignoredClass || is_subclass_of($class, $ignoredClass)) { return true; @@ -303,10 +545,119 @@ public static function isIgnoringTouch($class = null) return false; } + /** + * Indicate that models should prevent lazy loading, silently discarding attributes, and accessing missing attributes. + * + * @param bool $shouldBeStrict + * @return void + */ + public static function shouldBeStrict(bool $shouldBeStrict = true) + { + static::preventLazyLoading($shouldBeStrict); + static::preventSilentlyDiscardingAttributes($shouldBeStrict); + static::preventAccessingMissingAttributes($shouldBeStrict); + } + + /** + * Prevent model relationships from being lazy loaded. + * + * @param bool $value + * @return void + */ + public static function preventLazyLoading($value = true) + { + static::$modelsShouldPreventLazyLoading = $value; + } + + /** + * Determine if model relationships should be automatically eager loaded when accessed. + * + * @param bool $value + * @return void + */ + public static function automaticallyEagerLoadRelationships($value = true) + { + static::$modelsShouldAutomaticallyEagerLoadRelationships = $value; + } + + /** + * Register a callback that is responsible for handling lazy loading violations. + * + * @param (callable(self, string))|null $callback + * @return void + */ + public static function handleLazyLoadingViolationUsing(?callable $callback) + { + static::$lazyLoadingViolationCallback = $callback; + } + + /** + * Prevent non-fillable attributes from being silently discarded. + * + * @param bool $value + * @return void + */ + public static function preventSilentlyDiscardingAttributes($value = true) + { + static::$modelsShouldPreventSilentlyDiscardingAttributes = $value; + } + + /** + * Register a callback that is responsible for handling discarded attribute violations. + * + * @param (callable(self, array))|null $callback + * @return void + */ + public static function handleDiscardedAttributeViolationUsing(?callable $callback) + { + static::$discardedAttributeViolationCallback = $callback; + } + + /** + * Prevent accessing missing attributes on retrieved models. + * + * @param bool $value + * @return void + */ + public static function preventAccessingMissingAttributes($value = true) + { + static::$modelsShouldPreventAccessingMissingAttributes = $value; + } + + /** + * Register a callback that is responsible for handling missing attribute violations. + * + * @param (callable(self, string))|null $callback + * @return void + */ + public static function handleMissingAttributeViolationUsing(?callable $callback) + { + static::$missingAttributeViolationCallback = $callback; + } + + /** + * Execute a callback without broadcasting any model events for all model types. + * + * @param callable $callback + * @return mixed + */ + public static function withoutBroadcasting(callable $callback) + { + $isBroadcasting = static::$isBroadcasting; + + static::$isBroadcasting = false; + + try { + return $callback(); + } finally { + static::$isBroadcasting = $isBroadcasting; + } + } + /** * Fill the model with an array of attributes. * - * @param array $attributes + * @param array $attributes * @return $this * * @throws \Illuminate\Database\Eloquent\MassAssignmentException @@ -315,18 +666,37 @@ public function fill(array $attributes) { $totallyGuarded = $this->totallyGuarded(); - foreach ($this->fillableFromArray($attributes) as $key => $value) { - $key = $this->removeTableFromKey($key); + $fillable = $this->fillableFromArray($attributes); + foreach ($fillable as $key => $value) { // The developers may choose to place some attributes in the "fillable" array // which means only those attributes may be set through mass assignment to // the model, and all others will just get ignored for security reasons. if ($this->isFillable($key)) { $this->setAttribute($key, $value); - } elseif ($totallyGuarded) { + } elseif ($totallyGuarded || static::preventsSilentlyDiscardingAttributes()) { + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, [$key]); + } else { + throw new MassAssignmentException(sprintf( + 'Add [%s] to fillable property to allow mass assignment on [%s].', + $key, get_class($this) + )); + } + } + } + + if (count($attributes) !== count($fillable) && + static::preventsSilentlyDiscardingAttributes()) { + $keys = array_diff(array_keys($attributes), array_keys($fillable)); + + if (isset(static::$discardedAttributeViolationCallback)) { + call_user_func(static::$discardedAttributeViolationCallback, $this, $keys); + } else { throw new MassAssignmentException(sprintf( - 'Add [%s] to fillable property to allow mass assignment on [%s].', - $key, get_class($this) + 'Add fillable property [%s] to allow mass assignment on [%s].', + implode(', ', $keys), + get_class($this) )); } } @@ -337,14 +707,12 @@ public function fill(array $attributes) /** * Fill the model with an array of attributes. Force mass assignment. * - * @param array $attributes + * @param array $attributes * @return $this */ public function forceFill(array $attributes) { - return static::unguarded(function () use ($attributes) { - return $this->fill($attributes); - }); + return static::unguarded(fn () => $this->fill($attributes)); } /** @@ -355,7 +723,7 @@ public function forceFill(array $attributes) */ public function qualifyColumn($column) { - if (Str::contains($column, '.')) { + if (str_contains($column, '.')) { return $column; } @@ -363,20 +731,22 @@ public function qualifyColumn($column) } /** - * Remove the table name from a given key. + * Qualify the given columns with the model's table. * - * @param string $key - * @return string + * @param array $columns + * @return array */ - protected function removeTableFromKey($key) + public function qualifyColumns($columns) { - return Str::contains($key, '.') ? last(explode('.', $key)) : $key; + return (new BaseCollection($columns)) + ->map(fn ($column) => $this->qualifyColumn($column)) + ->all(); } /** * Create a new instance of the given model. * - * @param array $attributes + * @param array $attributes * @param bool $exists * @return static */ @@ -385,7 +755,7 @@ public function newInstance($attributes = [], $exists = false) // This method just provides a convenient way for us to generate fresh model // instances of this current model. It is particularly useful during the // hydration of new objects via the Eloquent query builder instances. - $model = new static((array) $attributes); + $model = new static; $model->exists = $exists; @@ -395,14 +765,18 @@ public function newInstance($attributes = [], $exists = false) $model->setTable($this->getTable()); + $model->mergeCasts($this->casts); + + $model->fill((array) $attributes); + return $model; } /** * Create a new model instance that is existing. * - * @param array $attributes - * @param string|null $connection + * @param array $attributes + * @param \UnitEnum|string|null $connection * @return static */ public function newFromBuilder($attributes = [], $connection = null) @@ -411,7 +785,7 @@ public function newFromBuilder($attributes = [], $connection = null) $model->setRawAttributes((array) $attributes, true); - $model->setConnection($connection ?: $this->getConnectionName()); + $model->setConnection($connection ?? $this->getConnectionName()); $model->fireModelEvent('retrieved', false); @@ -421,25 +795,21 @@ public function newFromBuilder($attributes = [], $connection = null) /** * Begin querying the model on a given connection. * - * @param string|null $connection - * @return \Illuminate\Database\Eloquent\Builder + * @param \UnitEnum|string|null $connection + * @return \Illuminate\Database\Eloquent\Builder */ public static function on($connection = null) { // First we will just create a fresh instance of this model, and then we can set the // connection on the model so that it is used for the queries we execute, as well // as being set on every relation we retrieve without a custom connection name. - $instance = new static; - - $instance->setConnection($connection); - - return $instance->newQuery(); + return (new static)->setConnection($connection)->newQuery(); } /** * Begin querying the model on the write connection. * - * @return \Illuminate\Database\Query\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function onWriteConnection() { @@ -449,8 +819,8 @@ public static function onWriteConnection() /** * Get all of the models from the database. * - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Collection|static[] + * @param array|string $columns + * @return \Illuminate\Database\Eloquent\Collection */ public static function all($columns = ['*']) { @@ -463,7 +833,7 @@ public static function all($columns = ['*']) * Begin querying a model with eager loading. * * @param array|string $relations - * @return \Illuminate\Database\Eloquent\Builder|static + * @return \Illuminate\Database\Eloquent\Builder */ public static function with($relations) { @@ -490,20 +860,55 @@ public function load($relations) } /** - * Eager load relations on the model if they are not already eager loaded. + * Eager load relationships on the polymorphic relation of a model. * - * @param array|string $relations + * @param string $relation + * @param array $relations * @return $this */ - public function loadMissing($relations) + public function loadMorph($relation, $relations) { - $relations = is_string($relations) ? func_get_args() : $relations; + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->load($relations[$className] ?? []); + + return $this; + } + + /** + * Eager load relations on the model if they are not already eager loaded. + * + * @param array|string $relations + * @return $this + */ + public function loadMissing($relations) + { + $relations = is_string($relations) ? func_get_args() : $relations; $this->newCollection([$this])->loadMissing($relations); return $this; } + /** + * Eager load relation's column aggregations on the model. + * + * @param array|string $relations + * @param string $column + * @param string|null $function + * @return $this + */ + public function loadAggregate($relations, $column, $function = null) + { + $this->newCollection([$this])->loadAggregate($relations, $column, $function); + + return $this; + } + /** * Eager load relation counts on the model. * @@ -514,11 +919,154 @@ public function loadCount($relations) { $relations = is_string($relations) ? func_get_args() : $relations; - $this->newCollection([$this])->loadCount($relations); + return $this->loadAggregate($relations, '*', 'count'); + } + + /** + * Eager load relation max column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMax($relations, $column) + { + return $this->loadAggregate($relations, $column, 'max'); + } + + /** + * Eager load relation min column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadMin($relations, $column) + { + return $this->loadAggregate($relations, $column, 'min'); + } + + /** + * Eager load relation's column summations on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadSum($relations, $column) + { + return $this->loadAggregate($relations, $column, 'sum'); + } + + /** + * Eager load relation average column values on the model. + * + * @param array|string $relations + * @param string $column + * @return $this + */ + public function loadAvg($relations, $column) + { + return $this->loadAggregate($relations, $column, 'avg'); + } + + /** + * Eager load related model existence values on the model. + * + * @param array|string $relations + * @return $this + */ + public function loadExists($relations) + { + return $this->loadAggregate($relations, '*', 'exists'); + } + + /** + * Eager load relationship column aggregation on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @param string|null $function + * @return $this + */ + public function loadMorphAggregate($relation, $relations, $column, $function = null) + { + if (! $this->{$relation}) { + return $this; + } + + $className = get_class($this->{$relation}); + + $this->{$relation}->loadAggregate($relations[$className] ?? [], $column, $function); return $this; } + /** + * Eager load relationship counts on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @return $this + */ + public function loadMorphCount($relation, $relations) + { + return $this->loadMorphAggregate($relation, $relations, '*', 'count'); + } + + /** + * Eager load relationship max column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphMax($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'max'); + } + + /** + * Eager load relationship min column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphMin($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'min'); + } + + /** + * Eager load relationship column summations on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphSum($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'sum'); + } + + /** + * Eager load relationship average column values on the polymorphic relation of a model. + * + * @param string $relation + * @param array $relations + * @param string $column + * @return $this + */ + public function loadMorphAvg($relation, $relations, $column) + { + return $this->loadMorphAggregate($relation, $relations, $column, 'avg'); + } + /** * Increment a column's value by a given amount. * @@ -556,79 +1104,163 @@ protected function decrement($column, $amount = 1, array $extra = []) */ protected function incrementOrDecrement($column, $amount, $extra, $method) { - $query = $this->newQueryWithoutRelationships(); - if (! $this->exists) { - return $query->{$method}($column, $amount, $extra); + return $this->newQueryWithoutRelationships()->{$method}($column, $amount, $extra); + } + + $this->{$column} = $this->isClassDeviable($column) + ? $this->deviateClassCastableAttribute($method, $column, $amount) + : $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); + + $this->forceFill($extra); + + if ($this->fireModelEvent('updating') === false) { + return false; + } + + if ($this->isClassDeviable($column)) { + $amount = (clone $this)->setAttribute($column, $amount)->getAttributeFromArray($column); } - $this->incrementOrDecrementAttributeValue($column, $amount, $extra, $method); + return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), function () use ($column) { + $this->syncChanges(); - return $query->where( - $this->getKeyName(), $this->getKey() - )->{$method}($column, $amount, $extra); + $this->fireModelEvent('updated', false); + + $this->syncOriginalAttribute($column); + }); } /** - * Increment the underlying attribute value and sync with original. + * Update the model in the database. * - * @param string $column - * @param float|int $amount - * @param array $extra - * @param string $method - * @return void + * @param array $attributes + * @param array $options + * @return bool */ - protected function incrementOrDecrementAttributeValue($column, $amount, $extra, $method) + public function update(array $attributes = [], array $options = []) { - $this->{$column} = $this->{$column} + ($method === 'increment' ? $amount : $amount * -1); - - $this->forceFill($extra); + if (! $this->exists) { + return false; + } - $this->syncOriginalAttribute($column); + return $this->fill($attributes)->save($options); } /** - * Update the model in the database. + * Update the model in the database within a transaction. * - * @param array $attributes - * @param array $options + * @param array $attributes + * @param array $options * @return bool + * + * @throws \Throwable */ - public function update(array $attributes = [], array $options = []) + public function updateOrFail(array $attributes = [], array $options = []) { if (! $this->exists) { return false; } - return $this->fill($attributes)->save($options); + return $this->fill($attributes)->saveOrFail($options); } /** - * Save the model and all of its relationships. + * Update the model in the database without raising any events. * + * @param array $attributes + * @param array $options * @return bool */ - public function push() + public function updateQuietly(array $attributes = [], array $options = []) { - if (! $this->save()) { + if (! $this->exists) { return false; } - // To sync all of the relationships to the database, we will simply spin through - // the relationships and save each model via this "push" method, which allows - // us to recurse into all of these nested relations for the model instance. - foreach ($this->relations as $models) { - $models = $models instanceof Collection - ? $models->all() : [$models]; + return $this->fill($attributes)->saveQuietly($options); + } - foreach (array_filter($models) as $model) { - if (! $model->push()) { - return false; + /** + * Increment a column's value by a given amount without raising any events. + * + * @param string $column + * @param float|int $amount + * @param array $extra + * @return int + */ + protected function incrementQuietly($column, $amount = 1, array $extra = []) + { + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') + ); + } + + /** + * Decrement a column's value by a given amount without raising any events. + * + * @param string $column + * @param float|int $amount + * @param array $extra + * @return int + */ + protected function decrementQuietly($column, $amount = 1, array $extra = []) + { + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') + ); + } + + /** + * Save the model and all of its relationships. + * + * @return bool + */ + public function push() + { + return $this->withoutRecursion(function () { + if (! $this->save()) { + return false; + } + + // To sync all of the relationships to the database, we will simply spin through + // the relationships and save each model via this "push" method, which allows + // us to recurse into all of these nested relations for the model instance. + foreach ($this->relations as $models) { + $models = $models instanceof Collection + ? $models->all() + : [$models]; + + foreach (array_filter($models) as $model) { + if (! $model->push()) { + return false; + } } } - } - return true; + return true; + }, true); + } + + /** + * Save the model and all of its relationships without raising any events to the parent model. + * + * @return bool + */ + public function pushQuietly() + { + return static::withoutEvents(fn () => $this->push()); + } + + /** + * Save the model to the database without raising any events. + * + * @param array $options + * @return bool + */ + public function saveQuietly(array $options = []) + { + return static::withoutEvents(fn () => $this->save($options)); } /** @@ -639,6 +1271,8 @@ public function push() */ public function save(array $options = []) { + $this->mergeAttributesFromCachedCasts(); + $query = $this->newModelQuery(); // If the "saving" event returns false we'll bail out of the save and return @@ -653,7 +1287,7 @@ public function save(array $options = []) // clause to only update this model. Otherwise, we'll just insert them. if ($this->exists) { $saved = $this->isDirty() ? - $this->performUpdate($query) : true; + $this->performUpdate($query) : true; } // If the model is brand new, we'll insert it into our database and set the @@ -679,7 +1313,7 @@ public function save(array $options = []) } /** - * Save the model to the database using transaction. + * Save the model to the database within a transaction. * * @param array $options * @return bool @@ -688,9 +1322,7 @@ public function save(array $options = []) */ public function saveOrFail(array $options = []) { - return $this->getConnection()->transaction(function () use ($options) { - return $this->save($options); - }); + return $this->getConnection()->transaction(fn () => $this->save($options)); } /** @@ -713,7 +1345,7 @@ protected function finishSave(array $options) /** * Perform a model update operation. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $query * @return bool */ protected function performUpdate(Builder $query) @@ -735,7 +1367,7 @@ protected function performUpdate(Builder $query) // Once we have run the update operation, we will fire the "updated" event for // this model instance. This will allow developers to hook into these after // models are updated, giving them a chance to do any special processing. - $dirty = $this->getDirty(); + $dirty = $this->getDirtyForUpdate(); if (count($dirty) > 0) { $this->setKeysForSaveQuery($query)->update($dirty); @@ -748,13 +1380,36 @@ protected function performUpdate(Builder $query) return true; } + /** + * Set the keys for a select query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery($query) + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()); + + return $query; + } + + /** + * Get the primary key value for a select query. + * + * @return mixed + */ + protected function getKeyForSelectQuery() + { + return $this->original[$this->getKeyName()] ?? $this->getKey(); + } + /** * Set the keys for a save update query. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSaveQuery($query) { $query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery()); @@ -768,18 +1423,21 @@ protected function setKeysForSaveQuery(Builder $query) */ protected function getKeyForSaveQuery() { - return $this->original[$this->getKeyName()] - ?? $this->getKey(); + return $this->original[$this->getKeyName()] ?? $this->getKey(); } /** * Perform a model insert operation. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $query * @return bool */ protected function performInsert(Builder $query) { + if ($this->usesUniqueIds()) { + $this->setUniqueIds(); + } + if ($this->fireModelEvent('creating') === false) { return false; } @@ -794,7 +1452,7 @@ protected function performInsert(Builder $query) // If the model has an incrementing key, we can use the "insertGetId" method on // the query builder, which will give us back the final inserted ID for this // table from the database. Not all tables have to be incrementing though. - $attributes = $this->getAttributes(); + $attributes = $this->getAttributesForInsert(); if ($this->getIncrementing()) { $this->insertAndSetId($query, $attributes); @@ -826,8 +1484,8 @@ protected function performInsert(Builder $query) /** * Insert the given attributes and set the ID on the model. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param array $attributes + * @param \Illuminate\Database\Eloquent\Builder $query + * @param array $attributes * @return void */ protected function insertAndSetId(Builder $query, $attributes) @@ -840,15 +1498,14 @@ protected function insertAndSetId(Builder $query, $attributes) /** * Destroy the models for the given IDs. * - * @param \Illuminate\Support\Collection|array|int $ids + * @param \Illuminate\Support\Collection|array|int|string $ids * @return int */ public static function destroy($ids) { - // We'll initialize a count here so we will return the total number of deletes - // for the operation. The developers can then check this number as a boolean - // type value or get this total count of records deleted for logging, etc. - $count = 0; + if ($ids instanceof EloquentCollection) { + $ids = $ids->modelKeys(); + } if ($ids instanceof BaseCollection) { $ids = $ids->all(); @@ -856,11 +1513,17 @@ public static function destroy($ids) $ids = is_array($ids) ? $ids : func_get_args(); + if (count($ids) === 0) { + return 0; + } + // We will actually pull the models from the database table and call delete on // each of them individually so that their events get fired properly with a // correct set of attributes in case the developers wants to check these. $key = ($instance = new static)->getKeyName(); + $count = 0; + foreach ($instance->whereIn($key, $ids)->get() as $model) { if ($model->delete()) { $count++; @@ -875,12 +1538,14 @@ public static function destroy($ids) * * @return bool|null * - * @throws \Exception + * @throws \LogicException */ public function delete() { + $this->mergeAttributesFromCachedCasts(); + if (is_null($this->getKeyName())) { - throw new Exception('No primary key defined on model.'); + throw new LogicException('No primary key defined on model.'); } // If the model doesn't exist, there is nothing to delete so we'll just return @@ -894,25 +1559,51 @@ public function delete() return false; } - // Here, we'll touch the owning models, verifying these timestamps get updated - // for the models. This will allow any caching to get broken on the parents - // by the timestamp. Then we will go ahead and delete the model instance. - $this->touchOwners(); - - $this->performDeleteOnModel(); - - // Once the model has been deleted, we will fire off the deleted event so that - // the developers may hook into post-delete operations. We will then return - // a boolean true as the delete is presumably successful on the database. - $this->fireModelEvent('deleted', false); - - return true; + // Here, we'll touch the owning models, verifying these timestamps get updated + // for the models. This will allow any caching to get broken on the parents + // by the timestamp. Then we will go ahead and delete the model instance. + $this->touchOwners(); + + $this->performDeleteOnModel(); + + // Once the model has been deleted, we will fire off the deleted event so that + // the developers may hook into post-delete operations. We will then return + // a boolean true as the delete is presumably successful on the database. + $this->fireModelEvent('deleted', false); + + return true; + } + + /** + * Delete the model from the database without raising any events. + * + * @return bool + */ + public function deleteQuietly() + { + return static::withoutEvents(fn () => $this->delete()); + } + + /** + * Delete the model from the database within a transaction. + * + * @return bool|null + * + * @throws \Throwable + */ + public function deleteOrFail() + { + if (! $this->exists) { + return false; + } + + return $this->getConnection()->transaction(fn () => $this->delete()); } /** * Force a hard delete on a soft deleted model. * - * This method protects developers from running forceDelete when trait is missing. + * This method protects developers from running forceDelete when the trait is missing. * * @return bool|null */ @@ -921,6 +1612,19 @@ public function forceDelete() return $this->delete(); } + /** + * Force a hard destroy on a soft deleted model. + * + * This method protects developers from running forceDestroy when the trait is missing. + * + * @param \Illuminate\Support\Collection|array|int|string $ids + * @return bool|null + */ + public static function forceDestroy($ids) + { + return static::destroy($ids); + } + /** * Perform the actual delete query on this model instance. * @@ -936,7 +1640,7 @@ protected function performDeleteOnModel() /** * Begin querying the model. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function query() { @@ -946,7 +1650,7 @@ public static function query() /** * Get a new query builder for the model's table. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQuery() { @@ -956,7 +1660,7 @@ public function newQuery() /** * Get a new query builder that doesn't have any global scopes or eager loading. * - * @return \Illuminate\Database\Eloquent\Builder|static + * @return \Illuminate\Database\Eloquent\Builder */ public function newModelQuery() { @@ -968,7 +1672,7 @@ public function newModelQuery() /** * Get a new query builder with no relationships loaded. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryWithoutRelationships() { @@ -978,8 +1682,8 @@ public function newQueryWithoutRelationships() /** * Register the global scopes for this builder instance. * - * @param \Illuminate\Database\Eloquent\Builder $builder - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $builder + * @return \Illuminate\Database\Eloquent\Builder */ public function registerGlobalScopes($builder) { @@ -993,20 +1697,20 @@ public function registerGlobalScopes($builder) /** * Get a new query builder that doesn't have any global scopes. * - * @return \Illuminate\Database\Eloquent\Builder|static + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryWithoutScopes() { return $this->newModelQuery() - ->with($this->with) - ->withCount($this->withCount); + ->with($this->with) + ->withCount($this->withCount); } /** * Get a new query instance without a given scope. * * @param \Illuminate\Database\Eloquent\Scope|string $scope - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryWithoutScope($scope) { @@ -1017,52 +1721,60 @@ public function newQueryWithoutScope($scope) * Get a new query to restore one or more models by their queueable IDs. * * @param array|int $ids - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryForRestoration($ids) { - return is_array($ids) - ? $this->newQueryWithoutScopes()->whereIn($this->getQualifiedKeyName(), $ids) - : $this->newQueryWithoutScopes()->whereKey($ids); + return $this->newQueryWithoutScopes()->whereKey($ids); } /** * Create a new Eloquent query builder for the model. * * @param \Illuminate\Database\Query\Builder $query - * @return \Illuminate\Database\Eloquent\Builder|static + * @return \Illuminate\Database\Eloquent\Builder<*> */ public function newEloquentBuilder($query) { - return new Builder($query); + $builderClass = $this->resolveCustomBuilderClass(); + + if ($builderClass && is_subclass_of($builderClass, Builder::class)) { + return new $builderClass($query); + } + + return new static::$builder($query); } /** - * Get a new query builder instance for the connection. + * Resolve the custom Eloquent builder class from the model attributes. * - * @return \Illuminate\Database\Query\Builder + * @return class-string<\Illuminate\Database\Eloquent\Builder>|false */ - protected function newBaseQueryBuilder() + protected function resolveCustomBuilderClass() { - return $this->getConnection()->query(); + $attributes = (new ReflectionClass($this)) + ->getAttributes(UseEloquentBuilder::class); + + return ! empty($attributes) + ? $attributes[0]->newInstance()->builderClass + : false; } /** - * Create a new Eloquent Collection instance. + * Get a new query builder instance for the connection. * - * @param array $models - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Query\Builder */ - public function newCollection(array $models = []) + protected function newBaseQueryBuilder() { - return new Collection($models); + return $this->getConnection()->query(); } /** * Create a new pivot model instance. * * @param \Illuminate\Database\Eloquent\Model $parent - * @param array $attributes + * @param array $attributes * @param string $table * @param bool $exists * @param string|null $using @@ -1071,7 +1783,48 @@ public function newCollection(array $models = []) public function newPivot(self $parent, array $attributes, $table, $exists, $using = null) { return $using ? $using::fromRawAttributes($parent, $attributes, $table, $exists) - : Pivot::fromAttributes($parent, $attributes, $table, $exists); + : Pivot::fromAttributes($parent, $attributes, $table, $exists); + } + + /** + * Determine if the model has a given scope. + * + * @param string $scope + * @return bool + */ + public function hasNamedScope($scope) + { + return method_exists($this, 'scope'.ucfirst($scope)) || + static::isScopeMethodWithAttribute($scope); + } + + /** + * Apply the given named scope if possible. + * + * @param string $scope + * @param array $parameters + * @return mixed + */ + public function callNamedScope($scope, array $parameters = []) + { + if ($this->isScopeMethodWithAttribute($scope)) { + return $this->{$scope}(...$parameters); + } + + return $this->{'scope'.ucfirst($scope)}(...$parameters); + } + + /** + * Determine if the given method has a scope attribute. + * + * @param string $method + * @return bool + */ + protected static function isScopeMethodWithAttribute(string $method) + { + return method_exists(static::class, $method) && + (new ReflectionMethod(static::class, $method)) + ->getAttributes(LocalScope::class) !== []; } /** @@ -1081,7 +1834,10 @@ public function newPivot(self $parent, array $attributes, $table, $exists, $usin */ public function toArray() { - return array_merge($this->attributesToArray(), $this->relationsToArray()); + return $this->withoutRecursion( + fn () => array_merge($this->attributesToArray(), $this->relationsToArray()), + fn () => $this->attributesToArray(), + ); } /** @@ -1094,21 +1850,34 @@ public function toArray() */ public function toJson($options = 0) { - $json = json_encode($this->jsonSerialize(), $options); - - if (JSON_ERROR_NONE !== json_last_error()) { - throw JsonEncodingException::forModel($this, json_last_error_msg()); + try { + $json = json_encode($this->jsonSerialize(), $options | JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw JsonEncodingException::forModel($this, $e->getMessage()); } return $json; } + /** + * Convert the model instance to pretty print formatted JSON. + * + * @param int $options + * @return string + * + * @throws \Illuminate\Database\Eloquent\JsonEncodingException + */ + public function toPrettyJson(int $options = 0) + { + return $this->toJson(JSON_PRETTY_PRINT | $options); + } + /** * Convert the object into something JSON serializable. * - * @return array + * @return mixed */ - public function jsonSerialize() + public function jsonSerialize(): mixed { return $this->toArray(); } @@ -1125,10 +1894,10 @@ public function fresh($with = []) return; } - return static::newQueryWithoutScopes() - ->with(is_string($with) ? func_get_args() : $with) - ->where($this->getKeyName(), $this->getKey()) - ->first(); + return $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) + ->useWritePdo() + ->with(is_string($with) ? func_get_args() : $with) + ->first(); } /** @@ -1143,10 +1912,16 @@ public function refresh() } $this->setRawAttributes( - static::newQueryWithoutScopes()->findOrFail($this->getKey())->attributes + $this->setKeysForSelectQuery($this->newQueryWithoutScopes()) + ->useWritePdo() + ->firstOrFail() + ->attributes ); - $this->load(collect($this->relations)->except('pivot')->keys()->toArray()); + $this->load((new BaseCollection($this->relations))->reject( + fn ($relation) => $relation instanceof Pivot + || (is_object($relation) && in_array(AsPivot::class, class_uses_recursive($relation), true)) + )->keys()->all()); $this->syncOriginal(); @@ -1159,25 +1934,40 @@ public function refresh() * @param array|null $except * @return static */ - public function replicate(array $except = null) + public function replicate(?array $except = null) { - $defaults = [ + $defaults = array_values(array_filter([ $this->getKeyName(), $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), - ]; + ...$this->uniqueIds(), + 'laravel_through_key', + ])); $attributes = Arr::except( - $this->attributes, $except ? array_unique(array_merge($except, $defaults)) : $defaults + $this->getAttributes(), $except ? array_unique(array_merge($except, $defaults)) : $defaults ); return tap(new static, function ($instance) use ($attributes) { $instance->setRawAttributes($attributes); $instance->setRelations($this->relations); + + $instance->fireModelEvent('replicating', false); }); } + /** + * Clone the model into a new, non-existing instance without raising any events. + * + * @param array|null $except + * @return static + */ + public function replicateQuietly(?array $except = null) + { + return static::withoutEvents(fn () => $this->replicate($except)); + } + /** * Determine if two models have the same ID and belong to the same table. * @@ -1187,9 +1977,9 @@ public function replicate(array $except = null) public function is($model) { return ! is_null($model) && - $this->getKey() === $model->getKey() && - $this->getTable() === $model->getTable() && - $this->getConnectionName() === $model->getConnectionName(); + $this->getKey() === $model->getKey() && + $this->getTable() === $model->getTable() && + $this->getConnectionName() === $model->getConnectionName(); } /** @@ -1216,17 +2006,17 @@ public function getConnection() /** * Get the current connection name for the model. * - * @return string + * @return string|null */ public function getConnectionName() { - return $this->connection; + return enum_value($this->connection); } /** * Set the connection associated with the model. * - * @param string $name + * @param \UnitEnum|string|null $name * @return $this */ public function setConnection($name) @@ -1239,7 +2029,7 @@ public function setConnection($name) /** * Resolve a connection instance. * - * @param string|null $connection + * @param \UnitEnum|string|null $connection * @return \Illuminate\Database\Connection */ public static function resolveConnection($connection = null) @@ -1250,7 +2040,7 @@ public static function resolveConnection($connection = null) /** * Get the connection resolver instance. * - * @return \Illuminate\Database\ConnectionResolverInterface + * @return \Illuminate\Database\ConnectionResolverInterface|null */ public static function getConnectionResolver() { @@ -1285,9 +2075,7 @@ public static function unsetConnectionResolver() */ public function getTable() { - return isset($this->table) - ? $this->table - : Str::snake(Str::pluralStudly(class_basename($this))); + return $this->table ?? Str::snake(Str::pluralStudly(class_basename($this))); } /** @@ -1409,35 +2197,37 @@ public function getQueueableId() */ public function getQueueableRelations() { - $relations = []; + return $this->withoutRecursion(function () { + $relations = []; - foreach ($this->getRelations() as $key => $relation) { - if (! method_exists($this, $key)) { - continue; - } + foreach ($this->getRelations() as $key => $relation) { + if (! method_exists($this, $key)) { + continue; + } - $relations[] = $key; + $relations[] = $key; - if ($relation instanceof QueueableCollection) { - foreach ($relation->getQueueableRelations() as $collectionValue) { - $relations[] = $key.'.'.$collectionValue; + if ($relation instanceof QueueableCollection) { + foreach ($relation->getQueueableRelations() as $collectionValue) { + $relations[] = $key.'.'.$collectionValue; + } } - } - if ($relation instanceof QueueableEntity) { - foreach ($relation->getQueueableRelations() as $entityKey => $entityValue) { - $relations[] = $key.'.'.$entityValue; + if ($relation instanceof QueueableEntity) { + foreach ($relation->getQueueableRelations() as $entityValue) { + $relations[] = $key.'.'.$entityValue; + } } } - } - return array_unique($relations); + return array_unique($relations); + }, []); } /** * Get the queueable connection for the entity. * - * @return mixed + * @return string|null */ public function getQueueableConnection() { @@ -1468,11 +2258,98 @@ public function getRouteKeyName() * Retrieve the model for a bound value. * * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveRouteBinding($value, $field = null) + { + return $this->resolveRouteBindingQuery($this, $value, $field)->first(); + } + + /** + * Retrieve the model for a bound value. + * + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveSoftDeletableRouteBinding($value, $field = null) + { + return $this->resolveRouteBindingQuery($this, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Model|null + */ + public function resolveChildRouteBinding($childType, $value, $field) + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->first(); + } + + /** + * Retrieve the child model for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field * @return \Illuminate\Database\Eloquent\Model|null */ - public function resolveRouteBinding($value) + public function resolveSoftDeletableChildRouteBinding($childType, $value, $field) + { + return $this->resolveChildRouteBindingQuery($childType, $value, $field)->withTrashed()->first(); + } + + /** + * Retrieve the child model query for a bound value. + * + * @param string $childType + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, $this, *> + */ + protected function resolveChildRouteBindingQuery($childType, $value, $field) + { + $relationship = $this->{$this->childRouteBindingRelationshipName($childType)}(); + + $field = $field ?: $relationship->getRelated()->getRouteKeyName(); + + if ($relationship instanceof HasManyThrough || + $relationship instanceof BelongsToMany) { + $field = $relationship->getRelated()->qualifyColumn($field); + } + + return $relationship instanceof Model + ? $relationship->resolveRouteBindingQuery($relationship, $value, $field) + : $relationship->getRelated()->resolveRouteBindingQuery($relationship, $value, $field); + } + + /** + * Retrieve the child route model binding relationship name for the given child type. + * + * @param string $childType + * @return string + */ + protected function childRouteBindingRelationshipName($childType) + { + return Str::plural(Str::camel($childType)); + } + + /** + * Retrieve the model for a bound value. + * + * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Contracts\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Relations\Relation $query + * @param mixed $value + * @param string|null $field + * @return \Illuminate\Contracts\Database\Eloquent\Builder + */ + public function resolveRouteBindingQuery($query, $value, $field = null) { - return $this->where($this->getRouteKeyName(), $value)->first(); + return $query->where($field ?? $this->getRouteKeyName(), $value); } /** @@ -1508,6 +2385,129 @@ public function setPerPage($perPage) return $this; } + /** + * Determine if the model is soft deletable. + */ + public static function isSoftDeletable(): bool + { + return static::$isSoftDeletable[static::class] ??= in_array(SoftDeletes::class, class_uses_recursive(static::class)); + } + + /** + * Determine if the model is prunable. + */ + protected function isPrunable(): bool + { + return self::$isPrunable[static::class] ??= in_array(Prunable::class, class_uses_recursive(static::class)) || static::isMassPrunable(); + } + + /** + * Determine if the model is mass prunable. + */ + protected function isMassPrunable(): bool + { + return self::$isMassPrunable[static::class] ??= in_array(MassPrunable::class, class_uses_recursive(static::class)); + } + + /** + * Determine if lazy loading is disabled. + * + * @return bool + */ + public static function preventsLazyLoading() + { + return static::$modelsShouldPreventLazyLoading; + } + + /** + * Determine if relationships are being automatically eager loaded when accessed. + * + * @return bool + */ + public static function isAutomaticallyEagerLoadingRelationships() + { + return static::$modelsShouldAutomaticallyEagerLoadRelationships; + } + + /** + * Determine if discarding guarded attribute fills is disabled. + * + * @return bool + */ + public static function preventsSilentlyDiscardingAttributes() + { + return static::$modelsShouldPreventSilentlyDiscardingAttributes; + } + + /** + * Determine if accessing missing attributes is disabled. + * + * @return bool + */ + public static function preventsAccessingMissingAttributes() + { + return static::$modelsShouldPreventAccessingMissingAttributes; + } + + /** + * Get the broadcast channel route definition that is associated with the given entity. + * + * @return string + */ + public function broadcastChannelRoute() + { + return str_replace('\\', '.', get_class($this)).'.{'.Str::camel(class_basename($this)).'}'; + } + + /** + * Get the broadcast channel name that is associated with the given entity. + * + * @return string + */ + public function broadcastChannel() + { + return str_replace('\\', '.', get_class($this)).'.'.$this->getKey(); + } + + /** + * Resolve a class attribute value from the model. + * + * @template TAttribute of object + * + * @param class-string $attributeClass + * @param string|null $property + * @param string|null $class + * @return mixed + */ + protected static function resolveClassAttribute(string $attributeClass, ?string $property = null, ?string $class = null) + { + $class = $class ?? static::class; + + $cacheKey = $class.'@'.$attributeClass; + + if (array_key_exists($cacheKey, static::$classAttributes)) { + return static::$classAttributes[$cacheKey]; + } + + try { + $reflection = new ReflectionClass($class); + + do { + $attributes = $reflection->getAttributes($attributeClass); + + if (count($attributes) > 0) { + $instance = $attributes[0]->newInstance(); + + return static::$classAttributes[$cacheKey] = $property ? $instance->{$property} : $instance; + } + } while ($reflection = $reflection->getParentClass()); + } catch (Exception) { + // + } + + return static::$classAttributes[$cacheKey] = null; + } + /** * Dynamically retrieve attributes on the model. * @@ -1537,9 +2537,17 @@ public function __set($key, $value) * @param mixed $offset * @return bool */ - public function offsetExists($offset) + public function offsetExists($offset): bool { - return ! is_null($this->getAttribute($offset)); + $shouldPrevent = static::$modelsShouldPreventAccessingMissingAttributes; + + static::$modelsShouldPreventAccessingMissingAttributes = false; + + try { + return ! is_null($this->getAttribute($offset)); + } finally { + static::$modelsShouldPreventAccessingMissingAttributes = $shouldPrevent; + } } /** @@ -1548,7 +2556,7 @@ public function offsetExists($offset) * @param mixed $offset * @return mixed */ - public function offsetGet($offset) + public function offsetGet($offset): mixed { return $this->getAttribute($offset); } @@ -1560,7 +2568,7 @@ public function offsetGet($offset) * @param mixed $value * @return void */ - public function offsetSet($offset, $value) + public function offsetSet($offset, $value): void { $this->setAttribute($offset, $value); } @@ -1571,9 +2579,14 @@ public function offsetSet($offset, $value) * @param mixed $offset * @return void */ - public function offsetUnset($offset) + public function offsetUnset($offset): void { - unset($this->attributes[$offset], $this->relations[$offset]); + unset( + $this->attributes[$offset], + $this->relations[$offset], + $this->attributeCastCache[$offset], + $this->classCastCache[$offset] + ); } /** @@ -1607,15 +2620,24 @@ public function __unset($key) */ public function __call($method, $parameters) { - if (in_array($method, ['increment', 'decrement'])) { + if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly'])) { return $this->$method(...$parameters); } + if ($resolver = $this->relationResolver(static::class, $method)) { + return $resolver($this); + } + + if (Str::startsWith($method, 'through') && + method_exists($this, $relationMethod = (new SupportStringable($method))->after('through')->lcfirst()->toString())) { + return $this->through($relationMethod); + } + return $this->forwardCallTo($this->newQuery(), $method, $parameters); } /** - * Handle dynamic static method calls into the method. + * Handle dynamic static method calls into the model. * * @param string $method * @param array $parameters @@ -1623,6 +2645,10 @@ public function __call($method, $parameters) */ public static function __callStatic($method, $parameters) { + if (static::isScopeMethodWithAttribute($method)) { + return static::query()->$method(...$parameters); + } + return (new static)->$method(...$parameters); } @@ -1633,7 +2659,49 @@ public static function __callStatic($method, $parameters) */ public function __toString() { - return $this->toJson(); + return $this->escapeWhenCastingToString + ? e($this->toJson()) + : $this->toJson(); + } + + /** + * Indicate that the object's string representation should be escaped when __toString is invoked. + * + * @param bool $escape + * @return $this + */ + public function escapeWhenCastingToString($escape = true) + { + $this->escapeWhenCastingToString = $escape; + + return $this; + } + + /** + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep() + { + $this->mergeAttributesFromCachedCasts(); + + $this->classCastCache = []; + $this->attributeCastCache = []; + $this->relationAutoloadCallback = null; + $this->relationAutoloadContext = null; + + $keys = get_object_vars($this); + + if (version_compare(PHP_VERSION, '8.4.0', '>=')) { + foreach ((new ReflectionClass($this))->getProperties() as $property) { + if ($property->hasHooks()) { + unset($keys[$property->getName()]); + } + } + } + + return array_keys($keys); } /** @@ -1644,5 +2712,11 @@ public function __toString() public function __wakeup() { $this->bootIfNotBooted(); + $this->initializeTraits(); + $this->initializeModelAttributes(); + + if (static::isAutomaticallyEagerLoadingRelationships()) { + $this->withRelationshipAutoloading(); + } } } diff --git a/Eloquent/ModelInfo.php b/Eloquent/ModelInfo.php new file mode 100644 index 0000000000..9ff740ffda --- /dev/null +++ b/Eloquent/ModelInfo.php @@ -0,0 +1,97 @@ + + */ +class ModelInfo implements Arrayable, ArrayAccess +{ + /** + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $class The model's fully-qualified class. + * @param string $database The database connection name. + * @param string $table The database table name. + * @param class-string|null $policy The policy that applies to the model. + * @param \Illuminate\Support\Collection> $attributes The attributes available on the model. + * @param \Illuminate\Support\Collection}> $relations The relations defined on the model. + * @param \Illuminate\Support\Collection $events The events that the model dispatches. + * @param \Illuminate\Support\Collection}> $observers The observers registered for the model. + * @param class-string<\Illuminate\Database\Eloquent\Collection> $collection The Collection class that collects the models. + * @param class-string<\Illuminate\Database\Eloquent\Builder> $builder The Builder class registered for the model. + * @param \Illuminate\Http\Resources\Json\JsonResource|null $resource The JSON resource that represents the model. + */ + public function __construct( + public $class, + public $database, + public $table, + public $policy, + public $attributes, + public $relations, + public $events, + public $observers, + public $collection, + public $builder, + public $resource + ) { + } + + /** + * Convert the model info to an array. + * + * @return array{ + * "class": class-string<\Illuminate\Database\Eloquent\Model>, + * database: string, + * table: string, + * policy: class-string|null, + * attributes: \Illuminate\Support\Collection>, + * relations: \Illuminate\Support\Collection}>, + * events: \Illuminate\Support\Collection, + * observers: \Illuminate\Support\Collection}>, collection: class-string<\Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model>>, + * builder: class-string<\Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model>> + * resource: \Illuminate\Http\Resources\Json\JsonResource|null + * } + */ + public function toArray() + { + return [ + 'class' => $this->class, + 'database' => $this->database, + 'table' => $this->table, + 'policy' => $this->policy, + 'attributes' => $this->attributes, + 'relations' => $this->relations, + 'events' => $this->events, + 'observers' => $this->observers, + 'collection' => $this->collection, + 'builder' => $this->builder, + 'resource' => $this->resource, + ]; + } + + public function offsetExists(mixed $offset): bool + { + return property_exists($this, $offset); + } + + public function offsetGet(mixed $offset): mixed + { + return property_exists($this, $offset) ? $this->{$offset} : throw new InvalidArgumentException("Property {$offset} does not exist."); + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new LogicException(self::class.' may not be mutated using array access.'); + } + + public function offsetUnset(mixed $offset): void + { + throw new LogicException(self::class.' may not be mutated using array access.'); + } +} diff --git a/Eloquent/ModelInspector.php b/Eloquent/ModelInspector.php new file mode 100644 index 0000000000..b89d6bccfb --- /dev/null +++ b/Eloquent/ModelInspector.php @@ -0,0 +1,414 @@ + + */ + protected $relationMethods = [ + 'hasMany', + 'hasManyThrough', + 'hasOneThrough', + 'belongsToMany', + 'hasOne', + 'belongsTo', + 'morphOne', + 'morphTo', + 'morphMany', + 'morphToMany', + 'morphedByMany', + ]; + + /** + * Create a new model inspector instance. + * + * @param \Illuminate\Contracts\Foundation\Application $app The Laravel application instance. + */ + public function __construct(protected Application $app) + { + } + + /** + * Extract model details for the given model. + * + * @param class-string<\Illuminate\Database\Eloquent\Model>|string $model + * @param string|null $connection + * @return \Illuminate\Database\Eloquent\ModelInfo + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + public function inspect($model, $connection = null) + { + $class = $this->qualifyModel($model); + + /** @var \Illuminate\Database\Eloquent\Model $model */ + $model = $this->app->make($class); + + if ($connection !== null) { + $model->setConnection($connection); + } + + return new ModelInfo( + class: $model::class, + database: $model->getConnection()->getName(), + table: $model->getConnection()->getTablePrefix().$model->getTable(), + policy: $this->getPolicy($model), + attributes: $this->getAttributes($model), + relations: $this->getRelations($model), + events: $this->getEvents($model), + observers: $this->getObservers($model), + collection: $this->getCollectedBy($model), + builder: $this->getBuilder($model), + resource: $this->getResource($model), + ); + } + + /** + * Get the column attributes for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection> + */ + protected function getAttributes($model) + { + $connection = $model->getConnection(); + $schema = $connection->getSchemaBuilder(); + $table = $model->getTable(); + $columns = $schema->getColumns($table); + $indexes = $schema->getIndexes($table); + + return (new BaseCollection($columns)) + ->map(fn ($column) => [ + 'name' => $column['name'], + 'type' => $column['type'], + 'increments' => $column['auto_increment'], + 'nullable' => $column['nullable'], + 'default' => $this->getColumnDefault($column, $model), + 'unique' => $this->columnIsUnique($column['name'], $indexes), + 'fillable' => $model->isFillable($column['name']), + 'hidden' => $this->attributeIsHidden($column['name'], $model), + 'appended' => null, + 'cast' => $this->getCastType($column['name'], $model), + ]) + ->merge($this->getVirtualAttributes($model, $columns)); + } + + /** + * Get the virtual (non-column) attributes for the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param array $columns + * @return \Illuminate\Support\Collection + */ + protected function getVirtualAttributes($model, $columns) + { + $class = new ReflectionClass($model); + + return (new BaseCollection($class->getMethods())) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() === Model::class + ) + ->mapWithKeys(function (ReflectionMethod $method) use ($model) { + if (preg_match('/^get(.+)Attribute$/', $method->getName(), $matches) === 1) { + return [Str::snake($matches[1]) => 'accessor']; + } elseif ($model->hasAttributeMutator($method->getName())) { + return [Str::snake($method->getName()) => 'attribute']; + } else { + return []; + } + }) + ->reject(fn ($cast, $name) => (new BaseCollection($columns))->contains('name', $name)) + ->map(fn ($cast, $name) => [ + 'name' => $name, + 'type' => null, + 'increments' => false, + 'nullable' => null, + 'default' => null, + 'unique' => null, + 'fillable' => $model->isFillable($name), + 'hidden' => $this->attributeIsHidden($name, $model), + 'appended' => $model->hasAppended($name), + 'cast' => $cast, + ]) + ->values(); + } + + /** + * Get the relations from the given model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getRelations($model) + { + return (new BaseCollection(get_class_methods($model))) + ->map(fn ($method) => new ReflectionMethod($model, $method)) + ->reject( + fn (ReflectionMethod $method) => $method->isStatic() + || $method->isAbstract() + || $method->getDeclaringClass()->getName() === Model::class + || $method->getNumberOfParameters() > 0 + ) + ->filter(function (ReflectionMethod $method) { + if ($method->getReturnType() instanceof ReflectionNamedType + && is_subclass_of($method->getReturnType()->getName(), Relation::class)) { + return true; + } + + $file = new SplFileObject($method->getFileName()); + $file->seek($method->getStartLine() - 1); + $code = ''; + while ($file->key() < $method->getEndLine()) { + $code .= trim($file->current()); + $file->next(); + } + + return (new BaseCollection($this->relationMethods)) + ->contains(fn ($relationMethod) => str_contains($code, '$this->'.$relationMethod.'(')); + }) + ->map(function (ReflectionMethod $method) use ($model) { + $relation = $method->invoke($model); + + if (! $relation instanceof Relation) { + return null; + } + + return [ + 'name' => $method->getName(), + 'type' => Str::afterLast(get_class($relation), '\\'), + 'related' => get_class($relation->getRelated()), + ]; + }) + ->filter() + ->values(); + } + + /** + * Get the first policy associated with this model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return string|null + */ + protected function getPolicy($model) + { + $policy = Gate::getPolicyFor($model::class); + + return $policy ? $policy::class : null; + } + + /** + * Get the events that the model dispatches. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getEvents($model) + { + return (new BaseCollection($model->dispatchesEvents())) + ->map(fn (string $class, string $event) => [ + 'event' => $event, + 'class' => $class, + ])->values(); + } + + /** + * Get the observers watching this model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + */ + protected function getObservers($model) + { + $listeners = $this->app->make('events')->getRawListeners(); + + // Get the Eloquent observers for this model... + $listeners = array_filter($listeners, function ($v, $key) use ($model) { + return Str::startsWith($key, 'eloquent.') && Str::endsWith($key, $model::class); + }, ARRAY_FILTER_USE_BOTH); + + // Format listeners Eloquent verb => Observer methods... + $extractVerb = function ($key) { + preg_match('/eloquent.([a-zA-Z]+)\: /', $key, $matches); + + return $matches[1] ?? '?'; + }; + + $formatted = []; + + foreach ($listeners as $key => $observerMethods) { + $formatted[] = [ + 'event' => $extractVerb($key), + 'observer' => array_map(fn ($obs) => is_string($obs) ? $obs : 'Closure', $observerMethods), + ]; + } + + return new BaseCollection($formatted); + } + + /** + * Get the collection class being used by the model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return class-string<\Illuminate\Database\Eloquent\Collection> + */ + protected function getCollectedBy($model) + { + return $model->newCollection()::class; + } + + /** + * Get the builder class being used by the model. + * + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param TModel $model + * @return class-string<\Illuminate\Database\Eloquent\Builder> + */ + protected function getBuilder($model) + { + return $model->newQuery()::class; + } + + /** + * Get the class used for JSON response transforming. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Http\Resources\Json\JsonResource|null + */ + protected function getResource($model) + { + return rescue(static fn () => $model->toResource()::class, null, false); + } + + /** + * Qualify the given model class base name. + * + * @param string $model + * @return class-string<\Illuminate\Database\Eloquent\Model> + * + * @see \Illuminate\Console\GeneratorCommand + */ + protected function qualifyModel(string $model) + { + if (str_contains($model, '\\') && class_exists($model)) { + return $model; + } + + $model = ltrim($model, '\\/'); + + $model = str_replace('/', '\\', $model); + + $rootNamespace = $this->app->getNamespace(); + + if (Str::startsWith($model, $rootNamespace)) { + return $model; + } + + return is_dir(app_path('Models')) + ? $rootNamespace.'Models\\'.$model + : $rootNamespace.$model; + } + + /** + * Get the cast type for the given column. + * + * @param string $column + * @param \Illuminate\Database\Eloquent\Model $model + * @return string|null + */ + protected function getCastType($column, $model) + { + if ($model->hasGetMutator($column) || $model->hasSetMutator($column)) { + return 'accessor'; + } + + if ($model->hasAttributeMutator($column)) { + return 'attribute'; + } + + return $this->getCastsWithDates($model)->get($column) ?? null; + } + + /** + * Get the model casts, including any date casts. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return \Illuminate\Support\Collection + */ + protected function getCastsWithDates($model) + { + return (new BaseCollection($model->getDates())) + ->filter() + ->flip() + ->map(fn () => 'datetime') + ->merge($model->getCasts()); + } + + /** + * Determine if the given attribute is hidden. + * + * @param string $attribute + * @param \Illuminate\Database\Eloquent\Model $model + * @return bool + */ + protected function attributeIsHidden($attribute, $model) + { + if (count($model->getHidden()) > 0) { + return in_array($attribute, $model->getHidden()); + } + + if (count($model->getVisible()) > 0) { + return ! in_array($attribute, $model->getVisible()); + } + + return false; + } + + /** + * Get the default value for the given column. + * + * @param array $column + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + protected function getColumnDefault($column, $model) + { + $attributeDefault = $model->getAttributes()[$column['name']] ?? null; + + return enum_value($attributeDefault) ?? $column['default']; + } + + /** + * Determine if the given attribute is unique. + * + * @param string $column + * @param array $indexes + * @return bool + */ + protected function columnIsUnique($column, $indexes) + { + return (new BaseCollection($indexes))->contains( + fn ($index) => count($index['columns']) === 1 && $index['columns'][0] === $column && $index['unique'] + ); + } +} diff --git a/Eloquent/ModelNotFoundException.php b/Eloquent/ModelNotFoundException.php index c3db8242fd..79ae8a39de 100755 --- a/Eloquent/ModelNotFoundException.php +++ b/Eloquent/ModelNotFoundException.php @@ -2,30 +2,33 @@ namespace Illuminate\Database\Eloquent; -use RuntimeException; +use Illuminate\Database\RecordsNotFoundException; use Illuminate\Support\Arr; -class ModelNotFoundException extends RuntimeException +/** + * @template TModel of \Illuminate\Database\Eloquent\Model + */ +class ModelNotFoundException extends RecordsNotFoundException { /** * Name of the affected Eloquent model. * - * @var string + * @var class-string */ protected $model; /** * The affected model IDs. * - * @var int|array + * @var array */ protected $ids; /** * Set the affected Eloquent model and instance ids. * - * @param string $model - * @param int|array $ids + * @param class-string $model + * @param array|int|string $ids * @return $this */ public function setModel($model, $ids = []) @@ -47,7 +50,7 @@ public function setModel($model, $ids = []) /** * Get the affected Eloquent model. * - * @return string + * @return class-string */ public function getModel() { @@ -57,7 +60,7 @@ public function getModel() /** * Get the affected Eloquent model IDs. * - * @return int|array + * @return array */ public function getIds() { diff --git a/Eloquent/PendingHasThroughRelationship.php b/Eloquent/PendingHasThroughRelationship.php new file mode 100644 index 0000000000..9f3b757fbd --- /dev/null +++ b/Eloquent/PendingHasThroughRelationship.php @@ -0,0 +1,119 @@ + + */ +class PendingHasThroughRelationship +{ + /** + * The root model that the relationship exists on. + * + * @var TDeclaringModel + */ + protected $rootModel; + + /** + * The local relationship. + * + * @var TLocalRelationship + */ + protected $localRelationship; + + /** + * Create a pending has-many-through or has-one-through relationship. + * + * @param TDeclaringModel $rootModel + * @param TLocalRelationship $localRelationship + */ + public function __construct($rootModel, $localRelationship) + { + $this->rootModel = $rootModel; + + $this->localRelationship = $localRelationship; + } + + /** + * Define the distant relationship that this model has. + * + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param string|(callable(TIntermediateModel): (\Illuminate\Database\Eloquent\Relations\HasOne|\Illuminate\Database\Eloquent\Relations\HasMany|\Illuminate\Database\Eloquent\Relations\MorphOneOrMany)) $callback + * @return ( + * $callback is string + * ? \Illuminate\Database\Eloquent\Relations\HasManyThrough<\Illuminate\Database\Eloquent\Model, TIntermediateModel, TDeclaringModel>|\Illuminate\Database\Eloquent\Relations\HasOneThrough<\Illuminate\Database\Eloquent\Model, TIntermediateModel, TDeclaringModel> + * : ( + * TLocalRelationship is \Illuminate\Database\Eloquent\Relations\HasMany + * ? \Illuminate\Database\Eloquent\Relations\HasManyThrough + * : ( + * $callback is callable(TIntermediateModel): \Illuminate\Database\Eloquent\Relations\HasMany + * ? \Illuminate\Database\Eloquent\Relations\HasManyThrough + * : \Illuminate\Database\Eloquent\Relations\HasOneThrough + * ) + * ) + * ) + */ + public function has($callback) + { + if (is_string($callback)) { + $callback = fn () => $this->localRelationship->getRelated()->{$callback}(); + } + + $distantRelation = $callback($this->localRelationship->getRelated()); + + if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) { + $returnedRelation = $this->rootModel->hasManyThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } else { + $returnedRelation = $this->rootModel->hasOneThrough( + $distantRelation->getRelated()::class, + $this->localRelationship->getRelated()::class, + $this->localRelationship->getForeignKeyName(), + $distantRelation->getForeignKeyName(), + $this->localRelationship->getLocalKeyName(), + $distantRelation->getLocalKeyName(), + ); + } + + if ($this->localRelationship instanceof MorphOneOrMany) { + $returnedRelation->where($this->localRelationship->getQualifiedMorphType(), $this->localRelationship->getMorphClass()); + } + + return $returnedRelation; + } + + /** + * Handle dynamic method calls into the model. + * + * @param string $method + * @param array $parameters + * @return mixed + * + * @throws \BadMethodCallException + */ + public function __call($method, $parameters) + { + if (Str::startsWith($method, 'has')) { + return $this->has((new Stringable($method))->after('has')->lcfirst()->toString()); + } + + throw new BadMethodCallException(sprintf( + 'Call to undefined method %s::%s()', static::class, $method + )); + } +} diff --git a/Eloquent/Prunable.php b/Eloquent/Prunable.php new file mode 100644 index 0000000000..15423dd638 --- /dev/null +++ b/Eloquent/Prunable.php @@ -0,0 +1,85 @@ +prunable() + ->when(static::isSoftDeletable(), function ($query) { + $query->withTrashed(); + })->chunkById($chunkSize, function ($models) use (&$total) { + $models->each(function ($model) use (&$total) { + try { + $model->prune(); + + $total++; + } catch (Throwable $e) { + $handler = app(ExceptionHandler::class); + + if ($handler) { + $handler->report($e); + } else { + throw $e; + } + } + }); + + event(new ModelsPruned(static::class, $total)); + }); + + return $total; + } + + /** + * Get the prunable model query. + * + * @return \Illuminate\Database\Eloquent\Builder + * + * @throws \LogicException + */ + public function prunable() + { + throw new LogicException('Please implement the prunable method on your model.'); + } + + /** + * Prune the model in the database. + * + * @return bool|null + */ + public function prune() + { + $this->pruning(); + + return static::isSoftDeletable() + ? $this->forceDelete() + : $this->delete(); + } + + /** + * Prepare the model for pruning. + * + * @return void + */ + protected function pruning() + { + // + } +} diff --git a/Eloquent/RelationNotFoundException.php b/Eloquent/RelationNotFoundException.php index 088429cc68..73257bb101 100755 --- a/Eloquent/RelationNotFoundException.php +++ b/Eloquent/RelationNotFoundException.php @@ -23,17 +23,22 @@ class RelationNotFoundException extends RuntimeException /** * Create a new exception instance. * - * @param mixed $model + * @param object $model * @param string $relation + * @param string|null $type * @return static */ - public static function make($model, $relation) + public static function make($model, $relation, $type = null) { $class = get_class($model); - $instance = new static("Call to undefined relationship [{$relation}] on model [{$class}]."); + $instance = new static( + is_null($type) + ? "Call to undefined relationship [{$relation}] on model [{$class}]." + : "Call to undefined relationship [{$relation}] on model [{$class}] of type [{$type}].", + ); - $instance->model = $model; + $instance->model = $class; $instance->relation = $relation; return $instance; diff --git a/Eloquent/Relations/BelongsTo.php b/Eloquent/Relations/BelongsTo.php index 13be53fa30..a550eac730 100755 --- a/Eloquent/Relations/BelongsTo.php +++ b/Eloquent/Relations/BelongsTo.php @@ -2,17 +2,31 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use function Illuminate\Support\enum_value; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\Relation + */ class BelongsTo extends Relation { - use SupportsDefaultModels; + use ComparesRelatedModels, + InteractsWithDictionary, + SupportsDefaultModels; /** * The child model instance of the relation. + * + * @var TDeclaringModel */ protected $child; @@ -37,23 +51,14 @@ class BelongsTo extends Relation */ protected $relationName; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new belongs to relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $child + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $child * @param string $foreignKey * @param string $ownerKey * @param string $relationName - * - * @return void */ public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey, $relationName) { @@ -69,14 +74,10 @@ public function __construct(Builder $query, Model $child, $foreignKey, $ownerKey parent::__construct($query, $child); } - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { - if (is_null($this->child->{$this->foreignKey})) { + if (is_null($this->getForeignKeyFrom($this->child))) { return $this->getDefaultFor($this->parent); } @@ -94,34 +95,29 @@ public function addConstraints() // For belongs to relationships, which are essentially the inverse of has one // or has many relationships, we need to actually query on the primary key // of the related models matching on the foreign key that's on a parent. - $table = $this->related->getTable(); + $key = $this->getQualifiedOwnerKeyName(); - $this->query->where($table.'.'.$this->ownerKey, '=', $this->child->{$this->foreignKey}); + $this->query->where($key, '=', $this->getForeignKeyFrom($this->child)); } } - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ + /** @inheritDoc */ public function addEagerConstraints(array $models) { // We'll grab the primary key name of the related models since it could be set to // a non-standard name and not "id". We will then construct the constraint for // our eagerly loading query so it returns the proper models from execution. - $key = $this->related->getTable().'.'.$this->ownerKey; + $key = $this->getQualifiedOwnerKeyName(); $whereIn = $this->whereInMethod($this->related, $this->ownerKey); - $this->query->{$whereIn}($key, $this->getEagerModelKeys($models)); + $this->whereInEager($whereIn, $key, $this->getEagerModelKeys($models)); } /** * Gather the keys from an array of related models. * - * @param array $models + * @param array $models * @return array */ protected function getEagerModelKeys(array $models) @@ -132,7 +128,7 @@ protected function getEagerModelKeys(array $models) // to query for via the eager loading query. We will add them to an array then // execute a "where in" statement to gather up all of those related records. foreach ($models as $model) { - if (! is_null($value = $model->{$this->foreignKey})) { + if (! is_null($value = $this->getForeignKeyFrom($model))) { $keys[] = $value; } } @@ -142,13 +138,7 @@ protected function getEagerModelKeys(array $models) return array_values(array_unique($keys)); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -158,57 +148,39 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { - $foreign = $this->foreignKey; - - $owner = $this->ownerKey; - // First we will get to build a dictionary of the child models by their primary // key of the relationship, then we can easily match the children back onto // the parents using that dictionary and the primary key of the children. $dictionary = []; foreach ($results as $result) { - $dictionary[$result->getAttribute($owner)] = $result; + $attribute = $this->getDictionaryKey($this->getRelatedKeyFrom($result)); + + $dictionary[$attribute] = $result; } // Once we have the dictionary constructed, we can loop through all the parents // and match back onto their children using these keys of the dictionary and // the primary key of the children to map them onto the correct instances. foreach ($models as $model) { - if (isset($dictionary[$model->{$foreign}])) { - $model->setRelation($relation, $dictionary[$model->{$foreign}]); + $attribute = $this->getDictionaryKey($this->getForeignKeyFrom($model)); + + if (isset($dictionary[$attribute ?? ''])) { + $model->setRelation($relation, $dictionary[$attribute ?? '']); } } return $models; } - /** - * Update the parent model on the relationship. - * - * @param array $attributes - * @return mixed - */ - public function update(array $attributes) - { - return $this->getResults()->fill($attributes)->save(); - } - /** * Associate the model instance to the given parent. * - * @param \Illuminate\Database\Eloquent\Model|int|string $model - * @return \Illuminate\Database\Eloquent\Model + * @param TRelatedModel|int|string|null $model + * @return TDeclaringModel */ public function associate($model) { @@ -218,7 +190,7 @@ public function associate($model) if ($model instanceof Model) { $this->child->setRelation($this->relationName, $model); - } elseif ($this->child->isDirty($this->foreignKey)) { + } else { $this->child->unsetRelation($this->relationName); } @@ -228,7 +200,7 @@ public function associate($model) /** * Dissociate previously associated model from the given parent. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function dissociate() { @@ -238,13 +210,28 @@ public function dissociate() } /** - * Add the constraints for a relationship query. + * Alias of "dissociate" method. + * + * @return TDeclaringModel + */ + public function disassociate() + { + return $this->dissociate(); + } + + /** + * Touch all of the related models for the relationship. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @return void */ + public function touch() + { + if (! is_null($this->getParentKey())) { + parent::touch(); + } + } + + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { if ($parentQuery->getQuery()->from == $query->getQuery()->from) { @@ -259,10 +246,10 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Add the constraints for a relationship query on the same table. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder */ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) { @@ -277,16 +264,6 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder ); } - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - /** * Determine if the related model has an auto-incrementing ID. * @@ -295,14 +272,14 @@ public function getRelationCountHash() protected function relationHasIncrementingId() { return $this->related->getIncrementing() && - $this->related->getKeyType() === 'int'; + in_array($this->related->getKeyType(), ['int', 'integer']); } /** * Make a new related instance for the given model. * - * @param \Illuminate\Database\Eloquent\Model $parent - * @return \Illuminate\Database\Eloquent\Model + * @param TDeclaringModel $parent + * @return TRelatedModel */ protected function newRelatedInstanceFor(Model $parent) { @@ -312,7 +289,7 @@ protected function newRelatedInstanceFor(Model $parent) /** * Get the child of the relationship. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function getChild() { @@ -330,7 +307,7 @@ public function getForeignKeyName() } /** - * Get the fully qualified foreign key of the relationship. + * Get the fully-qualified foreign key of the relationship. * * @return string */ @@ -339,6 +316,16 @@ public function getQualifiedForeignKeyName() return $this->child->qualifyColumn($this->foreignKey); } + /** + * Get the key value of the child's foreign key. + * + * @return mixed + */ + public function getParentKey() + { + return $this->getForeignKeyFrom($this->child); + } + /** * Get the associated key of the relationship. * @@ -350,7 +337,7 @@ public function getOwnerKeyName() } /** - * Get the fully qualified associated key of the relationship. + * Get the fully-qualified associated key of the relationship. * * @return string */ @@ -359,6 +346,30 @@ public function getQualifiedOwnerKeyName() return $this->related->qualifyColumn($this->ownerKey); } + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + * @return int|string + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->{$this->ownerKey}; + } + + /** + * Get the value of the model's foreign key. + * + * @param TDeclaringModel $model + * @return mixed + */ + protected function getForeignKeyFrom(Model $model) + { + $foreignKey = $model->{$this->foreignKey}; + + return enum_value($foreignKey); + } + /** * Get the name of the relationship. * diff --git a/Eloquent/Relations/BelongsToMany.php b/Eloquent/Relations/BelongsToMany.php index e67c7290be..2a7e011ddf 100755 --- a/Eloquent/Relations/BelongsToMany.php +++ b/Eloquent/Relations/BelongsToMany.php @@ -2,16 +2,35 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Support\Str; -use InvalidArgumentException; -use Illuminate\Database\Eloquent\Model; +use Closure; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; +use Illuminate\Database\Query\Grammars\MySqlGrammar; +use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection as BaseCollection; +use Illuminate\Support\Str; +use InvalidArgumentException; +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TPivotModel of \Illuminate\Database\Eloquent\Relations\Pivot = \Illuminate\Database\Eloquent\Relations\Pivot + * @template TAccessor of string = 'pivot' + * + * @extends \Illuminate\Database\Eloquent\Relations\Relation> + * + * @todo use TAccessor when PHPStan bug is fixed: https://github.com/phpstan/phpstan/issues/12756 + */ class BelongsToMany extends Relation { - use Concerns\InteractsWithPivotTable; + use InteractsWithDictionary, InteractsWithPivotTable; /** * The intermediate table for the relation. @@ -58,7 +77,7 @@ class BelongsToMany extends Relation /** * The pivot table columns to retrieve. * - * @var array + * @var array */ protected $pivotColumns = []; @@ -76,6 +95,13 @@ class BelongsToMany extends Relation */ protected $pivotWhereIns = []; + /** + * Any pivot table restrictions for whereNull clauses. + * + * @var array + */ + protected $pivotWhereNulls = []; + /** * The default values for the pivot columns. * @@ -93,64 +119,88 @@ class BelongsToMany extends Relation /** * The custom pivot table column for the created_at timestamp. * - * @var string + * @var string|null */ protected $pivotCreatedAt; /** * The custom pivot table column for the updated_at timestamp. * - * @var string + * @var string|null */ protected $pivotUpdatedAt; /** * The class name of the custom pivot model to use for the relationship. * - * @var string + * @var class-string */ protected $using; /** * The name of the accessor to use for the "pivot" relationship. * - * @var string + * @var TAccessor */ protected $accessor = 'pivot'; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new belongs to many relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent - * @param string $table + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @param string|class-string $table * @param string $foreignPivotKey * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName - * @return void - */ - public function __construct(Builder $query, Model $parent, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey, $relatedKey, $relationName = null) - { - $this->table = $table; + * @param string|null $relationName + */ + public function __construct( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + ) { $this->parentKey = $parentKey; $this->relatedKey = $relatedKey; $this->relationName = $relationName; $this->relatedPivotKey = $relatedPivotKey; $this->foreignPivotKey = $foreignPivotKey; + $this->table = $this->resolveTableName($table); parent::__construct($query, $parent); } + /** + * Attempt to resolve the intermediate table name from the given string. + * + * @param string $table + * @return string + */ + protected function resolveTableName($table) + { + if (! str_contains($table, '\\') || ! class_exists($table)) { + return $table; + } + + $model = new $table; + + if (! $model instanceof Model) { + return $table; + } + + if (in_array(AsPivot::class, class_uses_recursive($model))) { + $this->using($table); + } + + return $model->getTable(); + } + /** * Set the base constraints on the relation query. * @@ -168,7 +218,7 @@ public function addConstraints() /** * Set the join clause for the relation query. * - * @param \Illuminate\Database\Eloquent\Builder|null $query + * @param \Illuminate\Database\Eloquent\Builder|null $query * @return $this */ protected function performJoin($query = null) @@ -178,11 +228,12 @@ protected function performJoin($query = null) // We need to join to the intermediate table on the related model's primary // key column with the intermediate table's foreign key for the related // model instance. Then we can set the "where" for the parent models. - $baseTable = $this->related->getTable(); - - $key = $baseTable.'.'.$this->relatedKey; - - $query->join($this->table, $key, '=', $this->getQualifiedRelatedPivotKeyName()); + $query->join( + $this->table, + $this->getQualifiedRelatedKeyName(), + '=', + $this->getQualifiedRelatedPivotKeyName() + ); return $this; } @@ -201,29 +252,19 @@ protected function addWhereConstraints() return $this; } - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ + /** @inheritDoc */ public function addEagerConstraints(array $models) { $whereIn = $this->whereInMethod($this->parent, $this->parentKey); - $this->query->{$whereIn}( + $this->whereInEager( + $whereIn, $this->getQualifiedForeignPivotKeyName(), $this->getKeys($models, $this->parentKey) ); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -233,23 +274,18 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { $dictionary = $this->buildDictionary($results); // Once we have an array dictionary of child objects we can easily match the // children back to their parent using the dictionary and the keys on the - // the parent models. Then we will return the hydrated models back out. + // parent models. Then we should return these hydrated models back out. foreach ($models as $model) { - if (isset($dictionary[$key = $model->{$this->parentKey}])) { + $key = $this->getDictionaryKey($model->{$this->parentKey}); + + if (isset($dictionary[$key])) { $model->setRelation( $relation, $this->related->newCollection($dictionary[$key]) ); @@ -262,18 +298,26 @@ public function match(array $models, Collection $results, $relation) /** * Build model dictionary keyed by the relation's foreign key. * - * @param \Illuminate\Database\Eloquent\Collection $results - * @return array + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array> */ - protected function buildDictionary(Collection $results) + protected function buildDictionary(EloquentCollection $results) { - // First we will build a dictionary of child models keyed by the foreign key - // of the relation so that we will easily and quickly match them to their - // parents without having a possibly slow inner loops for every models. + // First we'll build a dictionary of child models keyed by the foreign key + // of the relation so that we will easily and quickly match them to the + // parents without having a possibly slow inner loop for every model. $dictionary = []; - foreach ($results as $result) { - $dictionary[$result->{$this->accessor}->{$this->foreignPivotKey}][] = $result; + $isAssociative = Arr::isAssoc($results->all()); + + foreach ($results as $key => $result) { + $value = $this->getDictionaryKey($result->{$this->accessor}->{$this->foreignPivotKey}); + + if ($isAssociative) { + $dictionary[$value][$key] = $result; + } else { + $dictionary[$value][] = $result; + } } return $dictionary; @@ -282,7 +326,7 @@ protected function buildDictionary(Collection $results) /** * Get the class being used for pivot models. * - * @return string + * @return class-string */ public function getPivotClass() { @@ -292,8 +336,12 @@ public function getPivotClass() /** * Specify the custom pivot model to use for the relationship. * - * @param string $class + * @template TNewPivotModel of \Illuminate\Database\Eloquent\Relations\Pivot + * + * @param class-string $class * @return $this + * + * @phpstan-this-out static */ public function using($class) { @@ -305,8 +353,12 @@ public function using($class) /** * Specify the custom pivot accessor to use for the relationship. * - * @param string $accessor + * @template TNewAccessor of string + * + * @param TNewAccessor $accessor * @return $this + * + * @phpstan-this-out static */ public function as($accessor) { @@ -318,9 +370,9 @@ public function as($accessor) /** * Set a where clause for a pivot table column. * - * @param string $column - * @param string $operator - * @param mixed $value + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value * @param string $boolean * @return $this */ @@ -328,31 +380,82 @@ public function wherePivot($column, $operator = null, $value = null, $boolean = { $this->pivotWheres[] = func_get_args(); - return $this->where($this->table.'.'.$column, $operator, $value, $boolean); + return $this->where($this->qualifyPivotColumn($column), $operator, $value, $boolean); + } + + /** + * Set a "where between" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param array $values + * @param string $boolean + * @param bool $not + * @return $this + */ + public function wherePivotBetween($column, array $values, $boolean = 'and', $not = false) + { + return $this->whereBetween($this->qualifyPivotColumn($column), $values, $boolean, $not); + } + + /** + * Set a "or where between" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param array $values + * @return $this + */ + public function orWherePivotBetween($column, array $values) + { + return $this->wherePivotBetween($column, $values, 'or'); + } + + /** + * Set a "where pivot not between" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param array $values + * @param string $boolean + * @return $this + */ + public function wherePivotNotBetween($column, array $values, $boolean = 'and') + { + return $this->wherePivotBetween($column, $values, $boolean, true); + } + + /** + * Set a "or where not between" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param array $values + * @return $this + */ + public function orWherePivotNotBetween($column, array $values) + { + return $this->wherePivotBetween($column, $values, 'or', true); } /** * Set a "where in" clause for a pivot table column. * - * @param string $column - * @param mixed $values + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $values * @param string $boolean - * @param bool $not + * @param bool $not * @return $this */ public function wherePivotIn($column, $values, $boolean = 'and', $not = false) { $this->pivotWhereIns[] = func_get_args(); - return $this->whereIn($this->table.'.'.$column, $values, $boolean, $not); + return $this->whereIn($this->qualifyPivotColumn($column), $values, $boolean, $not); } /** * Set an "or where" clause for a pivot table column. * - * @param string $column - * @param string $operator - * @param mixed $value + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $operator + * @param mixed $value * @return $this */ public function orWherePivot($column, $operator = null, $value = null) @@ -365,9 +468,11 @@ public function orWherePivot($column, $operator = null, $value = null) * * In addition, new pivot records will receive this value. * - * @param string|array $column + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $column * @param mixed $value * @return $this + * + * @throws \InvalidArgumentException */ public function withPivotValue($column, $value = null) { @@ -392,7 +497,7 @@ public function withPivotValue($column, $value = null) * Set an "or where in" clause for a pivot table column. * * @param string $column - * @param mixed $values + * @param mixed $values * @return $this */ public function orWherePivotIn($column, $values) @@ -401,11 +506,113 @@ public function orWherePivotIn($column, $values) } /** - * Find a related model by its primary key or return new instance of the related model. + * Set a "where not in" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param mixed $values + * @param string $boolean + * @return $this + */ + public function wherePivotNotIn($column, $values, $boolean = 'and') + { + return $this->wherePivotIn($column, $values, $boolean, true); + } + + /** + * Set an "or where not in" clause for a pivot table column. + * + * @param string $column + * @param mixed $values + * @return $this + */ + public function orWherePivotNotIn($column, $values) + { + return $this->wherePivotNotIn($column, $values, 'or'); + } + + /** + * Set a "where null" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param string $boolean + * @param bool $not + * @return $this + */ + public function wherePivotNull($column, $boolean = 'and', $not = false) + { + $this->pivotWhereNulls[] = func_get_args(); + + return $this->whereNull($this->qualifyPivotColumn($column), $boolean, $not); + } + + /** + * Set a "where not null" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param string $boolean + * @return $this + */ + public function wherePivotNotNull($column, $boolean = 'and') + { + return $this->wherePivotNull($column, $boolean, true); + } + + /** + * Set a "or where null" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param bool $not + * @return $this + */ + public function orWherePivotNull($column, $not = false) + { + return $this->wherePivotNull($column, 'or', $not); + } + + /** + * Set a "or where not null" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @return $this + */ + public function orWherePivotNotNull($column) + { + return $this->orWherePivotNull($column, true); + } + + /** + * Add an "order by" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @param string $direction + * @return $this + */ + public function orderByPivot($column, $direction = 'asc') + { + return $this->orderBy($this->qualifyPivotColumn($column), $direction); + } + + /** + * Add an "order by desc" clause for a pivot table column. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @return $this + */ + public function orderByPivotDesc($column) + { + return $this->orderBy($this->qualifyPivotColumn($column), 'desc'); + } + + /** + * Find a related model by its primary key or return a new instance of the related model. * * @param mixed $id * @param array $columns - * @return \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model + * @return ( + * $id is (\Illuminate\Contracts\Support\Arrayable|array) + * ? \Illuminate\Database\Eloquent\Collection + * : TRelatedModel&object{pivot: TPivotModel} + * ) */ public function findOrNew($id, $columns = ['*']) { @@ -420,54 +627,90 @@ public function findOrNew($id, $columns = ['*']) * Get the first related model record matching the attributes or instantiate it. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model + * @param array $values + * @return TRelatedModel&object{pivot: TPivotModel} */ - public function firstOrNew(array $attributes) + public function firstOrNew(array $attributes = [], array $values = []) { - if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance($attributes); + if (is_null($instance = $this->related->where($attributes)->first())) { + $instance = $this->related->newInstance(array_merge($attributes, $values)); } return $instance; } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes + * @param (\Closure(): array)|array $values * @param array $joining - * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} */ - public function firstOrCreate(array $attributes, array $joining = [], $touch = true) + public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], $touch = true) { - if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->create($attributes, $joining, $touch); + if (is_null($instance = (clone $this)->where($attributes)->first())) { + if (is_null($instance = $this->related->where($attributes)->first())) { + $instance = $this->createOrFirst($attributes, $values, $joining, $touch); + } else { + try { + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); + } catch (UniqueConstraintViolationException) { + // Nothing to do, the model was already attached... + } + } } return $instance; } /** - * Create or update a related record matching the attributes, and fill it with values. + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. * * @param array $attributes - * @param array $values + * @param (\Closure(): array)|array $values * @param array $joining - * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} + * + * @throws \Illuminate\Database\UniqueConstraintViolationException */ - public function updateOrCreate(array $attributes, array $values = [], array $joining = [], $touch = true) + public function createOrFirst(array $attributes = [], Closure|array $values = [], array $joining = [], $touch = true) { - if (is_null($instance = $this->where($attributes)->first())) { - return $this->create($values, $joining, $touch); + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)), $joining, $touch)); + } catch (UniqueConstraintViolationException $e) { + // ... } - $instance->fill($values); + try { + return tap($this->related->where($attributes)->first() ?? throw $e, function ($instance) use ($joining, $touch) { + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); + }); + } catch (UniqueConstraintViolationException $e) { + return (clone $this)->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } - $instance->save(['touch' => false]); + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array $attributes + * @param array $values + * @param array $joining + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} + */ + public function updateOrCreate(array $attributes, array $values = [], array $joining = [], $touch = true) + { + return tap($this->firstOrCreate($attributes, $values, $joining, $touch), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values); - return $instance; + $instance->save(['touch' => false]); + } + }); } /** @@ -475,26 +718,57 @@ public function updateOrCreate(array $attributes, array $values = [], array $joi * * @param mixed $id * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|null + * @return ( + * $id is (\Illuminate\Contracts\Support\Arrayable|array) + * ? \Illuminate\Database\Eloquent\Collection + * : (TRelatedModel&object{pivot: TPivotModel})|null + * ) */ public function find($id, $columns = ['*']) { - return is_array($id) ? $this->findMany($id, $columns) : $this->where( - $this->getRelated()->getQualifiedKeyName(), '=', $id + if (! $id instanceof Model && (is_array($id) || $id instanceof Arrayable)) { + return $this->findMany($id, $columns); + } + + return $this->where( + $this->getRelated()->getQualifiedKeyName(), '=', $this->parseId($id) )->first($columns); } + /** + * Find a sole related model by its primary key. + * + * @param mixed $id + * @param array $columns + * @return TRelatedModel&object{pivot: TPivotModel} + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function findSole($id, $columns = ['*']) + { + return $this->where( + $this->getRelated()->getQualifiedKeyName(), '=', $this->parseId($id) + )->sole($columns); + } + /** * Find multiple related models by their primary keys. * - * @param mixed $ids + * @param \Illuminate\Contracts\Support\Arrayable|array $ids * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function findMany($ids, $columns = ['*']) { - return empty($ids) ? $this->getRelated()->newCollection() : $this->whereIn( - $this->getRelated()->getQualifiedKeyName(), $ids + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->getRelated()->newCollection(); + } + + return $this->whereKey( + $this->parseIds($ids) )->get($columns); } @@ -503,14 +777,20 @@ public function findMany($ids, $columns = ['*']) * * @param mixed $id * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection + * @return ( + * $id is (\Illuminate\Contracts\Support\Arrayable|array) + * ? \Illuminate\Database\Eloquent\Collection + * : TRelatedModel&object{pivot: TPivotModel} + * ) * - * @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; @@ -522,15 +802,66 @@ public function findOrFail($id, $columns = ['*']) throw (new ModelNotFoundException)->setModel(get_class($this->related), $id); } + /** + * Find a related 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|TValue + * : (TRelatedModel&object{pivot: TPivotModel})|TValue + * ) + */ + public function findOr($id, $columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $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; + } + } elseif (! is_null($result)) { + return $result; + } + + return $callback(); + } + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param \Closure|string|array $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return (TRelatedModel&object{pivot: TPivotModel})|null + */ + public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') + { + return $this->where($column, $operator, $value, $boolean)->first(); + } + /** * Execute the query and get the first result. * - * @param array $columns - * @return mixed + * @param array $columns + * @return (TRelatedModel&object{pivot: TPivotModel})|null */ public function first($columns = ['*']) { - $results = $this->take(1)->get($columns); + $results = $this->limit(1)->get($columns); return count($results) > 0 ? $results->first() : null; } @@ -539,9 +870,9 @@ public function first($columns = ['*']) * Execute the query and get the first result or throw an exception. * * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|static + * @return TRelatedModel&object{pivot: TPivotModel} * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ public function firstOrFail($columns = ['*']) { @@ -553,27 +884,42 @@ public function firstOrFail($columns = ['*']) } /** - * Get the results of the relationship. + * Execute the query and get the first result or call a callback. + * + * @template TValue * - * @return mixed + * @param (\Closure(): TValue)|list $columns + * @param (\Closure(): TValue)|null $callback + * @return (TRelatedModel&object{pivot: TPivotModel})|TValue */ + public function firstOr($columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + /** @inheritDoc */ public function getResults() { return ! is_null($this->parent->{$this->parentKey}) - ? $this->get() - : $this->related->newCollection(); + ? $this->get() + : $this->related->newCollection(); } - /** - * Execute the query as a "select" statement. - * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection - */ + /** @inheritDoc */ public function get($columns = ['*']) { // First we'll add the proper select columns onto the query so it is run with - // the proper columns. Then, we will get the results and hydrate out pivot + // the proper columns. Then, we will get the results and hydrate our pivot // models with the result of those columns as a separate model relation. $builder = $this->query->applyScopes(); @@ -592,7 +938,9 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $this->related->newCollection($models); + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); } /** @@ -604,7 +952,7 @@ public function get($columns = ['*']) protected function shouldSelect(array $columns = ['*']) { if ($columns == ['*']) { - $columns = [$this->related->getTable().'.*']; + $columns = [$this->related->qualifyColumn('*')]; } return array_merge($columns, $this->aliasedPivotColumns()); @@ -613,27 +961,30 @@ protected function shouldSelect(array $columns = ['*']) /** * Get the pivot columns for the relation. * - * "pivot_" is prefixed ot each column for easy removal later. + * "pivot_" is prefixed at each column for easy removal later. * * @return array */ protected function aliasedPivotColumns() { - $defaults = [$this->foreignPivotKey, $this->relatedPivotKey]; - - return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { - return $this->table.'.'.$column.' as pivot_'.$column; - })->unique()->all(); + return (new BaseCollection([ + $this->foreignPivotKey, + $this->relatedPivotKey, + ...$this->pivotColumns, + ])) + ->map(fn ($column) => $this->qualifyPivotColumn($column).' as pivot_'.$column) + ->unique() + ->all(); } /** * Get a paginator for the "select" statement. * - * @param int $perPage + * @param int|null $perPage * @param array $columns * @param string $pageName * @param int|null $page - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + * @return \Illuminate\Pagination\LengthAwarePaginator */ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) { @@ -647,11 +998,11 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', /** * Paginate the given query into a simple paginator. * - * @param int $perPage + * @param int|null $perPage * @param array $columns * @param string $pageName * @param int|null $page - * @return \Illuminate\Contracts\Pagination\Paginator + * @return \Illuminate\Contracts\Pagination\Paginator */ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) { @@ -662,6 +1013,24 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p }); } + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) { + $this->hydratePivotRelation($paginator->items()); + }); + } + /** * Chunk the results of the query. * @@ -671,12 +1040,10 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p */ public function chunk($count, callable $callback) { - $this->query->addSelect($this->shouldSelect()); - - return $this->query->chunk($count, function ($results) use ($callback) { + return $this->prepareQueryBuilder()->chunk($count, function ($results, $page) use ($callback) { $this->hydratePivotRelation($results->all()); - return $callback($results); + return $callback($results, $page); }); } @@ -691,19 +1058,66 @@ public function chunk($count, callable $callback) */ public function chunkById($count, callable $callback, $column = null, $alias = null) { - $this->query->addSelect($this->shouldSelect()); + return $this->orderedChunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param int $count + * @param callable $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); + } - $column = $column ?? $this->getRelated()->qualifyColumn( + /** + * Execute a callback over each item while chunking by ID. + * + * @param callable $callback + * @param int $count + * @param string|null $column + * @param string|null $alias + * @return bool + */ + public function eachById(callable $callback, $count = 1000, $column = null, $alias = null) + { + return $this->chunkById($count, function ($results, $page) use ($callback, $count) { + foreach ($results as $key => $value) { + if ($callback($value, (($page - 1) * $count) + $key) === false) { + return false; + } + } + }, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in a given order. + * + * @param int $count + * @param callable $callback + * @param string|null $column + * @param string|null $alias + * @param bool $descending + * @return bool + */ + public function orderedChunkById($count, callable $callback, $column = null, $alias = null, $descending = false) + { + $column ??= $this->getRelated()->qualifyColumn( $this->getRelatedKeyName() ); - $alias = $alias ?? $this->getRelatedKeyName(); + $alias ??= $this->getRelatedKeyName(); - return $this->query->chunkById($count, function ($results) use ($callback) { + return $this->prepareQueryBuilder()->orderedChunkById($count, function ($results, $page) use ($callback) { $this->hydratePivotRelation($results->all()); - return $callback($results); - }, $column, $alias); + return $callback($results, $page); + }, $column, $alias, $descending); } /** @@ -724,10 +1138,95 @@ public function each(callable $callback, $count = 1000) }); } + /** + * Query lazily, by chunks of the given size. + * + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + */ + public function lazy($chunkSize = 1000) + { + return $this->prepareQueryBuilder()->lazy($chunkSize)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * 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 + */ + public function lazyById($chunkSize = 1000, $column = null, $alias = null) + { + $column ??= $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias ??= $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->lazyById($chunkSize, $column, $alias)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * 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 + */ + public function lazyByIdDesc($chunkSize = 1000, $column = null, $alias = null) + { + $column ??= $this->getRelated()->qualifyColumn( + $this->getRelatedKeyName() + ); + + $alias ??= $this->getRelatedKeyName(); + + return $this->prepareQueryBuilder()->lazyByIdDesc($chunkSize, $column, $alias)->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Get a lazy collection for the given query. + * + * @return \Illuminate\Support\LazyCollection + */ + public function cursor() + { + return $this->prepareQueryBuilder()->cursor()->map(function ($model) { + $this->hydratePivotRelation([$model]); + + return $model; + }); + } + + /** + * Prepare the query builder for query execution. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function prepareQueryBuilder() + { + return $this->query->addSelect($this->shouldSelect()); + } + /** * Hydrate the pivot table relationship on the models. * - * @param array $models + * @param array $models * @return void */ protected function hydratePivotRelation(array $models) @@ -745,7 +1244,7 @@ protected function hydratePivotRelation(array $models) /** * Get the pivot attributes from a model. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param TRelatedModel $model * @return array */ protected function migratePivotAttributes(Model $model) @@ -756,7 +1255,7 @@ protected function migratePivotAttributes(Model $model) // To get the pivots attributes we will just take any of the attributes which // begin with "pivot_" and add those to this arrays, as well as unsetting // them from the parent's models since they exist in a different table. - if (strpos($key, 'pivot_') === 0) { + if (str_starts_with($key, 'pivot_')) { $values[substr($key, 6)] = $value; unset($model->$key); @@ -811,7 +1310,9 @@ protected function guessInverseRelation() */ public function touch() { - $key = $this->getRelated()->getKeyName(); + if ($this->related->isIgnoringTouch()) { + return; + } $columns = [ $this->related->getUpdatedAtColumn() => $this->related->freshTimestampString(), @@ -821,14 +1322,14 @@ public function touch() // the related model's timestamps, to make sure these all reflect the changes // to the parent models. This will help us keep any caching synced up here. if (count($ids = $this->allRelatedIds()) > 0) { - $this->getRelated()->newQueryWithoutRelationships()->whereIn($key, $ids)->update($columns); + $this->getRelated()->newQueryWithoutRelationships()->whereKey($ids)->update($columns); } } /** * Get all of the IDs for the related models. * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function allRelatedIds() { @@ -838,10 +1339,10 @@ public function allRelatedIds() /** * Save a new model and attach it to the parent model. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param TRelatedModel $model * @param array $pivotAttributes - * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} */ public function save(Model $model, array $pivotAttributes = [], $touch = true) { @@ -852,12 +1353,29 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) return $model; } + /** + * Save a new model without raising any events and attach it to the parent model. + * + * @param TRelatedModel $model + * @param array $pivotAttributes + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} + */ + public function saveQuietly(Model $model, array $pivotAttributes = [], $touch = true) + { + return Model::withoutEvents(function () use ($model, $pivotAttributes, $touch) { + return $this->save($model, $pivotAttributes, $touch); + }); + } + /** * Save an array of new models and attach them to the parent model. * - * @param \Illuminate\Support\Collection|array $models + * @template TContainer of \Illuminate\Support\Collection|array + * + * @param TContainer $models * @param array $pivotAttributes - * @return array + * @return TContainer */ public function saveMany($models, array $pivotAttributes = []) { @@ -870,16 +1388,34 @@ public function saveMany($models, array $pivotAttributes = []) return $models; } + /** + * Save an array of new models without raising any events and attach them to the parent model. + * + * @template TContainer of \Illuminate\Support\Collection|array + * + * @param TContainer $models + * @param array $pivotAttributes + * @return TContainer + */ + public function saveManyQuietly($models, array $pivotAttributes = []) + { + return Model::withoutEvents(function () use ($models, $pivotAttributes) { + return $this->saveMany($models, $pivotAttributes); + }); + } + /** * Create a new instance of the related model. * * @param array $attributes * @param array $joining - * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @param bool $touch + * @return TRelatedModel&object{pivot: TPivotModel} */ public function create(array $attributes = [], array $joining = [], $touch = true) { + $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes); + $instance = $this->related->newInstance($attributes); // Once we save the related model, we need to attach it to the base model via @@ -895,11 +1431,11 @@ public function create(array $attributes = [], array $joining = [], $touch = tru /** * Create an array of new instances of the related models. * - * @param array $records + * @param iterable $records * @param array $joinings - * @return array + * @return array */ - public function createMany(array $records, array $joinings = []) + public function createMany(iterable $records, array $joinings = []) { $instances = []; @@ -912,14 +1448,7 @@ public function createMany(array $records, array $joinings = []) return $instances; } - /** - * Add the constraints for a relationship query. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { if ($parentQuery->getQuery()->from == $query->getQuery()->from) { @@ -934,10 +1463,10 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Add the constraints for a relationship query on the same table. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder */ public function getRelationExistenceQueryForSelfJoin(Builder $query, Builder $parentQuery, $columns = ['*']) { @@ -953,40 +1482,71 @@ public function getRelationExistenceQueryForSelfJoin(Builder $query, Builder $pa } /** - * Get the key for comparing against the parent key in "has" query. + * Alias to set the "limit" value of the query. * - * @return string + * @param int $value + * @return $this */ - public function getExistenceCompareKey() + public function take($value) { - return $this->getQualifiedForeignPivotKeyName(); + return $this->limit($value); } /** - * Get a relationship join table hash. + * Set the "limit" value of the query. + * + * @param int $value + * @return $this + */ + public function limit($value) + { + if ($this->parent->exists) { + $this->query->limit($value); + } else { + $column = $this->getExistenceCompareKey(); + + $grammar = $this->query->getQuery()->getGrammar(); + + if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) { + $column = 'pivot_'.last(explode('.', $column)); + } + + $this->query->groupLimit($value, $column); + } + + return $this; + } + + /** + * Get the key for comparing against the parent key in "has" query. * * @return string */ - public function getRelationCountHash() + public function getExistenceCompareKey() { - return 'laravel_reserved_'.static::$selfJoinCount++; + return $this->getQualifiedForeignPivotKeyName(); } /** * Specify that the pivot table has creation and update timestamps. * - * @param mixed $createdAt - * @param mixed $updatedAt + * @param string|null|false $createdAt + * @param string|null|false $updatedAt * @return $this */ public function withTimestamps($createdAt = null, $updatedAt = null) { - $this->withTimestamps = true; + $this->pivotCreatedAt = $createdAt !== false ? $createdAt : null; + $this->pivotUpdatedAt = $updatedAt !== false ? $updatedAt : null; - $this->pivotCreatedAt = $createdAt; - $this->pivotUpdatedAt = $updatedAt; + $pivots = array_filter([ + $createdAt !== false ? $this->createdAt() : null, + $updatedAt !== false ? $this->updatedAt() : null, + ]); - return $this->withPivot($this->createdAt(), $this->updatedAt()); + $this->withTimestamps = ! empty($pivots); + + return $this->withTimestamps ? $this->withPivot($pivots) : $this; } /** @@ -996,7 +1556,7 @@ public function withTimestamps($createdAt = null, $updatedAt = null) */ public function createdAt() { - return $this->pivotCreatedAt ?: $this->parent->getCreatedAtColumn(); + return $this->pivotCreatedAt ?? $this->parent->getCreatedAtColumn() ?? Model::CREATED_AT; } /** @@ -1006,7 +1566,7 @@ public function createdAt() */ public function updatedAt() { - return $this->pivotUpdatedAt ?: $this->parent->getUpdatedAtColumn(); + return $this->pivotUpdatedAt ?? $this->parent->getUpdatedAtColumn() ?? Model::UPDATED_AT; } /** @@ -1020,13 +1580,13 @@ public function getForeignPivotKeyName() } /** - * Get the fully qualified foreign key for the relation. + * Get the fully-qualified foreign key for the relation. * * @return string */ public function getQualifiedForeignPivotKeyName() { - return $this->table.'.'.$this->foreignPivotKey; + return $this->qualifyPivotColumn($this->foreignPivotKey); } /** @@ -1040,13 +1600,13 @@ public function getRelatedPivotKeyName() } /** - * Get the fully qualified "related key" for the relation. + * Get the fully-qualified "related key" for the relation. * * @return string */ public function getQualifiedRelatedPivotKeyName() { - return $this->table.'.'.$this->relatedPivotKey; + return $this->qualifyPivotColumn($this->relatedPivotKey); } /** @@ -1060,7 +1620,7 @@ public function getParentKeyName() } /** - * Get the fully qualified parent key name for the relation. + * Get the fully-qualified parent key name for the relation. * * @return string */ @@ -1079,6 +1639,16 @@ public function getRelatedKeyName() return $this->relatedKey; } + /** + * Get the fully-qualified related key name for the relation. + * + * @return string + */ + public function getQualifiedRelatedKeyName() + { + return $this->related->qualifyColumn($this->relatedKey); + } + /** * Get the intermediate table for the relationship. * @@ -1102,10 +1672,37 @@ public function getRelationName() /** * Get the name of the pivot accessor for this relationship. * - * @return string + * @return TAccessor */ public function getPivotAccessor() { return $this->accessor; } + + /** + * Get the pivot columns for this relationship. + * + * @return array + */ + public function getPivotColumns() + { + return $this->pivotColumns; + } + + /** + * Qualify the given column name by the pivot table. + * + * @param string|\Illuminate\Contracts\Database\Query\Expression $column + * @return string|\Illuminate\Contracts\Database\Query\Expression + */ + public function qualifyPivotColumn($column) + { + if ($this->query->getQuery()->getGrammar()->isExpression($column)) { + return $column; + } + + return str_contains($column, '.') + ? $column + : $this->table.'.'.$column; + } } diff --git a/Eloquent/Relations/Concerns/AsPivot.php b/Eloquent/Relations/Concerns/AsPivot.php index a33f56bdc5..610592e5a3 100644 --- a/Eloquent/Relations/Concerns/AsPivot.php +++ b/Eloquent/Relations/Concerns/AsPivot.php @@ -2,9 +2,9 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; -use Illuminate\Support\Str; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Relations\MorphPivot; +use Illuminate\Support\Str; trait AsPivot { @@ -15,6 +15,13 @@ trait AsPivot */ public $pivotParent; + /** + * The related model of the relationship. + * + * @var \Illuminate\Database\Eloquent\Model + */ + public $pivotRelated; + /** * The name of the foreign key column. * @@ -33,9 +40,9 @@ trait AsPivot * Create a new pivot model instance. * * @param \Illuminate\Database\Eloquent\Model $parent - * @param array $attributes + * @param array $attributes * @param string $table - * @param bool $exists + * @param bool $exists * @return static */ public static function fromAttributes(Model $parent, $attributes, $table, $exists = false) @@ -66,9 +73,9 @@ public static function fromAttributes(Model $parent, $attributes, $table, $exist * Create a new pivot model from raw values returned from a query. * * @param \Illuminate\Database\Eloquent\Model $parent - * @param array $attributes + * @param array $attributes * @param string $table - * @param bool $exists + * @param bool $exists * @return static */ public static function fromRawAttributes(Model $parent, $attributes, $table, $exists = false) @@ -77,21 +84,23 @@ public static function fromRawAttributes(Model $parent, $attributes, $table, $ex $instance->timestamps = $instance->hasTimestampAttributes($attributes); - $instance->setRawAttributes($attributes, true); + $instance->setRawAttributes( + array_merge($instance->getRawOriginal(), $attributes), $exists + ); return $instance; } /** - * Set the keys for a save update query. + * Set the keys for a select query. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSelectQuery($query) { if (isset($this->attributes[$this->getKeyName()])) { - return parent::setKeysForSaveQuery($query); + return parent::setKeysForSelectQuery($query); } $query->where($this->foreignKey, $this->getOriginal( @@ -103,6 +112,17 @@ protected function setKeysForSaveQuery(Builder $query) )); } + /** + * Set the keys for a save update query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSaveQuery($query) + { + return $this->setKeysForSelectQuery($query); + } + /** * Delete the pivot model record from the database. * @@ -121,6 +141,8 @@ public function delete() $this->touchOwners(); return tap($this->getDeleteQuery()->delete(), function () { + $this->exists = false; + $this->fireModelEvent('deleted', false); }); } @@ -128,7 +150,7 @@ public function delete() /** * Get the query builder for a delete operation on the pivot. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ protected function getDeleteQuery() { @@ -145,13 +167,13 @@ protected function getDeleteQuery() */ public function getTable() { - if (! isset($this->table)) { + if (! isset($this->table) && (! $this instanceof MorphPivot)) { $this->setTable(str_replace( '\\', '', Str::snake(Str::singular(class_basename($this))) )); } - return $this->table; + return parent::getTable(); } /** @@ -200,15 +222,29 @@ public function setPivotKeys($foreignKey, $relatedKey) return $this; } + /** + * Set the related model of the relationship. + * + * @param \Illuminate\Database\Eloquent\Model|null $related + * @return $this + */ + public function setRelatedModel(?Model $related = null) + { + $this->pivotRelated = $related; + + return $this; + } + /** * Determine if the pivot model or given attributes has timestamp attributes. * - * @param $attributes array|null + * @param array|null $attributes * @return bool */ public function hasTimestampAttributes($attributes = null) { - return array_key_exists($this->getCreatedAtColumn(), $attributes ?? $this->attributes); + return ($createdAt = $this->getCreatedAtColumn()) !== null + && array_key_exists($createdAt, $attributes ?? $this->attributes); } /** @@ -256,8 +292,8 @@ public function getQueueableId() /** * Get a new query to restore one or more models by their queueable IDs. * - * @param array $ids - * @return \Illuminate\Database\Eloquent\Builder + * @param int[]|string[]|string $ids + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryForRestoration($ids) { @@ -265,7 +301,7 @@ public function newQueryForRestoration($ids) return $this->newQueryForCollectionRestoration($ids); } - if (! Str::contains($ids, ':')) { + if (! str_contains($ids, ':')) { return parent::newQueryForRestoration($ids); } @@ -279,12 +315,14 @@ public function newQueryForRestoration($ids) /** * Get a new query to restore multiple models by their queueable IDs. * - * @param array|int $ids - * @return \Illuminate\Database\Eloquent\Builder + * @param int[]|string[] $ids + * @return \Illuminate\Database\Eloquent\Builder */ protected function newQueryForCollectionRestoration(array $ids) { - if (! Str::contains($ids[0], ':')) { + $ids = array_values($ids); + + if (! str_contains($ids[0], ':')) { return parent::newQueryForRestoration($ids); } @@ -301,4 +339,18 @@ protected function newQueryForCollectionRestoration(array $ids) return $query; } + + /** + * Unset all the loaded relations for the instance. + * + * @return $this + */ + public function unsetRelations() + { + $this->pivotParent = null; + $this->pivotRelated = null; + $this->relations = []; + + return $this; + } } diff --git a/Eloquent/Relations/Concerns/CanBeOneOfMany.php b/Eloquent/Relations/Concerns/CanBeOneOfMany.php new file mode 100644 index 0000000000..800999f86c --- /dev/null +++ b/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -0,0 +1,332 @@ +|null + */ + protected $oneOfManySubQuery; + + /** + * Add constraints for inner join subselect for one of many relationships. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $query + * @param string|null $column + * @param string|null $aggregate + * @return void + */ + abstract public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null); + + /** + * Get the columns the determine the relationship groups. + * + * @return array|string + */ + abstract public function getOneOfManySubQuerySelectColumns(); + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Query\JoinClause $join + * @return void + */ + abstract public function addOneOfManyJoinSubQueryConstraints(JoinClause $join); + + /** + * Indicate that the relation is a single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|\Closure|null $aggregate + * @param string|null $relation + * @return $this + * + * @throws \InvalidArgumentException + */ + public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null) + { + $this->isOneOfMany = true; + + $this->relationName = $relation ?: $this->getDefaultOneOfManyJoinAlias( + $this->guessRelationship() + ); + + $keyName = $this->query->getModel()->getKeyName(); + + $columns = is_string($columns = $column) ? [ + $column => $aggregate, + $keyName => $aggregate, + ] : $column; + + if (! array_key_exists($keyName, $columns)) { + $columns[$keyName] = 'MAX'; + } + + if ($aggregate instanceof Closure) { + $closure = $aggregate; + } + + foreach ($columns as $column => $aggregate) { + if (! in_array(strtolower($aggregate), ['min', 'max'])) { + throw new InvalidArgumentException("Invalid aggregate [{$aggregate}] used within ofMany relation. Available aggregates: MIN, MAX"); + } + + $subQuery = $this->newOneOfManySubQuery( + $this->getOneOfManySubQuerySelectColumns(), + array_merge([$column], $previous['columns'] ?? []), + $aggregate, + ); + + if (isset($previous)) { + $this->addOneOfManyJoinSubQuery( + $subQuery, + $previous['subQuery'], + $previous['columns'], + ); + } + + if (isset($closure)) { + $closure($subQuery); + } + + if (! isset($previous)) { + $this->oneOfManySubQuery = $subQuery; + } + + if (array_key_last($columns) == $column) { + $this->addOneOfManyJoinSubQuery( + $this->query, + $subQuery, + array_merge([$column], $previous['columns'] ?? []), + ); + } + + $previous = [ + 'subQuery' => $subQuery, + 'columns' => array_merge([$column], $previous['columns'] ?? []), + ]; + } + + $this->addConstraints(); + + $columns = $this->query->getQuery()->columns; + + if (is_null($columns) || $columns === ['*']) { + $this->select([$this->qualifyColumn('*')]); + } + + return $this; + } + + /** + * Indicate that the relation is the latest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|null $relation + * @return $this + */ + public function latestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { + return [$column => 'MAX']; + })->all(), 'MAX', $relation); + } + + /** + * Indicate that the relation is the oldest single result of a larger one-to-many relationship. + * + * @param string|array|null $column + * @param string|null $relation + * @return $this + */ + public function oldestOfMany($column = 'id', $relation = null) + { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { + return [$column => 'MIN']; + })->all(), 'MIN', $relation); + } + + /** + * Get the default alias for the one of many inner join clause. + * + * @param string $relation + * @return string + */ + protected function getDefaultOneOfManyJoinAlias($relation) + { + return $relation == $this->query->getModel()->getTable() + ? $relation.'_of_many' + : $relation; + } + + /** + * Get a new query for the related model, grouping the query by the given column, often the foreign key of the relationship. + * + * @param string|array $groupBy + * @param array|null $columns + * @param string|null $aggregate + * @return \Illuminate\Database\Eloquent\Builder<*> + */ + protected function newOneOfManySubQuery($groupBy, $columns = null, $aggregate = null) + { + $subQuery = $this->query->getModel() + ->newQuery() + ->withoutGlobalScopes($this->removedScopes()); + + foreach (Arr::wrap($groupBy) as $group) { + $subQuery->groupBy($this->qualifyRelatedColumn($group)); + } + + if (! is_null($columns)) { + foreach ($columns as $key => $column) { + $aggregatedColumn = $subQuery->getQuery()->grammar->wrap($subQuery->qualifyColumn($column)); + + if ($key === 0) { + $aggregatedColumn = "{$aggregate}({$aggregatedColumn})"; + } else { + $aggregatedColumn = "min({$aggregatedColumn})"; + } + + $subQuery->selectRaw($aggregatedColumn.' as '.$subQuery->getQuery()->grammar->wrap($column.'_aggregate')); + } + } + + $this->addOneOfManySubQueryConstraints($subQuery, column: null, aggregate: $aggregate); + + return $subQuery; + } + + /** + * Add the join subquery to the given query on the given column and the relationship's foreign key. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $parent + * @param \Illuminate\Database\Eloquent\Builder<*> $subQuery + * @param array $on + * @return void + */ + protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, $on) + { + $parent->beforeQuery(function ($parent) use ($subQuery, $on) { + $subQuery->applyBeforeQueryCallbacks(); + + $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { + foreach ($on as $onColumn) { + $join->on($this->qualifySubSelectColumn($onColumn.'_aggregate'), '=', $this->qualifyRelatedColumn($onColumn)); + } + + $this->addOneOfManyJoinSubQueryConstraints($join); + }); + }); + } + + /** + * Merge the relationship query joins to the given query builder. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $query + * @return void + */ + protected function mergeOneOfManyJoinsTo(Builder $query) + { + $query->getQuery()->beforeQueryCallbacks = $this->query->getQuery()->beforeQueryCallbacks; + + $query->applyBeforeQueryCallbacks(); + } + + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Illuminate\Database\Eloquent\Builder<*> + */ + protected function getRelationQuery() + { + return $this->isOneOfMany() + ? $this->oneOfManySubQuery + : $this->query; + } + + /** + * Get the one of many inner join subselect builder instance. + * + * @return \Illuminate\Database\Eloquent\Builder<*>|void + */ + public function getOneOfManySubQuery() + { + return $this->oneOfManySubQuery; + } + + /** + * Get the qualified column name for the one-of-many relationship using the subselect join query's alias. + * + * @param string $column + * @return string + */ + public function qualifySubSelectColumn($column) + { + return $this->getRelationName().'.'.last(explode('.', $column)); + } + + /** + * Qualify related column using the related table name if it is not already qualified. + * + * @param string $column + * @return string + */ + protected function qualifyRelatedColumn($column) + { + return $this->query->getModel()->qualifyColumn($column); + } + + /** + * Guess the "hasOne" relationship's name via backtrace. + * + * @return string + */ + protected function guessRelationship() + { + return debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[2]['function']; + } + + /** + * Determine whether the relationship is a one-of-many relationship. + * + * @return bool + */ + public function isOneOfMany() + { + return $this->isOneOfMany; + } + + /** + * Get the name of the relationship. + * + * @return string + */ + public function getRelationName() + { + return $this->relationName; + } +} diff --git a/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/Eloquent/Relations/Concerns/ComparesRelatedModels.php new file mode 100644 index 0000000000..3dccf13107 --- /dev/null +++ b/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -0,0 +1,77 @@ +compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model)) && + $this->related->getTable() === $model->getTable() && + $this->related->getConnectionName() === $model->getConnectionName(); + + if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { + return $this->query + ->whereKey($model->getKey()) + ->exists(); + } + + return $match; + } + + /** + * Determine if the model is not the related instance of the relationship. + * + * @param \Illuminate\Database\Eloquent\Model|null $model + * @return bool + */ + public function isNot($model) + { + return ! $this->is($model); + } + + /** + * Get the value of the parent model's key. + * + * @return mixed + */ + abstract public function getParentKey(); + + /** + * Get the value of the model's related key. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @return mixed + */ + abstract protected function getRelatedKeyFrom(Model $model); + + /** + * Compare the parent key with the related key. + * + * @param mixed $parentKey + * @param mixed $relatedKey + * @return bool + */ + protected function compareKeys($parentKey, $relatedKey) + { + if (empty($parentKey) || empty($relatedKey)) { + return false; + } + + if (is_int($parentKey) || is_int($relatedKey)) { + return (int) $parentKey === (int) $relatedKey; + } + + return $parentKey === $relatedKey; + } +} diff --git a/Eloquent/Relations/Concerns/InteractsWithDictionary.php b/Eloquent/Relations/Concerns/InteractsWithDictionary.php new file mode 100644 index 0000000000..d94432e9a4 --- /dev/null +++ b/Eloquent/Relations/Concerns/InteractsWithDictionary.php @@ -0,0 +1,36 @@ +__toString(); + } + + if ($attribute instanceof UnitEnum) { + return enum_value($attribute); + } + + throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); + } + + return $attribute; + } +} diff --git a/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index 695d0bc776..dd324d09a6 100644 --- a/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -2,8 +2,10 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; +use BackedEnum; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Collection as BaseCollection; trait InteractsWithPivotTable @@ -14,7 +16,7 @@ trait InteractsWithPivotTable * Each existing model is detached, and non existing ones are attached. * * @param mixed $ids - * @param bool $touch + * @param bool $touch * @return array */ public function toggle($ids, $touch = true) @@ -64,8 +66,8 @@ public function toggle($ids, $touch = true) /** * Sync the intermediate tables with a list of IDs without detaching. * - * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids - * @return array + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array|int|string $ids + * @return array{attached: array, detached: array, updated: array} */ public function syncWithoutDetaching($ids) { @@ -75,9 +77,9 @@ public function syncWithoutDetaching($ids) /** * Sync the intermediate tables with a list of IDs or collection of models. * - * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array $ids - * @param bool $detaching - * @return array + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array|int|string $ids + * @param bool $detaching + * @return array{attached: array, detached: array, updated: array} */ public function sync($ids, $detaching = true) { @@ -85,24 +87,29 @@ public function sync($ids, $detaching = true) 'attached' => [], 'detached' => [], 'updated' => [], ]; + $records = $this->formatRecordsList($this->parseIds($ids)); + + if (empty($records) && ! $detaching) { + return $changes; + } + // First we need to attach any of the associated models that are not currently // in this joining table. We'll spin through the given IDs, checking to see // if they exist in the array of current ones, and if not we will insert. - $current = $this->newPivotQuery()->pluck( - $this->relatedPivotKey - )->all(); - - $detach = array_diff($current, array_keys( - $records = $this->formatRecordsList($this->parseIds($ids)) - )); + $current = $this->getCurrentlyAttachedPivots() + ->pluck($this->relatedPivotKey)->all(); // Next, we will take the differences of the currents and given IDs and detach // all of the entities that exist in the "current" array but are not in the // array of the new IDs given to the method which will complete the sync. - if ($detaching && count($detach) > 0) { - $this->detach($detach); + if ($detaching) { + $detach = array_diff($current, array_keys($records)); - $changes['detached'] = $this->castKeys($detach); + if (count($detach) > 0) { + $this->detach($detach, false); + + $changes['detached'] = $this->castKeys($detach); + } } // Now we are finally ready to attach the new records. Note that we'll disable @@ -116,13 +123,29 @@ public function sync($ids, $detaching = true) // have done any attaching or detaching, and if we have we will touch these // relationships if they are configured to touch on any database updates. if (count($changes['attached']) || - count($changes['updated'])) { + count($changes['updated']) || + count($changes['detached'])) { $this->touchIfTouching(); } return $changes; } + /** + * Sync the intermediate tables with a list of IDs or collection of models with the given pivot values. + * + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array|int|string $ids + * @param array $values + * @param bool $detaching + * @return array{attached: array, detached: array, updated: array} + */ + public function syncWithPivotValues($ids, array $values, bool $detaching = true) + { + return $this->sync((new BaseCollection($this->parseIds($ids)))->mapWithKeys(function ($id) use ($values) { + return [$id => $values]; + }), $detaching); + } + /** * Format the sync / toggle record list so that it is keyed by ID. * @@ -131,11 +154,15 @@ public function sync($ids, $detaching = true) */ protected function formatRecordsList(array $records) { - return collect($records)->mapWithKeys(function ($attributes, $id) { + return (new BaseCollection($records))->mapWithKeys(function ($attributes, $id) { if (! is_array($attributes)) { [$id, $attributes] = [$attributes, []]; } + if ($id instanceof BackedEnum) { + $id = $id->value; + } + return [$id => $attributes]; })->all(); } @@ -145,7 +172,7 @@ protected function formatRecordsList(array $records) * * @param array $records * @param array $current - * @param bool $touch + * @param bool $touch * @return array */ protected function attachNew(array $records, array $current, $touch = true) @@ -179,7 +206,7 @@ protected function attachNew(array $records, array $current, $touch = true) * * @param mixed $id * @param array $attributes - * @param bool $touch + * @param bool $touch * @return int */ public function updateExistingPivot($id, array $attributes, $touch = true) @@ -188,11 +215,11 @@ public function updateExistingPivot($id, array $attributes, $touch = true) return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); } - if (in_array($this->updatedAt(), $this->pivotColumns)) { + if ($this->hasPivotColumn($this->updatedAt())) { $attributes = $this->addTimestampsToAttachment($attributes, true); } - $updated = $this->newPivotStatementForId($this->parseId($id))->update( + $updated = $this->newPivotStatementForId($id)->update( $this->castAttributes($attributes) ); @@ -208,15 +235,18 @@ public function updateExistingPivot($id, array $attributes, $touch = true) * * @param mixed $id * @param array $attributes - * @param bool $touch + * @param bool $touch * @return int */ protected function updateExistingPivotUsingCustomClass($id, array $attributes, $touch) { - $updated = $this->newPivot([ - $this->foreignPivotKey => $this->parent->getKey(), - $this->relatedPivotKey => $this->parseId($id), - ], true)->fill($attributes)->save(); + $pivot = $this->getCurrentlyAttachedPivotsForIds($id)->first(); + + $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; + + if ($updated) { + $pivot->save(); + } if ($touch) { $this->touchIfTouching(); @@ -228,21 +258,21 @@ protected function updateExistingPivotUsingCustomClass($id, array $attributes, $ /** * Attach a model to the parent. * - * @param mixed $id + * @param mixed $ids * @param array $attributes - * @param bool $touch + * @param bool $touch * @return void */ - public function attach($id, array $attributes = [], $touch = true) + public function attach($ids, array $attributes = [], $touch = true) { if ($this->using) { - $this->attachUsingCustomClass($id, $attributes); + $this->attachUsingCustomClass($ids, $attributes); } else { // Here we will insert the attachment records into the pivot table. Once we have // inserted the records, we will touch the relationships if necessary and the // function will return. We can parse the IDs before inserting the records. $this->newPivotStatement()->insert($this->formatAttachRecords( - $this->parseIds($id), $attributes + $this->parseIds($ids), $attributes )); } @@ -254,14 +284,14 @@ public function attach($id, array $attributes = [], $touch = true) /** * Attach a model to the parent using a custom class. * - * @param mixed $id + * @param mixed $ids * @param array $attributes * @return void */ - protected function attachUsingCustomClass($id, array $attributes) + protected function attachUsingCustomClass($ids, array $attributes) { $records = $this->formatAttachRecords( - $this->parseIds($id), $attributes + $this->parseIds($ids), $attributes ); foreach ($records as $record) { @@ -298,10 +328,10 @@ protected function formatAttachRecords($ids, array $attributes) /** * Create a full attachment record payload. * - * @param int $key + * @param int $key * @param mixed $value * @param array $attributes - * @param bool $hasTimestamps + * @param bool $hasTimestamps * @return array */ protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps) @@ -324,14 +354,14 @@ protected function formatAttachRecord($key, $value, $attributes, $hasTimestamps) protected function extractAttachIdAndAttributes($key, $value, array $attributes) { return is_array($value) - ? [$key, array_merge($value, $attributes)] - : [$value, $attributes]; + ? [$key, array_merge($value, $attributes)] + : [$value, $attributes]; } /** * Create a new pivot attachment record. * - * @param int $id + * @param int $id * @param bool $timed * @return array */ @@ -359,7 +389,7 @@ protected function baseAttachRecord($id, $timed) * Set the creation and update timestamps on an attach record. * * @param array $record - * @param bool $exists + * @param bool $exists * @return array */ protected function addTimestampsToAttachment(array $record, $exists = false) @@ -369,7 +399,7 @@ protected function addTimestampsToAttachment(array $record, $exists = false) if ($this->using) { $pivotModel = new $this->using; - $fresh = $fresh->format($pivotModel->getDateFormat()); + $fresh = $pivotModel->fromDateTime($fresh); } if (! $exists && $this->hasPivotColumn($this->createdAt())) { @@ -389,7 +419,7 @@ protected function addTimestampsToAttachment(array $record, $exists = false) * @param string $column * @return bool */ - protected function hasPivotColumn($column) + public function hasPivotColumn($column) { return in_array($column, $this->pivotColumns); } @@ -418,7 +448,7 @@ public function detach($ids = null, $touch = true) return 0; } - $query->whereIn($this->relatedPivotKey, (array) $ids); + $query->whereIn($this->getQualifiedRelatedPivotKeyName(), (array) $ids); } // Once we have all of the conditions set on the statement, we are ready @@ -444,30 +474,67 @@ protected function detachUsingCustomClass($ids) { $results = 0; - foreach ($this->parseIds($ids) as $id) { - $results += $this->newPivot([ - $this->foreignPivotKey => $this->parent->getKey(), - $this->relatedPivotKey => $id, - ], true)->delete(); + $records = $this->getCurrentlyAttachedPivotsForIds($ids); + + foreach ($records as $record) { + $results += $record->delete(); } return $results; } + /** + * Get the pivot models that are currently attached. + * + * @return \Illuminate\Support\Collection + */ + protected function getCurrentlyAttachedPivots() + { + return $this->getCurrentlyAttachedPivotsForIds(); + } + + /** + * Get the pivot models that are currently attached, filtered by related model keys. + * + * @param mixed $ids + * @return \Illuminate\Support\Collection + */ + protected function getCurrentlyAttachedPivotsForIds($ids = null) + { + return $this->newPivotQuery() + ->when(! is_null($ids), fn ($query) => $query->whereIn( + $this->getQualifiedRelatedPivotKeyName(), $this->parseIds($ids) + )) + ->get() + ->map(function ($record) { + $class = $this->using ?: Pivot::class; + + $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true); + + return $pivot + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related); + }); + } + /** * Create a new pivot model instance. * * @param array $attributes - * @param bool $exists + * @param bool $exists * @return \Illuminate\Database\Eloquent\Relations\Pivot */ public function newPivot(array $attributes = [], $exists = false) { + $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes); + $pivot = $this->related->newPivot( $this->parent, $attributes, $this->table, $exists, $this->using ); - return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); + return $pivot + ->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) + ->setRelatedModel($this->related); } /** @@ -499,7 +566,7 @@ public function newPivotStatement() */ public function newPivotStatementForId($id) { - return $this->newPivotQuery()->whereIn($this->relatedPivotKey, $this->parseIds($id)); + return $this->newPivotQuery()->whereIn($this->getQualifiedRelatedPivotKeyName(), $this->parseIds($id)); } /** @@ -507,25 +574,29 @@ public function newPivotStatementForId($id) * * @return \Illuminate\Database\Query\Builder */ - protected function newPivotQuery() + public function newPivotQuery() { $query = $this->newPivotStatement(); foreach ($this->pivotWheres as $arguments) { - call_user_func_array([$query, 'where'], $arguments); + $query->where(...$arguments); } foreach ($this->pivotWhereIns as $arguments) { - call_user_func_array([$query, 'whereIn'], $arguments); + $query->whereIn(...$arguments); + } + + foreach ($this->pivotWhereNulls as $arguments) { + $query->whereNull(...$arguments); } - return $query->where($this->foreignPivotKey, $this->parent->{$this->parentKey}); + return $query->where($this->getQualifiedForeignPivotKeyName(), $this->parent->{$this->parentKey}); } /** * Set the columns on the pivot table to retrieve. * - * @param array|mixed $columns + * @param mixed $columns * @return $this */ public function withPivot($columns) @@ -549,12 +620,14 @@ protected function parseIds($value) return [$value->{$this->relatedKey}]; } - if ($value instanceof Collection) { + if ($value instanceof EloquentCollection) { return $value->pluck($this->relatedKey)->all(); } - if ($value instanceof BaseCollection) { - return $value->toArray(); + if ($value instanceof BaseCollection || is_array($value)) { + return (new BaseCollection($value)) + ->map(fn ($item) => $item instanceof Model ? $item->{$this->relatedKey} : $item) + ->all(); } return (array) $value; @@ -601,37 +674,30 @@ protected function castKey($key) /** * Cast the given pivot attributes. * - * @param array $attributes + * @param array $attributes * @return array */ protected function castAttributes($attributes) { return $this->using - ? $this->newPivot()->fill($attributes)->getAttributes() - : $attributes; + ? $this->newPivot()->fill($attributes)->getAttributes() + : $attributes; } /** * Converts a given value to a given type value. * - * @param string $type + * @param string $type * @param mixed $value * @return mixed */ protected function getTypeSwapValue($type, $value) { - switch (strtolower($type)) { - case 'int': - case 'integer': - return (int) $value; - case 'real': - case 'float': - case 'double': - return (float) $value; - case 'string': - return (string) $value; - default: - return $value; - } + return match (strtolower($type)) { + 'int', 'integer' => (int) $value, + 'real', 'float', 'double' => (float) $value, + 'string' => (string) $value, + default => $value, + }; } } diff --git a/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/Eloquent/Relations/Concerns/SupportsInverseRelations.php new file mode 100644 index 0000000000..c7140d0a31 --- /dev/null +++ b/Eloquent/Relations/Concerns/SupportsInverseRelations.php @@ -0,0 +1,157 @@ +chaperone($relation); + } + + /** + * Instruct Eloquent to link the related models back to the parent after the relationship query has run. + * + * @param string|null $relation + * @return $this + */ + public function chaperone(?string $relation = null) + { + $relation ??= $this->guessInverseRelation(); + + if (! $relation || ! $this->getModel()->isRelation($relation)) { + throw RelationNotFoundException::make($this->getModel(), $relation ?: 'null'); + } + + if ($this->inverseRelationship === null && $relation) { + $this->query->afterQuery(function ($result) { + return $this->inverseRelationship + ? $this->applyInverseRelationToCollection($result, $this->getParent()) + : $result; + }); + } + + $this->inverseRelationship = $relation; + + return $this; + } + + /** + * Guess the name of the inverse relationship. + * + * @return string|null + */ + protected function guessInverseRelation(): ?string + { + return Arr::first( + $this->getPossibleInverseRelations(), + fn ($relation) => $relation && $this->getModel()->isRelation($relation) + ); + } + + /** + * Get the possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return array_filter(array_unique([ + Str::camel(Str::beforeLast($this->getForeignKeyName(), $this->getParent()->getKeyName())), + Str::camel(Str::beforeLast($this->getParent()->getForeignKey(), $this->getParent()->getKeyName())), + Str::camel(class_basename($this->getParent())), + 'owner', + get_class($this->getParent()) === get_class($this->getModel()) ? 'parent' : null, + ])); + } + + /** + * Set the inverse relation on all models in a collection. + * + * @param \Illuminate\Database\Eloquent\Collection $models + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Collection + */ + protected function applyInverseRelationToCollection($models, ?Model $parent = null) + { + $parent ??= $this->getParent(); + + foreach ($models as $model) { + $model instanceof Model && $this->applyInverseRelationToModel($model, $parent); + } + + return $models; + } + + /** + * Set the inverse relation on a model. + * + * @param \Illuminate\Database\Eloquent\Model $model + * @param \Illuminate\Database\Eloquent\Model|null $parent + * @return \Illuminate\Database\Eloquent\Model + */ + protected function applyInverseRelationToModel(Model $model, ?Model $parent = null) + { + if ($inverse = $this->getInverseRelationship()) { + $parent ??= $this->getParent(); + + $model->setRelation($inverse, $parent); + } + + return $model; + } + + /** + * Get the name of the inverse relationship. + * + * @return string|null + */ + public function getInverseRelationship() + { + return $this->inverseRelationship; + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * Alias of "withoutChaperone". + * + * @return $this + */ + public function withoutInverse() + { + return $this->withoutChaperone(); + } + + /** + * Remove the chaperone / inverse relationship for this query. + * + * @return $this + */ + public function withoutChaperone() + { + $this->inverseRelationship = null; + + return $this; + } +} diff --git a/Eloquent/Relations/HasMany.php b/Eloquent/Relations/HasMany.php index 8971a7d2a4..1337b50246 100755 --- a/Eloquent/Relations/HasMany.php +++ b/Eloquent/Relations/HasMany.php @@ -2,29 +2,47 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\HasOneOrMany> + */ class HasMany extends HasOneOrMany { /** - * Get the results of the relationship. + * Convert the relationship to a "has one" relationship. * - * @return mixed + * @return \Illuminate\Database\Eloquent\Relations\HasOne */ + public function one() + { + return HasOne::noConstraints(fn () => tap( + new HasOne( + $this->getQuery(), + $this->parent, + $this->foreignKey, + $this->localKey + ), + function ($hasOne) { + if ($inverse = $this->getInverseRelationship()) { + $hasOne->inverse($inverse); + } + } + )); + } + + /** @inheritDoc */ public function getResults() { return ! is_null($this->getParentKey()) - ? $this->query->get() - : $this->related->newCollection(); + ? $this->query->get() + : $this->related->newCollection(); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -34,15 +52,8 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { return $this->matchMany($models, $results, $relation); } diff --git a/Eloquent/Relations/HasManyThrough.php b/Eloquent/Relations/HasManyThrough.php index 873ea893aa..1b94f1c593 100644 --- a/Eloquent/Relations/HasManyThrough.php +++ b/Eloquent/Relations/HasManyThrough.php @@ -2,164 +2,40 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Database\Eloquent\ModelNotFoundException; - -class HasManyThrough extends Relation +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough> + */ +class HasManyThrough extends HasOneOrManyThrough { - /** - * The "through" parent model instance. - * - * @var \Illuminate\Database\Eloquent\Model - */ - protected $throughParent; - - /** - * The far parent model instance. - * - * @var \Illuminate\Database\Eloquent\Model - */ - protected $farParent; - - /** - * The near key on the relationship. - * - * @var string - */ - protected $firstKey; - - /** - * The far key on the relationship. - * - * @var string - */ - protected $secondKey; - - /** - * The local key on the relationship. - * - * @var string - */ - protected $localKey; - - /** - * The local key on the intermediary model. - * - * @var string - */ - protected $secondLocalKey; + use InteractsWithDictionary; /** - * The count of self joins. + * Convert the relationship to a "has one through" relationship. * - * @var int + * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough */ - protected static $selfJoinCount = 0; - - /** - * Create a new has many through relationship instance. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $farParent - * @param \Illuminate\Database\Eloquent\Model $throughParent - * @param string $firstKey - * @param string $secondKey - * @param string $localKey - * @param string $secondLocalKey - * @return void - */ - public function __construct(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + public function one() { - $this->localKey = $localKey; - $this->firstKey = $firstKey; - $this->secondKey = $secondKey; - $this->farParent = $farParent; - $this->throughParent = $throughParent; - $this->secondLocalKey = $secondLocalKey; - - parent::__construct($query, $throughParent); - } - - /** - * Set the base constraints on the relation query. - * - * @return void - */ - public function addConstraints() - { - $localValue = $this->farParent[$this->localKey]; - - $this->performJoin(); - - if (static::$constraints) { - $this->query->where($this->getQualifiedFirstKeyName(), '=', $localValue); - } + return HasOneThrough::noConstraints(fn () => new HasOneThrough( + tap($this->getQuery(), fn (Builder $query) => $query->getQuery()->joins = []), + $this->farParent, + $this->throughParent, + $this->getFirstKeyName(), + $this->getForeignKeyName(), + $this->getLocalKeyName(), + $this->getSecondLocalKeyName(), + )); } - /** - * Set the join clause on the query. - * - * @param \Illuminate\Database\Eloquent\Builder|null $query - * @return void - */ - protected function performJoin(Builder $query = null) - { - $query = $query ?: $this->query; - - $farKey = $this->getQualifiedFarKeyName(); - - $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); - - if ($this->throughParentSoftDeletes()) { - $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); - } - } - - /** - * Get the fully qualified parent key name. - * - * @return string - */ - public function getQualifiedParentKeyName() - { - return $this->parent->qualifyColumn($this->secondLocalKey); - } - - /** - * Determine whether "through" parent of the relation uses Soft Deletes. - * - * @return bool - */ - public function throughParentSoftDeletes() - { - return in_array(SoftDeletes::class, class_uses_recursive($this->throughParent)); - } - - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ - public function addEagerConstraints(array $models) - { - $whereIn = $this->whereInMethod($this->farParent, $this->localKey); - - $this->query->{$whereIn}( - $this->getQualifiedFirstKeyName(), $this->getKeys($models, $this->localKey) - ); - } - - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -169,15 +45,8 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { $dictionary = $this->buildDictionary($results); @@ -185,7 +54,9 @@ public function match(array $models, Collection $results, $relation) // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { $model->setRelation( $relation, $this->related->newCollection($dictionary[$key]) ); @@ -195,461 +66,11 @@ public function match(array $models, Collection $results, $relation) return $models; } - /** - * Build model dictionary keyed by the relation's foreign key. - * - * @param \Illuminate\Database\Eloquent\Collection $results - * @return array - */ - protected function buildDictionary(Collection $results) - { - $dictionary = []; - - // First we will create a dictionary of models keyed by the foreign key of the - // relationship as this will allow us to quickly access all of the related - // models without having to do nested looping which will be quite slow. - foreach ($results as $result) { - $dictionary[$result->laravel_through_key][] = $result; - } - - return $dictionary; - } - - /** - * Get the first related model record matching the attributes or instantiate it. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model - */ - public function firstOrNew(array $attributes) - { - if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance($attributes); - } - - return $instance; - } - - /** - * Create or update a related record matching the attributes, and fill it with values. - * - * @param array $attributes - * @param array $values - * @return \Illuminate\Database\Eloquent\Model - */ - public function updateOrCreate(array $attributes, array $values = []) - { - $instance = $this->firstOrNew($attributes); - - $instance->fill($values)->save(); - - return $instance; - } - - /** - * Execute the query and get the first related model. - * - * @param array $columns - * @return mixed - */ - public function first($columns = ['*']) - { - $results = $this->take(1)->get($columns); - - return count($results) > 0 ? $results->first() : null; - } - - /** - * Execute the query and get the first result or throw an exception. - * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|static - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function firstOrFail($columns = ['*']) - { - if (! is_null($model = $this->first($columns))) { - return $model; - } - - throw (new ModelNotFoundException)->setModel(get_class($this->related)); - } - - /** - * Find a related model by its primary key. - * - * @param mixed $id - * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|null - */ - public function find($id, $columns = ['*']) - { - if (is_array($id)) { - return $this->findMany($id, $columns); - } - - return $this->where( - $this->getRelated()->getQualifiedKeyName(), '=', $id - )->first($columns); - } - - /** - * Find multiple related models by their primary keys. - * - * @param mixed $ids - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection - */ - public function findMany($ids, $columns = ['*']) - { - if (empty($ids)) { - return $this->getRelated()->newCollection(); - } - - return $this->whereIn( - $this->getRelated()->getQualifiedKeyName(), $ids - )->get($columns); - } - - /** - * Find a related model by its primary key or throw an exception. - * - * @param mixed $id - * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function findOrFail($id, $columns = ['*']) - { - $result = $this->find($id, $columns); - - if (is_array($id)) { - if (count($result) === count(array_unique($id))) { - return $result; - } - } elseif (! is_null($result)) { - return $result; - } - - throw (new ModelNotFoundException)->setModel(get_class($this->related), $id); - } - - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { return ! is_null($this->farParent->{$this->localKey}) - ? $this->get() - : $this->related->newCollection(); - } - - /** - * Execute the query as a "select" statement. - * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection - */ - public function get($columns = ['*']) - { - $builder = $this->prepareQueryBuilder($columns); - - $models = $builder->getModels(); - - // If we actually found models we will also eager load any relationships that - // have been specified as needing to be eager loaded. This will solve the - // n + 1 query problem for the developer and also increase performance. - if (count($models) > 0) { - $models = $builder->eagerLoadRelations($models); - } - - return $this->related->newCollection($models); - } - - /** - * Get a paginator for the "select" statement. - * - * @param int $perPage - * @param array $columns - * @param string $pageName - * @param int $page - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator - */ - public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) - { - $this->query->addSelect($this->shouldSelect($columns)); - - return $this->query->paginate($perPage, $columns, $pageName, $page); - } - - /** - * Paginate the given query into a simple paginator. - * - * @param int $perPage - * @param array $columns - * @param string $pageName - * @param int|null $page - * @return \Illuminate\Contracts\Pagination\Paginator - */ - public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) - { - $this->query->addSelect($this->shouldSelect($columns)); - - return $this->query->simplePaginate($perPage, $columns, $pageName, $page); - } - - /** - * Set the select clause for the relation query. - * - * @param array $columns - * @return array - */ - protected function shouldSelect(array $columns = ['*']) - { - if ($columns == ['*']) { - $columns = [$this->related->getTable().'.*']; - } - - return array_merge($columns, [$this->getQualifiedFirstKeyName().' as laravel_through_key']); - } - - /** - * Chunk the results of the query. - * - * @param int $count - * @param callable $callback - * @return bool - */ - public function chunk($count, callable $callback) - { - return $this->prepareQueryBuilder()->chunk($count, $callback); - } - - /** - * Chunk the results of a query by comparing numeric IDs. - * - * @param int $count - * @param callable $callback - * @param string|null $column - * @param string|null $alias - * @return bool - */ - public function chunkById($count, callable $callback, $column = null, $alias = null) - { - $column = $column ?? $this->getRelated()->getQualifiedKeyName(); - - $alias = $alias ?? $this->getRelated()->getKeyName(); - - return $this->prepareQueryBuilder()->chunkById($count, $callback, $column, $alias); - } - - /** - * Get a generator for the given query. - * - * @return \Generator - */ - public function cursor() - { - return $this->prepareQueryBuilder()->cursor(); - } - - /** - * Execute a callback over each item while chunking. - * - * @param callable $callback - * @param int $count - * @return bool - */ - 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; - } - } - }); - } - - /** - * Prepare the query builder for query execution. - * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Builder - */ - protected function prepareQueryBuilder($columns = ['*']) - { - $builder = $this->query->applyScopes(); - - return $builder->addSelect( - $this->shouldSelect($builder->getQuery()->columns ? [] : $columns) - ); - } - - /** - * Add the constraints for a relationship query. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ - public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) - { - if ($parentQuery->getQuery()->from === $query->getQuery()->from) { - return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); - } - - if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) { - return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns); - } - - $this->performJoin($query); - - return $query->select($columns)->whereColumn( - $this->getQualifiedLocalKeyName(), '=', $this->getQualifiedFirstKeyName() - ); - } - - /** - * Add the constraints for a relationship query on the same table. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ - public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) - { - $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash()); - - $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $hash.'.'.$this->secondKey); - - if ($this->throughParentSoftDeletes()) { - $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); - } - - $query->getModel()->setTable($hash); - - return $query->select($columns)->whereColumn( - $parentQuery->getQuery()->from.'.'.$this->localKey, '=', $this->getQualifiedFirstKeyName() - ); - } - - /** - * Add the constraints for a relationship query on the same table as the through parent. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ - public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) - { - $table = $this->throughParent->getTable().' as '.$hash = $this->getRelationCountHash(); - - $query->join($table, $hash.'.'.$this->secondLocalKey, '=', $this->getQualifiedFarKeyName()); - - if ($this->throughParentSoftDeletes()) { - $query->whereNull($hash.'.'.$this->throughParent->getDeletedAtColumn()); - } - - return $query->select($columns)->whereColumn( - $parentQuery->getQuery()->from.'.'.$this->localKey, '=', $hash.'.'.$this->firstKey - ); - } - - /** - * Get a relationship join table hash. - * - * @return string - */ - public function getRelationCountHash() - { - return 'laravel_reserved_'.static::$selfJoinCount++; - } - - /** - * Get the qualified foreign key on the related model. - * - * @return string - */ - public function getQualifiedFarKeyName() - { - return $this->getQualifiedForeignKeyName(); - } - - /** - * Get the foreign key on the "through" model. - * - * @return string - */ - public function getFirstKeyName() - { - return $this->firstKey; - } - - /** - * Get the qualified foreign key on the "through" model. - * - * @return string - */ - public function getQualifiedFirstKeyName() - { - return $this->throughParent->qualifyColumn($this->firstKey); - } - - /** - * Get the foreign key on the related model. - * - * @return string - */ - public function getForeignKeyName() - { - return $this->secondKey; - } - - /** - * Get the qualified foreign key on the related model. - * - * @return string - */ - public function getQualifiedForeignKeyName() - { - return $this->related->qualifyColumn($this->secondKey); - } - - /** - * Get the local key on the far parent model. - * - * @return string - */ - public function getLocalKeyName() - { - return $this->localKey; - } - - /** - * Get the qualified local key on the far parent model. - * - * @return string - */ - public function getQualifiedLocalKeyName() - { - return $this->farParent->qualifyColumn($this->localKey); - } - - /** - * Get the local key on the intermediary model. - * - * @return string - */ - public function getSecondLocalKeyName() - { - return $this->secondLocalKey; + ? $this->get() + : $this->related->newCollection(); } } diff --git a/Eloquent/Relations/HasOne.php b/Eloquent/Relations/HasOne.php index e4763ff4d4..911d4e26c7 100755 --- a/Eloquent/Relations/HasOne.php +++ b/Eloquent/Relations/HasOne.php @@ -2,19 +2,26 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class HasOne extends HasOneOrMany +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\HasOneOrMany + */ +class HasOne extends HasOneOrMany implements SupportsPartialRelations { - use SupportsDefaultModels; + use ComparesRelatedModels, CanBeOneOfMany, SupportsDefaultModels; - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { if (is_null($this->getParentKey())) { @@ -24,13 +31,7 @@ public function getResults() return $this->query->first() ?: $this->getDefaultFor($this->parent); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -40,29 +41,78 @@ public function initRelation(array $models, $relation) return $models; } + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) + { + return $this->matchOne($models, $results, $relation); + } + + /** @inheritDoc */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + /** - * Match the eagerly loaded results to their parents. + * Add constraints for inner join subselect for one of many relationships. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void */ - public function match(array $models, Collection $results, $relation) + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) { - return $this->matchOne($models, $results, $relation); + $query->addSelect($this->foreignKey); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return $this->foreignKey; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Query\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); } /** * Make a new related instance for the given model. * - * @param \Illuminate\Database\Eloquent\Model $parent - * @return \Illuminate\Database\Eloquent\Model + * @param TDeclaringModel $parent + * @return TRelatedModel */ public function newRelatedInstanceFor(Model $parent) { - return $this->related->newInstance()->setAttribute( - $this->getForeignKeyName(), $parent->{$this->localKey} - ); + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}); + $this->applyInverseRelationToModel($instance, $parent); + }); + } + + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + * @return int|string + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->getAttribute($this->getForeignKeyName()); } } diff --git a/Eloquent/Relations/HasOneOrMany.php b/Eloquent/Relations/HasOneOrMany.php index af59df96e9..ba32c0e3b3 100755 --- a/Eloquent/Relations/HasOneOrMany.php +++ b/Eloquent/Relations/HasOneOrMany.php @@ -2,12 +2,26 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Model; +use Closure; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; - +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations; +use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Arr; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TResult + * + * @extends \Illuminate\Database\Eloquent\Relations\Relation + */ abstract class HasOneOrMany extends Relation { + use InteractsWithDictionary, SupportsInverseRelations; + /** * The foreign key of the parent model. * @@ -22,21 +36,13 @@ abstract class HasOneOrMany extends Relation */ protected $localKey; - /** - * The count of self joins. - * - * @var int - */ - protected static $selfJoinCount = 0; - /** * Create a new has one or many relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent * @param string $foreignKey * @param string $localKey - * @return void */ public function __construct(Builder $query, Model $parent, $foreignKey, $localKey) { @@ -50,53 +56,71 @@ public function __construct(Builder $query, Model $parent, $foreignKey, $localKe * Create and return an un-saved instance of the related model. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function make(array $attributes = []) { return tap($this->related->newInstance($attributes), function ($instance) { $this->setForeignAttributesForCreate($instance); + $this->applyInverseRelationToModel($instance); }); } /** - * Set the base constraints on the relation query. + * Create and return an un-saved instance of the related models. * - * @return void + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection */ - public function addConstraints() + public function makeMany($records) { - if (static::$constraints) { - $this->query->where($this->foreignKey, '=', $this->getParentKey()); + $instances = $this->related->newCollection(); - $this->query->whereNotNull($this->foreignKey); + foreach ($records as $record) { + $instances->push($this->make($record)); } + + return $instances; } /** - * Set the constraints for an eager load of the relation. + * Set the base constraints on the relation query. * - * @param array $models * @return void */ + public function addConstraints() + { + if (static::$constraints) { + $query = $this->getRelationQuery(); + + $query->where($this->foreignKey, '=', $this->getParentKey()); + + $query->whereNotNull($this->foreignKey); + } + } + + /** @inheritDoc */ public function addEagerConstraints(array $models) { $whereIn = $this->whereInMethod($this->parent, $this->localKey); - $this->query->{$whereIn}( - $this->foreignKey, $this->getKeys($models, $this->localKey) + $this->whereInEager( + $whereIn, + $this->foreignKey, + $this->getKeys($models, $this->localKey), + $this->getRelationQuery() ); } /** * Match the eagerly loaded results to their single parents. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results * @param string $relation - * @return array + * @return array */ - public function matchOne(array $models, Collection $results, $relation) + public function matchOne(array $models, EloquentCollection $results, $relation) { return $this->matchOneOrMany($models, $results, $relation, 'one'); } @@ -104,12 +128,12 @@ public function matchOne(array $models, Collection $results, $relation) /** * Match the eagerly loaded results to their many parents. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results * @param string $relation - * @return array + * @return array */ - public function matchMany(array $models, Collection $results, $relation) + public function matchMany(array $models, EloquentCollection $results, $relation) { return $this->matchOneOrMany($models, $results, $relation, 'many'); } @@ -117,13 +141,13 @@ public function matchMany(array $models, Collection $results, $relation) /** * Match the eagerly loaded results to their many parents. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results * @param string $relation * @param string $type - * @return array + * @return array */ - protected function matchOneOrMany(array $models, Collection $results, $relation, $type) + protected function matchOneOrMany(array $models, EloquentCollection $results, $relation, $type) { $dictionary = $this->buildDictionary($results); @@ -131,10 +155,17 @@ protected function matchOneOrMany(array $models, Collection $results, $relation, // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { - $model->setRelation( - $relation, $this->getRelationValue($dictionary, $key, $type) - ); + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { + $related = $this->getRelationValue($dictionary, $key, $type); + + $model->setRelation($relation, $related); + + // Apply the inverse relation if we have one... + $type === 'one' + ? $this->applyInverseRelationToModel($related, $model) + : $this->applyInverseRelationToCollection($related, $model); } } @@ -144,7 +175,7 @@ protected function matchOneOrMany(array $models, Collection $results, $relation, /** * Get the value of a relationship by one or many type. * - * @param array $dictionary + * @param array $dictionary * @param string $key * @param string $type * @return mixed @@ -159,24 +190,36 @@ protected function getRelationValue(array $dictionary, $key, $type) /** * Build model dictionary keyed by the relation's foreign key. * - * @param \Illuminate\Database\Eloquent\Collection $results - * @return array + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array> */ - protected function buildDictionary(Collection $results) + protected function buildDictionary(EloquentCollection $results) { $foreign = $this->getForeignKeyName(); - return $results->mapToDictionary(function ($result) use ($foreign) { - return [$result->{$foreign} => $result]; - })->all(); + $dictionary = []; + + $isAssociative = Arr::isAssoc($results->all()); + + foreach ($results as $key => $item) { + $pairKey = $this->getDictionaryKey($item->{$foreign}); + + if ($isAssociative) { + $dictionary[$pairKey][$key] = $item; + } else { + $dictionary[$pairKey][] = $item; + } + } + + return $dictionary; } /** - * Find a model by its primary key or return new instance of the related model. + * Find a model by its primary key or return a new instance of the related model. * * @param mixed $id * @param array $columns - * @return \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TRelatedModel) */ public function findOrNew($id, $columns = ['*']) { @@ -194,12 +237,12 @@ public function findOrNew($id, $columns = ['*']) * * @param array $attributes * @param array $values - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ - public function firstOrNew(array $attributes, array $values = []) + public function firstOrNew(array $attributes = [], array $values = []) { if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->related->newInstance($attributes + $values); + $instance = $this->related->newInstance(array_merge($attributes, $values)); $this->setForeignAttributesForCreate($instance); } @@ -208,42 +251,81 @@ public function firstOrNew(array $attributes, array $values = []) } /** - * Get the first related record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes - * @param array $values - * @return \Illuminate\Database\Eloquent\Model + * @param (\Closure(): array)|array $values + * @return TRelatedModel */ - public function firstOrCreate(array $attributes, array $values = []) + public function firstOrCreate(array $attributes = [], Closure|array $values = []) { - if (is_null($instance = $this->where($attributes)->first())) { - $instance = $this->create($attributes + $values); + if (is_null($instance = (clone $this)->where($attributes)->first())) { + $instance = $this->createOrFirst($attributes, $values); } return $instance; } + /** + * 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 TRelatedModel + * + * @throws \Illuminate\Database\UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []) + { + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); + } catch (UniqueConstraintViolationException $e) { + return $this->useWritePdo()->where($attributes)->first() ?? throw $e; + } + } + /** * Create or update a related record matching the attributes, and fill it with values. * * @param array $attributes * @param array $values - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function updateOrCreate(array $attributes, array $values = []) { - return tap($this->firstOrNew($attributes), function ($instance) use ($values) { - $instance->fill($values); - - $instance->save(); + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } }); } + /** + * 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) && ! is_array(array_first($values))) { + $values = [$values]; + } + + foreach ($values as $key => $value) { + $values[$key][$this->getForeignKeyName()] = $this->getParentKey(); + } + + return $this->getQuery()->upsert($values, $uniqueBy, $update); + } + /** * Attach a model instance to the parent model. * - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Database\Eloquent\Model|false + * @param TRelatedModel $model + * @return TRelatedModel|false */ public function save(Model $model) { @@ -252,11 +334,24 @@ public function save(Model $model) return $model->save() ? $model : false; } + /** + * Attach a model instance without raising any events to the parent model. + * + * @param TRelatedModel $model + * @return TRelatedModel|false + */ + public function saveQuietly(Model $model) + { + return Model::withoutEvents(function () use ($model) { + return $this->save($model); + }); + } + /** * Attach a collection of models to the parent instance. * - * @param iterable $models - * @return iterable + * @param iterable $models + * @return iterable */ public function saveMany($models) { @@ -267,11 +362,24 @@ public function saveMany($models) return $models; } + /** + * Attach a collection of models to the parent instance without raising any events to the parent model. + * + * @param iterable $models + * @return iterable + */ + public function saveManyQuietly($models) + { + return Model::withoutEvents(function () use ($models) { + return $this->saveMany($models); + }); + } + /** * Create a new instance of the related model. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function create(array $attributes = []) { @@ -279,16 +387,53 @@ public function create(array $attributes = []) $this->setForeignAttributesForCreate($instance); $instance->save(); + + $this->applyInverseRelationToModel($instance); }); } + /** + * Create a new instance of the related model without raising any events to the parent model. + * + * @param array $attributes + * @return TRelatedModel + */ + public function createQuietly(array $attributes = []) + { + return Model::withoutEvents(fn () => $this->create($attributes)); + } + + /** + * Create a new instance of the related model. Allow mass-assignment. + * + * @param array $attributes + * @return TRelatedModel + */ + public function forceCreate(array $attributes = []) + { + $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); + } + + /** + * Create a new instance of the related model with mass assignment without raising model events. + * + * @param array $attributes + * @return TRelatedModel + */ + public function forceCreateQuietly(array $attributes = []) + { + return Model::withoutEvents(fn () => $this->forceCreate($attributes)); + } + /** * Create a Collection of new instances of the related model. * - * @param array $records - * @return \Illuminate\Database\Eloquent\Collection + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection */ - public function createMany(array $records) + public function createMany(iterable $records) { $instances = $this->related->newCollection(); @@ -299,25 +444,67 @@ public function createMany(array $records) return $instances; } + /** + * Create a Collection of new instances of the related model without raising any events to the parent model. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function createManyQuietly(iterable $records) + { + return Model::withoutEvents(fn () => $this->createMany($records)); + } + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function forceCreateMany(iterable $records) + { + $instances = $this->related->newCollection(); + + foreach ($records as $record) { + $instances->push($this->forceCreate($record)); + } + + return $instances; + } + + /** + * Create a Collection of new instances of the related model, allowing mass-assignment and without raising any events to the parent model. + * + * @param iterable $records + * @return \Illuminate\Database\Eloquent\Collection + */ + public function forceCreateManyQuietly(iterable $records) + { + return Model::withoutEvents(fn () => $this->forceCreateMany($records)); + } + /** * Set the foreign ID for creating a related model. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param TRelatedModel $model * @return void */ protected function setForeignAttributesForCreate(Model $model) { $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + $attributes ??= $model->getAttributes(); + + if (! array_key_exists($key, $attributes)) { + $model->setAttribute($key, $value); + } + } + + $this->applyInverseRelationToModel($model); } - /** - * Add the constraints for a relationship query. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { if ($query->getQuery()->from == $parentQuery->getQuery()->from) { @@ -330,10 +517,10 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Add the constraints for a relationship query on the same table. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder */ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) { @@ -347,13 +534,31 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder } /** - * Get a relationship join table hash. + * Alias to set the "limit" value of the query. * - * @return string + * @param int $value + * @return $this */ - public function getRelationCountHash() + public function take($value) { - return 'laravel_reserved_'.static::$selfJoinCount++; + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @param int $value + * @return $this + */ + public function limit($value) + { + if ($this->parent->exists) { + $this->query->limit($value); + } else { + $this->query->groupLimit($value, $this->getExistenceCompareKey()); + } + + return $this; } /** @@ -377,7 +582,7 @@ public function getParentKey() } /** - * Get the fully qualified parent key name. + * Get the fully-qualified parent key name. * * @return string */ @@ -395,7 +600,7 @@ public function getForeignKeyName() { $segments = explode('.', $this->getQualifiedForeignKeyName()); - return end($segments); + return array_last($segments); } /** diff --git a/Eloquent/Relations/HasOneOrManyThrough.php b/Eloquent/Relations/HasOneOrManyThrough.php new file mode 100644 index 0000000000..1636be0640 --- /dev/null +++ b/Eloquent/Relations/HasOneOrManyThrough.php @@ -0,0 +1,872 @@ + + */ +abstract class HasOneOrManyThrough extends Relation +{ + use InteractsWithDictionary; + + /** + * The "through" parent model instance. + * + * @var TIntermediateModel + */ + protected $throughParent; + + /** + * The far parent model instance. + * + * @var TDeclaringModel + */ + protected $farParent; + + /** + * The near key on the relationship. + * + * @var string + */ + protected $firstKey; + + /** + * The far key on the relationship. + * + * @var string + */ + protected $secondKey; + + /** + * The local key on the relationship. + * + * @var string + */ + protected $localKey; + + /** + * The local key on the intermediary model. + * + * @var string + */ + protected $secondLocalKey; + + /** + * Create a new has many through relationship instance. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $farParent + * @param TIntermediateModel $throughParent + * @param string $firstKey + * @param string $secondKey + * @param string $localKey + * @param string $secondLocalKey + */ + public function __construct(Builder $query, Model $farParent, Model $throughParent, $firstKey, $secondKey, $localKey, $secondLocalKey) + { + $this->localKey = $localKey; + $this->firstKey = $firstKey; + $this->secondKey = $secondKey; + $this->farParent = $farParent; + $this->throughParent = $throughParent; + $this->secondLocalKey = $secondLocalKey; + + parent::__construct($query, $throughParent); + } + + /** + * Set the base constraints on the relation query. + * + * @return void + */ + public function addConstraints() + { + $query = $this->getRelationQuery(); + + $localValue = $this->farParent[$this->localKey]; + + $this->performJoin($query); + + if (static::$constraints) { + $query->where($this->getQualifiedFirstKeyName(), '=', $localValue); + } + } + + /** + * Set the join clause on the query. + * + * @param \Illuminate\Database\Eloquent\Builder|null $query + * @return void + */ + protected function performJoin(?Builder $query = null) + { + $query ??= $this->query; + + $farKey = $this->getQualifiedFarKeyName(); + + $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $farKey); + + if ($this->throughParentSoftDeletes()) { + $query->withGlobalScope('SoftDeletableHasManyThrough', function ($query) { + $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + }); + } + } + + /** + * Get the fully-qualified parent key name. + * + * @return string + */ + public function getQualifiedParentKeyName() + { + return $this->parent->qualifyColumn($this->secondLocalKey); + } + + /** + * Determine whether "through" parent of the relation uses Soft Deletes. + * + * @return bool + */ + public function throughParentSoftDeletes() + { + return $this->throughParent::isSoftDeletable(); + } + + /** + * Indicate that trashed "through" parents should be included in the query. + * + * @return $this + */ + public function withTrashedParents() + { + $this->query->withoutGlobalScope('SoftDeletableHasManyThrough'); + + return $this; + } + + /** @inheritDoc */ + public function addEagerConstraints(array $models) + { + $whereIn = $this->whereInMethod($this->farParent, $this->localKey); + + $this->whereInEager( + $whereIn, + $this->getQualifiedFirstKeyName(), + $this->getKeys($models, $this->localKey), + $this->getRelationQuery(), + ); + } + + /** + * Build model dictionary keyed by the relation's foreign key. + * + * @param \Illuminate\Database\Eloquent\Collection $results + * @return array> + */ + protected function buildDictionary(EloquentCollection $results) + { + $dictionary = []; + + $isAssociative = Arr::isAssoc($results->all()); + + // First we will create a dictionary of models keyed by the foreign key of the + // relationship as this will allow us to quickly access all of the related + // models without having to do nested looping which will be quite slow. + foreach ($results as $key => $result) { + if ($isAssociative) { + $dictionary[$result->laravel_through_key][$key] = $result; + } else { + $dictionary[$result->laravel_through_key][] = $result; + } + } + + return $dictionary; + } + + /** + * Get the first related model record matching the attributes or instantiate it. + * + * @param array $attributes + * @param array $values + * @return TRelatedModel + */ + public function firstOrNew(array $attributes = [], array $values = []) + { + if (! is_null($instance = $this->where($attributes)->first())) { + return $instance; + } + + return $this->related->newInstance(array_merge($attributes, $values)); + } + + /** + * Get the first record matching the attributes. If the record is not found, create it. + * + * @param array $attributes + * @param (\Closure(): array)|array $values + * @return TRelatedModel + */ + public function firstOrCreate(array $attributes = [], Closure|array $values = []) + { + if (! is_null($instance = (clone $this)->where($attributes)->first())) { + return $instance; + } + + return $this->createOrFirst(array_merge($attributes, value($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 TRelatedModel + * + * @throws \Illuminate\Database\UniqueConstraintViolationException + */ + public function createOrFirst(array $attributes = [], Closure|array $values = []) + { + try { + return $this->getQuery()->withSavepointIfNeeded(fn () => $this->create(array_merge($attributes, value($values)))); + } catch (UniqueConstraintViolationException $exception) { + return $this->where($attributes)->first() ?? throw $exception; + } + } + + /** + * Create or update a related record matching the attributes, and fill it with values. + * + * @param array $attributes + * @param array $values + * @return TRelatedModel + */ + public function updateOrCreate(array $attributes, array $values = []) + { + return tap($this->firstOrCreate($attributes, $values), function ($instance) use ($values) { + if (! $instance->wasRecentlyCreated) { + $instance->fill($values)->save(); + } + }); + } + + /** + * Add a basic where clause to the query, and return the first result. + * + * @param \Closure|string|array $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return TRelatedModel|null + */ + public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') + { + return $this->where($column, $operator, $value, $boolean)->first(); + } + + /** + * Execute the query and get the first related model. + * + * @param array $columns + * @return TRelatedModel|null + */ + public function first($columns = ['*']) + { + $results = $this->limit(1)->get($columns); + + return count($results) > 0 ? $results->first() : null; + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array $columns + * @return TRelatedModel + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public function firstOrFail($columns = ['*']) + { + if (! is_null($model = $this->first($columns))) { + return $model; + } + + throw (new ModelNotFoundException)->setModel(get_class($this->related)); + } + + /** + * Execute the query and get the first result or call a callback. + * + * @template TValue + * + * @param (\Closure(): TValue)|list $columns + * @param (\Closure(): TValue)|null $callback + * @return TRelatedModel|TValue + */ + public function firstOr($columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($model = $this->first($columns))) { + return $model; + } + + return $callback(); + } + + /** + * Find a related model by its primary key. + * + * @param mixed $id + * @param array $columns + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TRelatedModel|null) + */ + public function find($id, $columns = ['*']) + { + if (is_array($id) || $id instanceof Arrayable) { + return $this->findMany($id, $columns); + } + + return $this->where( + $this->getRelated()->getQualifiedKeyName(), '=', $id + )->first($columns); + } + + /** + * Find a sole related model by its primary key. + * + * @param mixed $id + * @param array $columns + * @return TRelatedModel + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function findSole($id, $columns = ['*']) + { + return $this->where( + $this->getRelated()->getQualifiedKeyName(), '=', $id + )->sole($columns); + } + + /** + * Find multiple related models by their primary keys. + * + * @param \Illuminate\Contracts\Support\Arrayable|array $ids + * @param array $columns + * @return \Illuminate\Database\Eloquent\Collection + */ + public function findMany($ids, $columns = ['*']) + { + $ids = $ids instanceof Arrayable ? $ids->toArray() : $ids; + + if (empty($ids)) { + return $this->getRelated()->newCollection(); + } + + return $this->whereIn( + $this->getRelated()->getQualifiedKeyName(), $ids + )->get($columns); + } + + /** + * Find a related model by its primary key or throw an exception. + * + * @param mixed $id + * @param array $columns + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TRelatedModel) + * + * @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; + } + } elseif (! is_null($result)) { + return $result; + } + + throw (new ModelNotFoundException)->setModel(get_class($this->related), $id); + } + + /** + * Find a related 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|TValue + * : TRelatedModel|TValue + * ) + */ + public function findOr($id, $columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $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; + } + } elseif (! is_null($result)) { + return $result; + } + + return $callback(); + } + + /** @inheritDoc */ + public function get($columns = ['*']) + { + $builder = $this->prepareQueryBuilder($columns); + + $models = $builder->getModels(); + + // If we actually found models we will also eager load any relationships that + // have been specified as needing to be eager loaded. This will solve the + // n + 1 query problem for the developer and also increase performance. + if (count($models) > 0) { + $models = $builder->eagerLoadRelations($models); + } + + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); + } + + /** + * Get a paginator for the "select" statement. + * + * @param int|null $perPage + * @param array $columns + * @param string $pageName + * @param int|null $page + * @return \Illuminate\Pagination\LengthAwarePaginator + */ + public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->paginate($perPage, $columns, $pageName, $page); + } + + /** + * Paginate the given query into a simple paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $pageName + * @param int|null $page + * @return \Illuminate\Contracts\Pagination\Paginator + */ + public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->simplePaginate($perPage, $columns, $pageName, $page); + } + + /** + * Paginate the given query into a cursor paginator. + * + * @param int|null $perPage + * @param array $columns + * @param string $cursorName + * @param string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + $this->query->addSelect($this->shouldSelect($columns)); + + return $this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor); + } + + /** + * Set the select clause for the relation query. + * + * @param array $columns + * @return array + */ + protected function shouldSelect(array $columns = ['*']) + { + if ($columns == ['*']) { + $columns = [$this->related->qualifyColumn('*')]; + } + + return array_merge($columns, [$this->getQualifiedFirstKeyName().' as laravel_through_key']); + } + + /** + * Chunk the results of the query. + * + * @param int $count + * @param callable $callback + * @return bool + */ + public function chunk($count, callable $callback) + { + return $this->prepareQueryBuilder()->chunk($count, $callback); + } + + /** + * Chunk the results of a query by comparing numeric IDs. + * + * @param int $count + * @param callable $callback + * @param string|null $column + * @param string|null $alias + * @return bool + */ + public function chunkById($count, callable $callback, $column = null, $alias = null) + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->chunkById($count, $callback, $column, $alias); + } + + /** + * Chunk the results of a query by comparing IDs in descending order. + * + * @param int $count + * @param callable $callback + * @param string|null $column + * @param string|null $alias + * @return bool + */ + public function chunkByIdDesc($count, callable $callback, $column = null, $alias = null) + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->chunkByIdDesc($count, $callback, $column, $alias); + } + + /** + * Execute a callback over each item while chunking by ID. + * + * @param callable $callback + * @param int $count + * @param string|null $column + * @param string|null $alias + * @return bool + */ + public function eachById(callable $callback, $count = 1000, $column = null, $alias = null) + { + $column = $column ?? $this->getRelated()->getQualifiedKeyName(); + + $alias = $alias ?? $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->eachById($callback, $count, $column, $alias); + } + + /** + * Get a generator for the given query. + * + * @return \Illuminate\Support\LazyCollection + */ + public function cursor() + { + return $this->prepareQueryBuilder()->cursor(); + } + + /** + * Execute a callback over each item while chunking. + * + * @param callable $callback + * @param int $count + * @return bool + */ + 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; + } + } + }); + } + + /** + * Query lazily, by chunks of the given size. + * + * @param int $chunkSize + * @return \Illuminate\Support\LazyCollection + */ + public function lazy($chunkSize = 1000) + { + return $this->prepareQueryBuilder()->lazy($chunkSize); + } + + /** + * 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 + */ + public function lazyById($chunkSize = 1000, $column = null, $alias = null) + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->lazyById($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 + */ + public function lazyByIdDesc($chunkSize = 1000, $column = null, $alias = null) + { + $column ??= $this->getRelated()->getQualifiedKeyName(); + + $alias ??= $this->getRelated()->getKeyName(); + + return $this->prepareQueryBuilder()->lazyByIdDesc($chunkSize, $column, $alias); + } + + /** + * Prepare the query builder for query execution. + * + * @param array $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function prepareQueryBuilder($columns = ['*']) + { + $builder = $this->query->applyScopes(); + + return $builder->addSelect( + $this->shouldSelect($builder->getQuery()->columns ? [] : $columns) + ); + } + + /** @inheritDoc */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($parentQuery->getQuery()->from === $query->getQuery()->from) { + return $this->getRelationExistenceQueryForSelfRelation($query, $parentQuery, $columns); + } + + if ($parentQuery->getQuery()->from === $this->throughParent->getTable()) { + return $this->getRelationExistenceQueryForThroughSelfRelation($query, $parentQuery, $columns); + } + + $this->performJoin($query); + + return $query->select($columns)->whereColumn( + $this->getQualifiedLocalKeyName(), '=', $this->getQualifiedFirstKeyName() + ); + } + + /** + * Add the constraints for a relationship query on the same table. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) + { + $query->from($query->getModel()->getTable().' as '.$hash = $this->getRelationCountHash()); + + $query->join($this->throughParent->getTable(), $this->getQualifiedParentKeyName(), '=', $hash.'.'.$this->secondKey); + + if ($this->throughParentSoftDeletes()) { + $query->whereNull($this->throughParent->getQualifiedDeletedAtColumn()); + } + + $query->getModel()->setTable($hash); + + return $query->select($columns)->whereColumn( + $parentQuery->getQuery()->from.'.'.$this->localKey, '=', $this->getQualifiedFirstKeyName() + ); + } + + /** + * Add the constraints for a relationship query on the same table as the through parent. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder + */ + public function getRelationExistenceQueryForThroughSelfRelation(Builder $query, Builder $parentQuery, $columns = ['*']) + { + $table = $this->throughParent->getTable().' as '.$hash = $this->getRelationCountHash(); + + $query->join($table, $hash.'.'.$this->secondLocalKey, '=', $this->getQualifiedFarKeyName()); + + if ($this->throughParentSoftDeletes()) { + $query->whereNull($hash.'.'.$this->throughParent->getDeletedAtColumn()); + } + + return $query->select($columns)->whereColumn( + $parentQuery->getQuery()->from.'.'.$this->localKey, '=', $hash.'.'.$this->firstKey + ); + } + + /** + * Alias to set the "limit" value of the query. + * + * @param int $value + * @return $this + */ + public function take($value) + { + return $this->limit($value); + } + + /** + * Set the "limit" value of the query. + * + * @param int $value + * @return $this + */ + public function limit($value) + { + if ($this->farParent->exists) { + $this->query->limit($value); + } else { + $column = $this->getQualifiedFirstKeyName(); + + $grammar = $this->query->getQuery()->getGrammar(); + + if ($grammar instanceof MySqlGrammar && $grammar->useLegacyGroupLimit($this->query->getQuery())) { + $column = 'laravel_through_key'; + } + + $this->query->groupLimit($value, $column); + } + + return $this; + } + + /** + * Get the qualified foreign key on the related model. + * + * @return string + */ + public function getQualifiedFarKeyName() + { + return $this->getQualifiedForeignKeyName(); + } + + /** + * Get the foreign key on the "through" model. + * + * @return string + */ + public function getFirstKeyName() + { + return $this->firstKey; + } + + /** + * Get the qualified foreign key on the "through" model. + * + * @return string + */ + public function getQualifiedFirstKeyName() + { + return $this->throughParent->qualifyColumn($this->firstKey); + } + + /** + * Get the foreign key on the related model. + * + * @return string + */ + public function getForeignKeyName() + { + return $this->secondKey; + } + + /** + * Get the qualified foreign key on the related model. + * + * @return string + */ + public function getQualifiedForeignKeyName() + { + return $this->related->qualifyColumn($this->secondKey); + } + + /** + * Get the local key on the far parent model. + * + * @return string + */ + public function getLocalKeyName() + { + return $this->localKey; + } + + /** + * Get the qualified local key on the far parent model. + * + * @return string + */ + public function getQualifiedLocalKeyName() + { + return $this->farParent->qualifyColumn($this->localKey); + } + + /** + * Get the local key on the intermediary model. + * + * @return string + */ + public function getSecondLocalKeyName() + { + return $this->secondLocalKey; + } +} diff --git a/Eloquent/Relations/HasOneThrough.php b/Eloquent/Relations/HasOneThrough.php index 86bd35d123..27c3a9da5f 100644 --- a/Eloquent/Relations/HasOneThrough.php +++ b/Eloquent/Relations/HasOneThrough.php @@ -2,31 +2,38 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class HasOneThrough extends HasManyThrough +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough + */ +class HasOneThrough extends HasOneOrManyThrough implements SupportsPartialRelations { - use SupportsDefaultModels; + use ComparesRelatedModels, CanBeOneOfMany, InteractsWithDictionary, SupportsDefaultModels; - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { + if (is_null($this->getParentKey())) { + return $this->getDefaultFor($this->farParent); + } + return $this->first() ?: $this->getDefaultFor($this->farParent); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -36,15 +43,8 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { $dictionary = $this->buildDictionary($results); @@ -52,8 +52,11 @@ public function match(array $models, Collection $results, $relation) // link them up with their children using the keyed dictionary to make the // matching very convenient and easy work. Then we'll just return them. foreach ($models as $model) { - if (isset($dictionary[$key = $model->getAttribute($this->localKey)])) { + $key = $this->getDictionaryKey($model->getAttribute($this->localKey)); + + if ($key !== null && isset($dictionary[$key])) { $value = $dictionary[$key]; + $model->setRelation( $relation, reset($value) ); @@ -63,14 +66,59 @@ public function match(array $models, Collection $results, $relation) return $models; } + /** @inheritDoc */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + + /** @inheritDoc */ + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) + { + $query->addSelect([$this->getQualifiedFirstKeyName()]); + + // We need to join subqueries that aren't the inner-most subquery which is joined in the CanBeOneOfMany::ofMany method... + if ($this->getOneOfManySubQuery() !== null) { + $this->performJoin($query); + } + } + + /** @inheritDoc */ + public function getOneOfManySubQuerySelectColumns() + { + return [$this->getQualifiedFirstKeyName()]; + } + + /** @inheritDoc */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join->on($this->qualifySubSelectColumn($this->firstKey), '=', $this->getQualifiedFirstKeyName()); + } + /** * Make a new related instance for the given model. * - * @param \Illuminate\Database\Eloquent\Model $parent - * @return \Illuminate\Database\Eloquent\Model + * @param TDeclaringModel $parent + * @return TRelatedModel */ public function newRelatedInstanceFor(Model $parent) { return $this->related->newInstance(); } + + /** @inheritDoc */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->getAttribute($this->getForeignKeyName()); + } + + /** @inheritDoc */ + public function getParentKey() + { + return $this->farParent->getAttribute($this->localKey); + } } diff --git a/Eloquent/Relations/MorphMany.php b/Eloquent/Relations/MorphMany.php index a4c63763f3..fd7830956d 100755 --- a/Eloquent/Relations/MorphMany.php +++ b/Eloquent/Relations/MorphMany.php @@ -2,29 +2,48 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\MorphOneOrMany> + */ class MorphMany extends MorphOneOrMany { /** - * Get the results of the relationship. + * Convert the relationship to a "morph one" relationship. * - * @return mixed + * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ + public function one() + { + return MorphOne::noConstraints(fn () => tap( + new MorphOne( + $this->getQuery(), + $this->getParent(), + $this->morphType, + $this->foreignKey, + $this->localKey + ), + function ($morphOne) { + if ($inverse = $this->getInverseRelationship()) { + $morphOne->inverse($inverse); + } + } + )); + } + + /** @inheritDoc */ public function getResults() { return ! is_null($this->getParentKey()) - ? $this->query->get() - : $this->related->newCollection(); + ? $this->query->get() + : $this->related->newCollection(); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -34,16 +53,17 @@ public function initRelation(array $models, $relation) return $models; } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) { return $this->matchMany($models, $results, $relation); } + + /** @inheritDoc */ + public function forceCreate(array $attributes = []) + { + $attributes[$this->getMorphType()] = $this->morphClass; + + return parent::forceCreate($attributes); + } } diff --git a/Eloquent/Relations/MorphOne.php b/Eloquent/Relations/MorphOne.php index 0327ffae05..fa3632efb3 100755 --- a/Eloquent/Relations/MorphOne.php +++ b/Eloquent/Relations/MorphOne.php @@ -2,19 +2,26 @@ namespace Illuminate\Database\Eloquent\Relations; +use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Relations\Concerns\CanBeOneOfMany; +use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; +use Illuminate\Database\Query\JoinClause; -class MorphOne extends MorphOneOrMany +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\MorphOneOrMany + */ +class MorphOne extends MorphOneOrMany implements SupportsPartialRelations { - use SupportsDefaultModels; + use CanBeOneOfMany, ComparesRelatedModels, SupportsDefaultModels; - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { if (is_null($this->getParentKey())) { @@ -24,13 +31,7 @@ public function getResults() return $this->query->first() ?: $this->getDefaultFor($this->parent); } - /** - * Initialize the relation on a set of models. - * - * @param array $models - * @param string $relation - * @return array - */ + /** @inheritDoc */ public function initRelation(array $models, $relation) { foreach ($models as $model) { @@ -40,29 +41,82 @@ public function initRelation(array $models, $relation) return $models; } + /** @inheritDoc */ + public function match(array $models, EloquentCollection $results, $relation) + { + return $this->matchOne($models, $results, $relation); + } + + /** @inheritDoc */ + public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) + { + if ($this->isOneOfMany()) { + $this->mergeOneOfManyJoinsTo($query); + } + + return parent::getRelationExistenceQuery($query, $parentQuery, $columns); + } + /** - * Match the eagerly loaded results to their parents. + * Add constraints for inner join subselect for one of many relationships. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string|null $column + * @param string|null $aggregate + * @return void */ - public function match(array $models, Collection $results, $relation) + public function addOneOfManySubQueryConstraints(Builder $query, $column = null, $aggregate = null) { - return $this->matchOne($models, $results, $relation); + $query->addSelect($this->foreignKey, $this->morphType); + } + + /** + * Get the columns that should be selected by the one of many subquery. + * + * @return array|string + */ + public function getOneOfManySubQuerySelectColumns() + { + return [$this->foreignKey, $this->morphType]; + } + + /** + * Add join query constraints for one of many relationships. + * + * @param \Illuminate\Database\Query\JoinClause $join + * @return void + */ + public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) + { + $join + ->on($this->qualifySubSelectColumn($this->morphType), '=', $this->qualifyRelatedColumn($this->morphType)) + ->on($this->qualifySubSelectColumn($this->foreignKey), '=', $this->qualifyRelatedColumn($this->foreignKey)); } /** * Make a new related instance for the given model. * - * @param \Illuminate\Database\Eloquent\Model $parent - * @return \Illuminate\Database\Eloquent\Model + * @param TDeclaringModel $parent + * @return TRelatedModel */ public function newRelatedInstanceFor(Model $parent) { - return $this->related->newInstance() - ->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) - ->setAttribute($this->getMorphType(), $this->morphClass); + return tap($this->related->newInstance(), function ($instance) use ($parent) { + $instance->setAttribute($this->getForeignKeyName(), $parent->{$this->localKey}) + ->setAttribute($this->getMorphType(), $this->morphClass); + + $this->applyInverseRelationToModel($instance, $parent); + }); + } + + /** + * Get the value of the model's foreign key. + * + * @param TRelatedModel $model + * @return int|string + */ + protected function getRelatedKeyFrom(Model $model) + { + return $model->getAttribute($this->getForeignKeyName()); } } diff --git a/Eloquent/Relations/MorphOneOrMany.php b/Eloquent/Relations/MorphOneOrMany.php index 58de31c29a..7c32befcdf 100755 --- a/Eloquent/Relations/MorphOneOrMany.php +++ b/Eloquent/Relations/MorphOneOrMany.php @@ -2,9 +2,17 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; - +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TResult + * + * @extends \Illuminate\Database\Eloquent\Relations\HasOneOrMany + */ abstract class MorphOneOrMany extends HasOneOrMany { /** @@ -17,19 +25,18 @@ abstract class MorphOneOrMany extends HasOneOrMany /** * The class name of the parent model. * - * @var string + * @var class-string */ protected $morphClass; /** * Create a new morph one or many relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent * @param string $type * @param string $id * @param string $localKey - * @return void */ public function __construct(Builder $query, Model $parent, $type, $id, $localKey) { @@ -48,29 +55,38 @@ public function __construct(Builder $query, Model $parent, $type, $id, $localKey public function addConstraints() { if (static::$constraints) { - parent::addConstraints(); + $this->getRelationQuery()->where($this->morphType, $this->morphClass); - $this->query->where($this->morphType, $this->morphClass); + parent::addConstraints(); } } + /** @inheritDoc */ + public function addEagerConstraints(array $models) + { + parent::addEagerConstraints($models); + + $this->getRelationQuery()->where($this->morphType, $this->morphClass); + } + /** - * Set the constraints for an eager load of the relation. + * Create a new instance of the related model. Allow mass-assignment. * - * @param array $models - * @return void + * @param array $attributes + * @return TRelatedModel */ - public function addEagerConstraints(array $models) + public function forceCreate(array $attributes = []) { - parent::addEagerConstraints($models); + $attributes[$this->getForeignKeyName()] = $this->getParentKey(); + $attributes[$this->getMorphType()] = $this->morphClass; - $this->query->where($this->morphType, $this->morphClass); + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } /** * Set the foreign ID and type for creating a related model. * - * @param \Illuminate\Database\Eloquent\Model $model + * @param TRelatedModel $model * @return void */ protected function setForeignAttributesForCreate(Model $model) @@ -78,20 +94,44 @@ protected function setForeignAttributesForCreate(Model $model) $model->{$this->getForeignKeyName()} = $this->getParentKey(); $model->{$this->getMorphType()} = $this->morphClass; + + foreach ($this->getQuery()->pendingAttributes as $key => $value) { + $attributes ??= $model->getAttributes(); + + if (! array_key_exists($key, $attributes)) { + $model->setAttribute($key, $value); + } + } + + $this->applyInverseRelationToModel($model); } /** - * Get the relationship query. + * Insert new records or update the existing ones. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param array $values + * @param array|string $uniqueBy + * @param array|null $update + * @return int */ + public function upsert(array $values, $uniqueBy, $update = null) + { + if (! empty($values) && ! is_array(array_first($values))) { + $values = [$values]; + } + + foreach ($values as $key => $value) { + $values[$key][$this->getMorphType()] = $this->getMorphClass(); + } + + return parent::upsert($values, $uniqueBy, $update); + } + + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( - $this->morphType, $this->morphClass + $query->qualifyColumn($this->getMorphType()), $this->morphClass ); } @@ -118,10 +158,23 @@ public function getMorphType() /** * Get the class name of the parent model. * - * @return string + * @return class-string */ public function getMorphClass() { return $this->morphClass; } + + /** + * Get the possible inverse relations for the parent model. + * + * @return array + */ + protected function getPossibleInverseRelations(): array + { + return array_unique([ + Str::beforeLast($this->getMorphType(), '_type'), + ...parent::getPossibleInverseRelations(), + ]); + } } diff --git a/Eloquent/Relations/MorphPivot.php b/Eloquent/Relations/MorphPivot.php index ade5953524..01aea33950 100644 --- a/Eloquent/Relations/MorphPivot.php +++ b/Eloquent/Relations/MorphPivot.php @@ -2,9 +2,6 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Support\Str; -use Illuminate\Database\Eloquent\Builder; - class MorphPivot extends Pivot { /** @@ -21,23 +18,36 @@ class MorphPivot extends Pivot * * Explicitly define this so it's not included in saved attributes. * - * @var string + * @var class-string */ protected $morphClass; /** * Set the keys for a save update query. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ - protected function setKeysForSaveQuery(Builder $query) + protected function setKeysForSaveQuery($query) { $query->where($this->morphType, $this->morphClass); return parent::setKeysForSaveQuery($query); } + /** + * Set the keys for a select query. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function setKeysForSelectQuery($query) + { + $query->where($this->morphType, $this->morphClass); + + return parent::setKeysForSelectQuery($query); + } + /** * Delete the pivot model record from the database. * @@ -45,11 +55,33 @@ protected function setKeysForSaveQuery(Builder $query) */ public function delete() { + if (isset($this->attributes[$this->getKeyName()])) { + return (int) parent::delete(); + } + + if ($this->fireModelEvent('deleting') === false) { + return 0; + } + $query = $this->getDeleteQuery(); $query->where($this->morphType, $this->morphClass); - return $query->delete(); + return tap($query->delete(), function () { + $this->exists = false; + + $this->fireModelEvent('deleted', false); + }); + } + + /** + * Get the morph type for the pivot. + * + * @return string + */ + public function getMorphType() + { + return $this->morphType; } /** @@ -68,7 +100,7 @@ public function setMorphType($morphType) /** * Set the morph class for the pivot. * - * @param string $morphClass + * @param class-string $morphClass * @return \Illuminate\Database\Eloquent\Relations\MorphPivot */ public function setMorphClass($morphClass) @@ -101,7 +133,7 @@ public function getQueueableId() * Get a new query to restore one or more models by their queueable IDs. * * @param array|int $ids - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryForRestoration($ids) { @@ -109,27 +141,29 @@ public function newQueryForRestoration($ids) return $this->newQueryForCollectionRestoration($ids); } - if (! Str::contains($ids, ':')) { + if (! str_contains($ids, ':')) { return parent::newQueryForRestoration($ids); } $segments = explode(':', $ids); return $this->newQueryWithoutScopes() - ->where($segments[0], $segments[1]) - ->where($segments[2], $segments[3]) - ->where($segments[4], $segments[5]); + ->where($segments[0], $segments[1]) + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); } /** * Get a new query to restore multiple models by their queueable IDs. * - * @param array $ids - * @return \Illuminate\Database\Eloquent\Builder + * @param array $ids + * @return \Illuminate\Database\Eloquent\Builder */ protected function newQueryForCollectionRestoration(array $ids) { - if (! Str::contains($ids[0], ':')) { + $ids = array_values($ids); + + if (! str_contains($ids[0], ':')) { return parent::newQueryForRestoration($ids); } @@ -140,8 +174,8 @@ protected function newQueryForCollectionRestoration(array $ids) $query->orWhere(function ($query) use ($segments) { return $query->where($segments[0], $segments[1]) - ->where($segments[2], $segments[3]) - ->where($segments[4], $segments[5]); + ->where($segments[2], $segments[3]) + ->where($segments[4], $segments[5]); }); } diff --git a/Eloquent/Relations/MorphTo.php b/Eloquent/Relations/MorphTo.php index 6c08ef3e04..556fcf9cd9 100644 --- a/Eloquent/Relations/MorphTo.php +++ b/Eloquent/Relations/MorphTo.php @@ -3,12 +3,22 @@ namespace Illuminate\Database\Eloquent\Relations; use BadMethodCallException; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; - +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Support\Arr; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\BelongsTo + */ class MorphTo extends BelongsTo { + use InteractsWithDictionary; + /** * The type of the polymorphic relation. * @@ -16,10 +26,17 @@ class MorphTo extends BelongsTo */ protected $morphType; + /** + * The associated key on the parent model. + * + * @var string|null + */ + protected $ownerKey; + /** * The models whose relations are being eager loaded. * - * @var \Illuminate\Database\Eloquent\Collection + * @var \Illuminate\Database\Eloquent\Collection */ protected $models; @@ -37,16 +54,36 @@ class MorphTo extends BelongsTo */ protected $macroBuffer = []; + /** + * A map of relations to load for each individual morph type. + * + * @var array + */ + protected $morphableEagerLoads = []; + + /** + * A map of relationship counts to load for each individual morph type. + * + * @var array + */ + protected $morphableEagerLoadCounts = []; + + /** + * A map of constraints to apply for each individual morph type. + * + * @var array + */ + protected $morphableConstraints = []; + /** * Create a new morph to relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent + * @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 void */ public function __construct(Builder $query, Model $parent, $foreignKey, $ownerKey, $type, $relation) { @@ -55,28 +92,33 @@ public function __construct(Builder $query, Model $parent, $foreignKey, $ownerKe parent::__construct($query, $parent, $foreignKey, $ownerKey, $relation); } - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ + /** @inheritDoc */ + #[\Override] public function addEagerConstraints(array $models) { - $this->buildDictionary($this->models = Collection::make($models)); + $this->buildDictionary($this->models = new EloquentCollection($models)); } /** * Build a dictionary with the models. * - * @param \Illuminate\Database\Eloquent\Collection $models + * @param \Illuminate\Database\Eloquent\Collection $models * @return void */ - protected function buildDictionary(Collection $models) + protected function buildDictionary(EloquentCollection $models) { - foreach ($models as $model) { + $isAssociative = Arr::isAssoc($models->all()); + + foreach ($models as $key => $model) { if ($model->{$this->morphType}) { - $this->dictionary[$model->{$this->morphType}][$model->{$this->foreignKey}][] = $model; + $morphTypeKey = $this->getDictionaryKey($model->{$this->morphType}); + $foreignKeyKey = $this->getDictionaryKey($model->{$this->foreignKey}); + + if ($isAssociative) { + $this->dictionary[$morphTypeKey][$foreignKeyKey][$key] = $model; + } else { + $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; + } } } } @@ -86,7 +128,7 @@ protected function buildDictionary(Collection $models) * * Called via eager load method of Eloquent query builder. * - * @return mixed + * @return \Illuminate\Database\Eloquent\Collection */ public function getEager() { @@ -101,7 +143,7 @@ public function getEager() * Get all of the relation results for a type. * * @param string $type - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ protected function getResultsByType($type) { @@ -110,11 +152,23 @@ protected function getResultsByType($type) $ownerKey = $this->ownerKey ?? $instance->getKeyName(); $query = $this->replayMacros($instance->newQuery()) - ->mergeConstraintsFrom($this->getQuery()) - ->with($this->getQuery()->getEagerLoads()); + ->mergeConstraintsFrom($this->getQuery()) + ->with(array_merge( + $this->getQuery()->getEagerLoads(), + (array) ($this->morphableEagerLoads[get_class($instance)] ?? []) + )) + ->withCount( + (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) + ); + + if ($callback = ($this->morphableConstraints[get_class($instance)] ?? null)) { + $callback($query); + } - return $query->whereIn( - $instance->getTable().'.'.$ownerKey, $this->gatherKeysByType($type) + $whereIn = $this->whereInMethod($instance, $ownerKey); + + return $query->{$whereIn}( + $instance->qualifyColumn($ownerKey), $this->gatherKeysByType($type, $instance->getKeyType()) )->get(); } @@ -122,37 +176,38 @@ protected function getResultsByType($type) * Gather all of the foreign keys for a given type. * * @param string $type + * @param string $keyType * @return array */ - protected function gatherKeysByType($type) + protected function gatherKeysByType($type, $keyType) { - return collect($this->dictionary[$type])->map(function ($models) { - return head($models)->{$this->foreignKey}; - })->values()->unique()->all(); + return $keyType !== 'string' + ? array_keys($this->dictionary[$type]) + : array_map(function ($modelId) { + return (string) $modelId; + }, array_filter(array_keys($this->dictionary[$type]))); } /** * Create a new model instance by type. * * @param string $type - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function createModelByType($type) { $class = Model::getActualClassNameForMorph($type); - return new $class; + return tap(new $class, function ($instance) { + if (! $instance->getConnectionName()) { + $instance->setConnection($this->getConnection()->getName()); + } + }); } - /** - * Match the eagerly loaded results to their parents. - * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results - * @param string $relation - * @return array - */ - public function match(array $models, Collection $results, $relation) + /** @inheritDoc */ + #[\Override] + public function match(array $models, EloquentCollection $results, $relation) { return $models; } @@ -161,13 +216,13 @@ public function match(array $models, Collection $results, $relation) * Match the results for a given type to their parents. * * @param string $type - * @param \Illuminate\Database\Eloquent\Collection $results + * @param \Illuminate\Database\Eloquent\Collection $results * @return void */ - protected function matchToMorphParents($type, Collection $results) + protected function matchToMorphParents($type, EloquentCollection $results) { foreach ($results as $result) { - $ownerKey = ! is_null($this->ownerKey) ? $result->{$this->ownerKey} : $result->getKey(); + $ownerKey = ! is_null($this->ownerKey) ? $this->getDictionaryKey($result->{$this->ownerKey}) : $result->getKey(); if (isset($this->dictionary[$type][$ownerKey])) { foreach ($this->dictionary[$type][$ownerKey] as $model) { @@ -180,13 +235,20 @@ protected function matchToMorphParents($type, Collection $results) /** * Associate the model instance to the given parent. * - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Database\Eloquent\Model + * @param TRelatedModel|null $model + * @return TDeclaringModel */ + #[\Override] public function associate($model) { + if ($model instanceof Model) { + $foreignKey = $this->ownerKey && $model->{$this->ownerKey} + ? $this->ownerKey + : $model->getKeyName(); + } + $this->parent->setAttribute( - $this->foreignKey, $model instanceof Model ? $model->getKey() : null + $this->foreignKey, $model instanceof Model ? $model->{$foreignKey} : null ); $this->parent->setAttribute( @@ -199,8 +261,9 @@ public function associate($model) /** * Dissociate previously associated model from the given parent. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ + #[\Override] public function dissociate() { $this->parent->setAttribute($this->foreignKey, null); @@ -210,24 +273,17 @@ public function dissociate() return $this->parent->setRelation($this->relationName, null); } - /** - * Touch all of the related models for the relationship. - * - * @return void - */ + /** @inheritDoc */ + #[\Override] public function touch() { - if (! is_null($this->child->{$this->foreignKey})) { + if (! is_null($this->getParentKey())) { parent::touch(); } } - /** - * Make a new related instance for the given model. - * - * @param \Illuminate\Database\Eloquent\Model $parent - * @return \Illuminate\Database\Eloquent\Model - */ + /** @inheritDoc */ + #[\Override] protected function newRelatedInstanceFor(Model $parent) { return $parent->{$this->getRelationName()}()->getRelated()->newInstance(); @@ -253,11 +309,107 @@ public function getDictionary() return $this->dictionary; } + /** + * Specify which relations to load for a given morph type. + * + * @param array $with + * @return $this + */ + public function morphWith(array $with) + { + $this->morphableEagerLoads = array_merge( + $this->morphableEagerLoads, $with + ); + + return $this; + } + + /** + * Specify which relationship counts to load for a given morph type. + * + * @param array $withCount + * @return $this + */ + public function morphWithCount(array $withCount) + { + $this->morphableEagerLoadCounts = array_merge( + $this->morphableEagerLoadCounts, $withCount + ); + + return $this; + } + + /** + * Specify constraints on the query for a given morph type. + * + * @param array $callbacks + * @return $this + */ + public function constrain(array $callbacks) + { + $this->morphableConstraints = array_merge( + $this->morphableConstraints, $callbacks + ); + + return $this; + } + + /** + * Indicate that soft deleted models should be included in the results. + * + * @return $this + */ + public function withTrashed() + { + $callback = fn ($query) => $query->hasMacro('withTrashed') ? $query->withTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + + /** + * Indicate that soft deleted models should not be included in the results. + * + * @return $this + */ + public function withoutTrashed() + { + $callback = fn ($query) => $query->hasMacro('withoutTrashed') ? $query->withoutTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + + /** + * Indicate that only soft deleted models should be included in the results. + * + * @return $this + */ + public function onlyTrashed() + { + $callback = fn ($query) => $query->hasMacro('onlyTrashed') ? $query->onlyTrashed() : $query; + + $this->macroBuffer[] = [ + 'method' => 'when', + 'parameters' => [true, $callback], + ]; + + return $this->when(true, $callback); + } + /** * Replay stored macro calls on the actual related instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder */ protected function replayMacros(Builder $query) { @@ -268,11 +420,22 @@ protected function replayMacros(Builder $query) return $query; } + /** @inheritDoc */ + #[\Override] + public function getQualifiedOwnerKeyName() + { + if (is_null($this->ownerKey)) { + return ''; + } + + return parent::getQualifiedOwnerKeyName(); + } + /** * Handle dynamic method calls to the relationship. * * @param string $method - * @param array $parameters + * @param array $parameters * @return mixed */ public function __call($method, $parameters) @@ -290,7 +453,7 @@ public function __call($method, $parameters) // If we tried to call a method that does not exist on the parent Builder instance, // we'll assume that we want to call a query macro (e.g. withTrashed) that only // exists on related models. We will just store the call and replay it later. - catch (BadMethodCallException $e) { + catch (BadMethodCallException) { $this->macroBuffer[] = compact('method', 'parameters'); return $this; diff --git a/Eloquent/Relations/MorphToMany.php b/Eloquent/Relations/MorphToMany.php index e487af04af..dd0680c980 100644 --- a/Eloquent/Relations/MorphToMany.php +++ b/Eloquent/Relations/MorphToMany.php @@ -2,10 +2,19 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Support\Arr; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Builder; - +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; + +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TPivotModel of \Illuminate\Database\Eloquent\Relations\Pivot = \Illuminate\Database\Eloquent\Relations\MorphPivot + * @template TAccessor of string = 'pivot' + * + * @extends \Illuminate\Database\Eloquent\Relations\BelongsToMany + */ class MorphToMany extends BelongsToMany { /** @@ -18,7 +27,7 @@ class MorphToMany extends BelongsToMany /** * The class name of the morph type constraint. * - * @var string + * @var class-string */ protected $morphClass; @@ -34,21 +43,29 @@ class MorphToMany extends BelongsToMany /** * Create a new morph to many relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent * @param string $name * @param string $table * @param string $foreignPivotKey * @param string $relatedPivotKey * @param string $parentKey * @param string $relatedKey - * @param string $relationName + * @param string|null $relationName * @param bool $inverse - * @return void */ - public function __construct(Builder $query, Model $parent, $name, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey, $relatedKey, $relationName = null, $inverse = false) - { + public function __construct( + Builder $query, + Model $parent, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + $inverse = false, + ) { $this->inverse = $inverse; $this->morphType = $name.'_type'; $this->morphClass = $inverse ? $query->getModel()->getMorphClass() : $parent->getMorphClass(); @@ -68,28 +85,23 @@ protected function addWhereConstraints() { parent::addWhereConstraints(); - $this->query->where($this->table.'.'.$this->morphType, $this->morphClass); + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); return $this; } - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ + /** @inheritDoc */ public function addEagerConstraints(array $models) { parent::addEagerConstraints($models); - $this->query->where($this->table.'.'.$this->morphType, $this->morphClass); + $this->query->where($this->qualifyPivotColumn($this->morphType), $this->morphClass); } /** * Create a new pivot attachment record. * - * @param int $id + * @param int $id * @param bool $timed * @return array */ @@ -100,27 +112,36 @@ protected function baseAttachRecord($id, $timed) ); } - /** - * Add the constraints for a relationship count query. - * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder - */ + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return parent::getRelationExistenceQuery($query, $parentQuery, $columns)->where( - $this->table.'.'.$this->morphType, $this->morphClass + $this->qualifyPivotColumn($this->morphType), $this->morphClass ); } + /** + * Get the pivot models that are currently attached, filtered by related model keys. + * + * @param mixed $ids + * @return \Illuminate\Support\Collection + */ + protected function getCurrentlyAttachedPivotsForIds($ids = null) + { + return parent::getCurrentlyAttachedPivotsForIds($ids)->map(function ($record) { + return $record instanceof MorphPivot + ? $record->setMorphType($this->morphType) + ->setMorphClass($this->morphClass) + : $record; + }); + } + /** * Create a new query builder for the pivot table. * * @return \Illuminate\Database\Query\Builder */ - protected function newPivotQuery() + public function newPivotQuery() { return parent::newPivotQuery()->where($this->morphType, $this->morphClass); } @@ -129,19 +150,23 @@ protected function newPivotQuery() * Create a new pivot model instance. * * @param array $attributes - * @param bool $exists - * @return \Illuminate\Database\Eloquent\Relations\Pivot + * @param bool $exists + * @return TPivotModel */ public function newPivot(array $attributes = [], $exists = false) { $using = $this->using; - $pivot = $using ? $using::fromRawAttributes($this->parent, $attributes, $this->table, $exists) - : MorphPivot::fromAttributes($this->parent, $attributes, $this->table, $exists); + $attributes = array_merge([$this->morphType => $this->morphClass], $attributes); + + $pivot = $using + ? $using::fromRawAttributes($this->parent, $attributes, $this->table, $exists) + : MorphPivot::fromAttributes($this->parent, $attributes, $this->table, $exists); $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey) - ->setMorphType($this->morphType) - ->setMorphClass($this->morphClass); + ->setRelatedModel($this->related) + ->setMorphType($this->morphType) + ->setMorphClass($this->morphClass); return $pivot; } @@ -155,11 +180,15 @@ public function newPivot(array $attributes = [], $exists = false) */ protected function aliasedPivotColumns() { - $defaults = [$this->foreignPivotKey, $this->relatedPivotKey, $this->morphType]; - - return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { - return $this->table.'.'.$column.' as pivot_'.$column; - })->unique()->all(); + return (new Collection([ + $this->foreignPivotKey, + $this->relatedPivotKey, + $this->morphType, + ...$this->pivotColumns, + ])) + ->map(fn ($column) => $this->qualifyPivotColumn($column).' as pivot_'.$column) + ->unique() + ->all(); } /** @@ -173,10 +202,20 @@ public function getMorphType() } /** - * Get the class name of the parent model. + * Get the fully-qualified morph type for the relation. * * @return string */ + public function getQualifiedMorphTypeName() + { + return $this->qualifyPivotColumn($this->morphType); + } + + /** + * Get the class name of the parent model. + * + * @return class-string + */ public function getMorphClass() { return $this->morphClass; diff --git a/Eloquent/Relations/Pivot.php b/Eloquent/Relations/Pivot.php index a65ecdea66..6e1d3f2789 100755 --- a/Eloquent/Relations/Pivot.php +++ b/Eloquent/Relations/Pivot.php @@ -19,7 +19,7 @@ class Pivot extends Model /** * The attributes that aren't mass assignable. * - * @var array + * @var array|bool */ protected $guarded = []; } diff --git a/Eloquent/Relations/Relation.php b/Eloquent/Relations/Relation.php index 522d763aed..5f0ced5081 100755 --- a/Eloquent/Relations/Relation.php +++ b/Eloquent/Relations/Relation.php @@ -3,44 +3,58 @@ namespace Illuminate\Database\Eloquent\Relations; use Closure; -use Illuminate\Support\Arr; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Traits\Macroable; +use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\MultipleRecordsFoundException; use Illuminate\Database\Query\Expression; -use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Traits\ForwardsCalls; +use Illuminate\Support\Traits\Macroable; /** - * @mixin \Illuminate\Database\Eloquent\Builder + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TResult + * + * @mixin \Illuminate\Database\Eloquent\Builder */ -abstract class Relation +abstract class Relation implements BuilderContract { use ForwardsCalls, Macroable { - __call as macroCall; + Macroable::__call as macroCall; } /** * The Eloquent query builder instance. * - * @var \Illuminate\Database\Eloquent\Builder + * @var \Illuminate\Database\Eloquent\Builder */ protected $query; /** * The parent model instance. * - * @var \Illuminate\Database\Eloquent\Model + * @var TDeclaringModel */ protected $parent; /** * The related model instance. * - * @var \Illuminate\Database\Eloquent\Model + * @var TRelatedModel */ protected $related; + /** + * Indicates whether the eagerly loaded relation should implicitly return an empty collection. + * + * @var bool + */ + protected $eagerKeysWereEmpty = false; + /** * Indicates if the relation is adding constraints. * @@ -49,18 +63,31 @@ abstract class Relation protected static $constraints = true; /** - * An array to map class names to their morph names in database. + * An array to map morph names to their class names in the database. * - * @var array + * @var array> */ public static $morphMap = []; + /** + * Prevents morph relationships without a morph map. + * + * @var bool + */ + protected static $requireMorphMap = false; + + /** + * The count of self joins. + * + * @var int + */ + protected static $selfJoinCount = 0; + /** * Create a new relation instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent - * @return void + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent */ public function __construct(Builder $query, Model $parent) { @@ -74,8 +101,10 @@ public function __construct(Builder $query, Model $parent) /** * Run a callback with constraints disabled on the relation. * - * @param \Closure $callback - * @return mixed + * @template TReturn of mixed + * + * @param Closure(): TReturn $callback + * @return TReturn */ public static function noConstraints(Closure $callback) { @@ -87,7 +116,7 @@ public static function noConstraints(Closure $callback) // off of the bindings, leaving only the constraints that the developers put // as "extra" on the relationships, and not original relation constraints. try { - return call_user_func($callback); + return $callback(); } finally { static::$constraints = $previous; } @@ -103,7 +132,7 @@ abstract public function addConstraints(); /** * Set the constraints for an eager load of the relation. * - * @param array $models + * @param array $models * @return void */ abstract public function addEagerConstraints(array $models); @@ -111,44 +140,72 @@ abstract public function addEagerConstraints(array $models); /** * Initialize the relation on a set of models. * - * @param array $models + * @param array $models * @param string $relation - * @return array + * @return array */ abstract public function initRelation(array $models, $relation); /** * Match the eagerly loaded results to their parents. * - * @param array $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param array $models + * @param \Illuminate\Database\Eloquent\Collection $results * @param string $relation - * @return array + * @return array */ - abstract public function match(array $models, Collection $results, $relation); + abstract public function match(array $models, EloquentCollection $results, $relation); /** * Get the results of the relationship. * - * @return mixed + * @return TResult */ abstract public function getResults(); /** * Get the relationship for eager loading. * - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function getEager() { - return $this->get(); + return $this->eagerKeysWereEmpty + ? $this->related->newCollection() + : $this->get(); + } + + /** + * Execute the query and get the first result if it's the sole matching record. + * + * @param array|string $columns + * @return TRelatedModel + * + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function sole($columns = ['*']) + { + $result = $this->limit(2)->get($columns); + + $count = $result->count(); + + if ($count === 0) { + throw (new ModelNotFoundException)->setModel(get_class($this->related)); + } + + if ($count > 1) { + throw new MultipleRecordsFoundException($count); + } + + return $result->first(); } /** * Execute the query as a "select" statement. * * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function get($columns = ['*']) { @@ -185,9 +242,9 @@ public function rawUpdate(array $attributes = []) /** * Add the constraints for a relationship count query. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @return \Illuminate\Database\Eloquent\Builder */ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQuery) { @@ -201,10 +258,10 @@ public function getRelationExistenceCountQuery(Builder $query, Builder $parentQu * * Essentially, these queries compare on column names like whereColumn. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Builder $parentQuery - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $parentQuery + * @param mixed $columns + * @return \Illuminate\Database\Eloquent\Builder */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { @@ -213,24 +270,45 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, ); } + /** + * Get a relationship join table hash. + * + * @param bool $incrementJoinCount + * @return string + */ + public function getRelationCountHash($incrementJoinCount = true) + { + return 'laravel_reserved_'.($incrementJoinCount ? static::$selfJoinCount++ : static::$selfJoinCount); + } + /** * Get all of the primary keys for an array of models. * - * @param array $models - * @param string $key - * @return array + * @param array $models + * @param string|null $key + * @return array */ protected function getKeys(array $models, $key = null) { - return collect($models)->map(function ($value) use ($key) { + return (new BaseCollection($models))->map(function ($value) use ($key) { return $key ? $value->getAttribute($key) : $value->getKey(); })->values()->unique(null, true)->sort()->all(); } + /** + * Get the query builder that will contain the relationship constraints. + * + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function getRelationQuery() + { + return $this->query; + } + /** * Get the underlying query for the relation. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function getQuery() { @@ -247,10 +325,20 @@ public function getBaseQuery() return $this->query->getQuery(); } + /** + * Get a base query builder instance. + * + * @return \Illuminate\Database\Query\Builder + */ + public function toBase() + { + return $this->query->toBase(); + } + /** * Get the parent model of the relation. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function getParent() { @@ -258,7 +346,7 @@ public function getParent() } /** - * Get the fully qualified parent key name. + * Get the fully-qualified parent key name. * * @return string */ @@ -270,7 +358,7 @@ public function getQualifiedParentKeyName() /** * Get the related model of the relation. * - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function getRelated() { @@ -307,6 +395,24 @@ public function relatedUpdatedAt() return $this->related->getUpdatedAtColumn(); } + /** + * Add a whereIn eager constraint for the given set of model keys to be loaded. + * + * @param string $whereIn + * @param string $key + * @param array $modelKeys + * @param \Illuminate\Database\Eloquent\Builder|null $query + * @return void + */ + protected function whereInEager(string $whereIn, string $key, array $modelKeys, ?Builder $query = null) + { + ($query ?? $this->query)->{$whereIn}($key, $modelKeys); + + if ($modelKeys === []) { + $this->eagerKeysWereEmpty = true; + } + } + /** * Get the name of the "where in" method for eager loading. * @@ -317,26 +423,61 @@ public function relatedUpdatedAt() protected function whereInMethod(Model $model, $key) { return $model->getKeyName() === last(explode('.', $key)) - && $model->getIncrementing() - && in_array($model->getKeyType(), ['int', 'integer']) - ? 'whereIntegerInRaw' - : 'whereIn'; + && in_array($model->getKeyType(), ['int', 'integer']) + ? 'whereIntegerInRaw' + : 'whereIn'; } /** - * Set or get the morph map for polymorphic relations. + * Prevent polymorphic relationships from being used without model mappings. * - * @param array|null $map + * @param bool $requireMorphMap + * @return void + */ + public static function requireMorphMap($requireMorphMap = true) + { + static::$requireMorphMap = $requireMorphMap; + } + + /** + * Determine if polymorphic relationships require explicit model mapping. + * + * @return bool + */ + public static function requiresMorphMap() + { + return static::$requireMorphMap; + } + + /** + * Define the morph map for polymorphic relations and require all morphed models to be explicitly mapped. + * + * @param array> $map * @param bool $merge * @return array */ - public static function morphMap(array $map = null, $merge = true) + public static function enforceMorphMap(array $map, $merge = true) + { + static::requireMorphMap(); + + return static::morphMap($map, $merge); + } + + /** + * Set or get the morph map for polymorphic relations. + * + * @param array>|null $map + * @param bool $merge + * @return array> + */ + public static function morphMap(?array $map = null, $merge = true) { $map = static::buildMorphMapFromModels($map); if (is_array($map)) { static::$morphMap = $merge && static::$morphMap - ? $map + static::$morphMap : $map; + ? $map + static::$morphMap + : $map; } return static::$morphMap; @@ -345,12 +486,12 @@ public static function morphMap(array $map = null, $merge = true) /** * Builds a table-keyed array from model class names. * - * @param string[]|null $models - * @return array|null + * @param array>|null $models + * @return array>|null */ - protected static function buildMorphMapFromModels(array $models = null) + protected static function buildMorphMapFromModels(?array $models = null) { - if (is_null($models) || Arr::isAssoc($models)) { + if (is_null($models) || ! array_is_list($models)) { return $models; } @@ -363,18 +504,29 @@ protected static function buildMorphMapFromModels(array $models = null) * Get the model associated with a custom polymorphic type. * * @param string $alias - * @return string|null + * @return class-string<\Illuminate\Database\Eloquent\Model>|null */ public static function getMorphedModel($alias) { - return self::$morphMap[$alias] ?? null; + return static::$morphMap[$alias] ?? null; + } + + /** + * Get the alias associated with a custom polymorphic class. + * + * @param class-string<\Illuminate\Database\Eloquent\Model> $className + * @return int|string + */ + public static function getMorphAlias(string $className) + { + return array_search($className, static::$morphMap, strict: true) ?: $className; } /** * Handle dynamic method calls to the relationship. * * @param string $method - * @param array $parameters + * @param array $parameters * @return mixed */ public function __call($method, $parameters) @@ -383,13 +535,7 @@ public function __call($method, $parameters) return $this->macroCall($method, $parameters); } - $result = $this->forwardCallTo($this->query, $method, $parameters); - - if ($result === $this->query) { - return $this; - } - - return $result; + return $this->forwardDecoratedCallTo($this->query, $method, $parameters); } /** diff --git a/Eloquent/Scope.php b/Eloquent/Scope.php index 63cba6a517..cfb1d9b97b 100644 --- a/Eloquent/Scope.php +++ b/Eloquent/Scope.php @@ -7,8 +7,10 @@ interface Scope /** * Apply the scope to a given Eloquent query builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder - * @param \Illuminate\Database\Eloquent\Model $model + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @param TModel $model * @return void */ public function apply(Builder $builder, Model $model); diff --git a/Eloquent/SoftDeletes.php b/Eloquent/SoftDeletes.php index 1cd27c1c09..bdb4972743 100644 --- a/Eloquent/SoftDeletes.php +++ b/Eloquent/SoftDeletes.php @@ -2,6 +2,16 @@ namespace Illuminate\Database\Eloquent; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Collection as BaseCollection; + +/** + * @method static \Illuminate\Database\Eloquent\Builder withTrashed(bool $withTrashed = true) + * @method static \Illuminate\Database\Eloquent\Builder onlyTrashed() + * @method static \Illuminate\Database\Eloquent\Builder withoutTrashed() + * @method static static restoreOrCreate(array $attributes = [], array $values = []) + * @method static static createOrRestore(array $attributes = [], array $values = []) + */ trait SoftDeletes { /** @@ -28,7 +38,9 @@ public static function bootSoftDeletes() */ public function initializeSoftDeletes() { - $this->dates[] = $this->getDeletedAtColumn(); + if (! isset($this->casts[$this->getDeletedAtColumn()])) { + $this->casts[$this->getDeletedAtColumn()] = 'datetime'; + } } /** @@ -38,6 +50,10 @@ public function initializeSoftDeletes() */ public function forceDelete() { + if ($this->fireModelEvent('forceDeleting') === false) { + return false; + } + $this->forceDeleting = true; return tap($this->delete(), function ($deleted) { @@ -49,6 +65,54 @@ public function forceDelete() }); } + /** + * Force a hard delete on a soft deleted model without raising any events. + * + * @return bool|null + */ + public function forceDeleteQuietly() + { + return static::withoutEvents(fn () => $this->forceDelete()); + } + + /** + * Destroy the models for the given IDs. + * + * @param \Illuminate\Support\Collection|array|int|string $ids + * @return int + */ + public static function forceDestroy($ids) + { + if ($ids instanceof EloquentCollection) { + $ids = $ids->modelKeys(); + } + + if ($ids instanceof BaseCollection) { + $ids = $ids->all(); + } + + $ids = is_array($ids) ? $ids : func_get_args(); + + if (count($ids) === 0) { + return 0; + } + + // We will actually pull the models from the database table and call delete on + // each of them individually so that their events get fired properly with a + // correct set of attributes in case the developers wants to check these. + $key = ($instance = new static)->getKeyName(); + + $count = 0; + + foreach ($instance->withTrashed()->whereIn($key, $ids)->get() as $model) { + if ($model->forceDelete()) { + $count++; + } + } + + return $count; + } + /** * Perform the actual delete query on this model instance. * @@ -57,9 +121,9 @@ public function forceDelete() protected function performDeleteOnModel() { if ($this->forceDeleting) { - $this->exists = false; - - return $this->newModelQuery()->where($this->getKeyName(), $this->getKey())->forceDelete(); + return tap($this->setKeysForSaveQuery($this->newModelQuery())->forceDelete(), function () { + $this->exists = false; + }); } return $this->runSoftDelete(); @@ -72,7 +136,7 @@ protected function performDeleteOnModel() */ protected function runSoftDelete() { - $query = $this->newModelQuery()->where($this->getKeyName(), $this->getKey()); + $query = $this->setKeysForSaveQuery($this->newModelQuery()); $time = $this->freshTimestamp(); @@ -80,19 +144,23 @@ protected function runSoftDelete() $this->{$this->getDeletedAtColumn()} = $time; - if ($this->timestamps && ! is_null($this->getUpdatedAtColumn())) { + if ($this->usesTimestamps() && ! is_null($this->getUpdatedAtColumn())) { $this->{$this->getUpdatedAtColumn()} = $time; $columns[$this->getUpdatedAtColumn()] = $this->fromDateTime($time); } $query->update($columns); + + $this->syncOriginalAttributes(array_keys($columns)); + + $this->fireModelEvent('trashed', false); } /** * Restore a soft-deleted model instance. * - * @return bool|null + * @return bool */ public function restore() { @@ -117,6 +185,16 @@ public function restore() return $result; } + /** + * Restore a soft-deleted model instance without raising any events. + * + * @return bool + */ + public function restoreQuietly() + { + return static::withoutEvents(fn () => $this->restore()); + } + /** * Determine if the model instance has been soft-deleted. * @@ -128,9 +206,20 @@ public function trashed() } /** - * Register a restoring model event with the dispatcher. + * Register a "softDeleted" model event callback with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback + * @return void + */ + public static function softDeleted($callback) + { + static::registerModelEvent('trashed', $callback); + } + + /** + * Register a "restoring" model event callback with the dispatcher. + * + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback * @return void */ public static function restoring($callback) @@ -139,9 +228,9 @@ public static function restoring($callback) } /** - * Register a restored model event with the dispatcher. + * Register a "restored" model event callback with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback * @return void */ public static function restored($callback) @@ -149,6 +238,28 @@ public static function restored($callback) static::registerModelEvent('restored', $callback); } + /** + * Register a "forceDeleting" model event callback with the dispatcher. + * + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback + * @return void + */ + public static function forceDeleting($callback) + { + static::registerModelEvent('forceDeleting', $callback); + } + + /** + * Register a "forceDeleted" model event callback with the dispatcher. + * + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback + * @return void + */ + public static function forceDeleted($callback) + { + static::registerModelEvent('forceDeleted', $callback); + } + /** * Determine if the model is currently force deleting. * @@ -166,11 +277,11 @@ public function isForceDeleting() */ public function getDeletedAtColumn() { - return defined('static::DELETED_AT') ? static::DELETED_AT : 'deleted_at'; + return defined(static::class.'::DELETED_AT') ? static::DELETED_AT : 'deleted_at'; } /** - * Get the fully qualified "deleted at" column. + * Get the fully-qualified "deleted at" column. * * @return string */ diff --git a/Eloquent/SoftDeletingScope.php b/Eloquent/SoftDeletingScope.php index 0d51696624..d1ef0d22b9 100644 --- a/Eloquent/SoftDeletingScope.php +++ b/Eloquent/SoftDeletingScope.php @@ -7,15 +7,17 @@ class SoftDeletingScope implements Scope /** * All of the extensions to be added to the builder. * - * @var array + * @var string[] */ - protected $extensions = ['Restore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; + protected $extensions = ['Restore', 'RestoreOrCreate', 'CreateOrRestore', 'WithTrashed', 'WithoutTrashed', 'OnlyTrashed']; /** * Apply the scope to a given Eloquent query builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder - * @param \Illuminate\Database\Eloquent\Model $model + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Builder $builder + * @param TModel $model * @return void */ public function apply(Builder $builder, Model $model) @@ -26,7 +28,7 @@ public function apply(Builder $builder, Model $model) /** * Extend the query builder with the needed functions. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return void */ public function extend(Builder $builder) @@ -47,7 +49,7 @@ public function extend(Builder $builder) /** * Get the "deleted at" column for the builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return string */ protected function getDeletedAtColumn(Builder $builder) @@ -62,7 +64,7 @@ protected function getDeletedAtColumn(Builder $builder) /** * Add the restore extension to the builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return void */ protected function addRestore(Builder $builder) @@ -74,10 +76,44 @@ protected function addRestore(Builder $builder) }); } + /** + * Add the restore-or-create extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $builder + * @return void + */ + protected function addRestoreOrCreate(Builder $builder) + { + $builder->macro('restoreOrCreate', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->firstOrCreate($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + + /** + * Add the create-or-restore extension to the builder. + * + * @param \Illuminate\Database\Eloquent\Builder<*> $builder + * @return void + */ + protected function addCreateOrRestore(Builder $builder) + { + $builder->macro('createOrRestore', function (Builder $builder, array $attributes = [], array $values = []) { + $builder->withTrashed(); + + return tap($builder->createOrFirst($attributes, $values), function ($instance) { + $instance->restore(); + }); + }); + } + /** * Add the with-trashed extension to the builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return void */ protected function addWithTrashed(Builder $builder) @@ -94,7 +130,7 @@ protected function addWithTrashed(Builder $builder) /** * Add the without-trashed extension to the builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return void */ protected function addWithoutTrashed(Builder $builder) @@ -113,7 +149,7 @@ protected function addWithoutTrashed(Builder $builder) /** * Add the only-trashed extension to the builder. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @return void */ protected function addOnlyTrashed(Builder $builder) diff --git a/Events/ConnectionEstablished.php b/Events/ConnectionEstablished.php new file mode 100644 index 0000000000..22a45b834a --- /dev/null +++ b/Events/ConnectionEstablished.php @@ -0,0 +1,8 @@ +method = $method; + $this->migration = $migration; + } +} diff --git a/Events/MigrationSkipped.php b/Events/MigrationSkipped.php new file mode 100644 index 0000000000..f877020fcd --- /dev/null +++ b/Events/MigrationSkipped.php @@ -0,0 +1,18 @@ + $options The options provided when the migration method was invoked. + */ + public function __construct( + public $method, + public array $options = [], + ) { + } +} diff --git a/Events/MigrationsPruned.php b/Events/MigrationsPruned.php new file mode 100644 index 0000000000..16e519e27c --- /dev/null +++ b/Events/MigrationsPruned.php @@ -0,0 +1,42 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/Events/MigrationsStarted.php b/Events/MigrationsStarted.php new file mode 100644 index 0000000000..5283b49916 --- /dev/null +++ b/Events/MigrationsStarted.php @@ -0,0 +1,8 @@ + $models The class names of the models that were pruned. + */ + public function __construct( + public $models, + ) { + } +} diff --git a/Events/ModelPruningStarting.php b/Events/ModelPruningStarting.php new file mode 100644 index 0000000000..581d3da8a4 --- /dev/null +++ b/Events/ModelPruningStarting.php @@ -0,0 +1,16 @@ + $models The class names of the models that will be pruned. + */ + public function __construct( + public $models, + ) { + } +} diff --git a/Events/ModelsPruned.php b/Events/ModelsPruned.php new file mode 100644 index 0000000000..2d9605e5fe --- /dev/null +++ b/Events/ModelsPruned.php @@ -0,0 +1,18 @@ +sql = $sql; $this->time = $time; $this->bindings = $bindings; $this->connection = $connection; $this->connectionName = $connection->getName(); + $this->readWriteType = $readWriteType; + } + + /** + * Get the raw SQL representation of the query with embedded bindings. + * + * @return string + */ + public function toRawSql() + { + return $this->connection + ->query() + ->getGrammar() + ->substituteBindingsIntoRawSql($this->sql, $this->connection->prepareBindings($this->bindings)); } } diff --git a/Events/SchemaDumped.php b/Events/SchemaDumped.php new file mode 100644 index 0000000000..416462027c --- /dev/null +++ b/Events/SchemaDumped.php @@ -0,0 +1,40 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/Events/SchemaLoaded.php b/Events/SchemaLoaded.php new file mode 100644 index 0000000000..d86ae53074 --- /dev/null +++ b/Events/SchemaLoaded.php @@ -0,0 +1,40 @@ +connection = $connection; + $this->connectionName = $connection->getName(); + $this->path = $path; + } +} diff --git a/Events/StatementPrepared.php b/Events/StatementPrepared.php index 2f603235da..43f02a0e26 100644 --- a/Events/StatementPrepared.php +++ b/Events/StatementPrepared.php @@ -4,30 +4,15 @@ class StatementPrepared { - /** - * The database connection instance. - * - * @var \Illuminate\Database\Connection - */ - public $connection; - - /** - * The PDO statement. - * - * @var \PDOStatement - */ - public $statement; - /** * Create a new event instance. * - * @param \Illuminate\Database\Connection $connection - * @param \PDOStatement $statement - * @return void + * @param \Illuminate\Database\Connection $connection The database connection instance. + * @param \PDOStatement $statement The PDO statement. */ - public function __construct($connection, $statement) - { - $this->statement = $statement; - $this->connection = $connection; + public function __construct( + public $connection, + public $statement, + ) { } } diff --git a/Events/TransactionCommitting.php b/Events/TransactionCommitting.php new file mode 100644 index 0000000000..9b8179d32d --- /dev/null +++ b/Events/TransactionCommitting.php @@ -0,0 +1,8 @@ +connection = $connection; + } /** * Wrap an array of values. * - * @param array $values - * @return array + * @param array<\Illuminate\Contracts\Database\Query\Expression|string> $values + * @return array */ public function wrapArray(array $values) { - return array_map([$this, 'wrap'], $values); + return array_map($this->wrap(...), $values); } /** * Wrap a table in keyword identifiers. * - * @param \Illuminate\Database\Query\Expression|string $table + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param string|null $prefix * @return string */ - public function wrapTable($table) + public function wrapTable($table, $prefix = null) { - if (! $this->isExpression($table)) { - return $this->wrap($this->tablePrefix.$table, true); + if ($this->isExpression($table)) { + return $this->getValue($table); + } + + $prefix ??= $this->connection->getTablePrefix(); + + // If the table being wrapped has an alias we'll need to separate the pieces + // so we can prefix the table and then wrap each of the segments on their + // own and then join these both back together using the "as" connector. + if (stripos($table, ' as ') !== false) { + return $this->wrapAliasedTable($table, $prefix); } - return $this->getValue($table); + // If the table being wrapped has a custom schema name specified, we need to + // prefix the last segment as the table name then wrap each segment alone + // and eventually join them both back together using the dot connector. + if (str_contains($table, '.')) { + $table = substr_replace($table, '.'.$prefix, strrpos($table, '.'), 1); + + return (new Collection(explode('.', $table))) + ->map($this->wrapValue(...)) + ->implode('.'); + } + + return $this->wrapValue($prefix.$table); } /** * Wrap a value in keyword identifiers. * - * @param \Illuminate\Database\Query\Expression|string $value - * @param bool $prefixAlias + * @param \Illuminate\Contracts\Database\Query\Expression|string $value * @return string */ - public function wrap($value, $prefixAlias = false) + public function wrap($value) { if ($this->isExpression($value)) { return $this->getValue($value); @@ -59,7 +91,14 @@ public function wrap($value, $prefixAlias = false) // the pieces so we can wrap each of the segments of the expression on its // own, and then join these both back together using the "as" connector. if (stripos($value, ' as ') !== false) { - return $this->wrapAliasedValue($value, $prefixAlias); + return $this->wrapAliasedValue($value); + } + + // If the given value is a JSON selector we will wrap it differently than a + // traditional value. We will need to split this path and wrap each part + // wrapped, etc. Otherwise, we will simply wrap the value as a string. + if ($this->isJsonSelector($value)) { + return $this->wrapJsonSelector($value); } return $this->wrapSegments(explode('.', $value)); @@ -69,37 +108,43 @@ public function wrap($value, $prefixAlias = false) * Wrap a value that has an alias. * * @param string $value - * @param bool $prefixAlias * @return string */ - protected function wrapAliasedValue($value, $prefixAlias = false) + protected function wrapAliasedValue($value) { $segments = preg_split('/\s+as\s+/i', $value); - // If we are wrapping a table we need to prefix the alias with the table prefix - // as well in order to generate proper syntax. If this is a column of course - // no prefix is necessary. The condition will be true when from wrapTable. - if ($prefixAlias) { - $segments[1] = $this->tablePrefix.$segments[1]; - } + return $this->wrap($segments[0]).' as '.$this->wrapValue($segments[1]); + } + + /** + * Wrap a table that has an alias. + * + * @param string $value + * @param string|null $prefix + * @return string + */ + protected function wrapAliasedTable($value, $prefix = null) + { + $segments = preg_split('/\s+as\s+/i', $value); + + $prefix ??= $this->connection->getTablePrefix(); - return $this->wrap( - $segments[0]).' as '.$this->wrapValue($segments[1] - ); + return $this->wrapTable($segments[0], $prefix).' as '.$this->wrapValue($prefix.$segments[1]); } /** * Wrap the given value segments. * - * @param array $segments + * @param list $segments * @return string */ protected function wrapSegments($segments) { - return collect($segments)->map(function ($segment, $key) use ($segments) { + return (new Collection($segments))->map(function ($segment, $key) use ($segments) { return $key == 0 && count($segments) > 1 - ? $this->wrapTable($segment) - : $this->wrapValue($segment); + ? $this->wrapTable($segment) + : $this->wrapValue($segment); })->implode('.'); } @@ -118,32 +163,56 @@ protected function wrapValue($value) return $value; } + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + * + * @throws \RuntimeException + */ + protected function wrapJsonSelector($value) + { + throw new RuntimeException('This database engine does not support JSON operations.'); + } + + /** + * Determine if the given string is a JSON selector. + * + * @param string $value + * @return bool + */ + protected function isJsonSelector($value) + { + return str_contains($value, '->'); + } + /** * Convert an array of column names into a delimited string. * - * @param array $columns + * @param array<\Illuminate\Contracts\Database\Query\Expression|string> $columns * @return string */ public function columnize(array $columns) { - return implode(', ', array_map([$this, 'wrap'], $columns)); + return implode(', ', array_map($this->wrap(...), $columns)); } /** * Create query parameter place-holders for an array. * - * @param array $values + * @param array $values * @return string */ public function parameterize(array $values) { - return implode(', ', array_map([$this, 'parameter'], $values)); + return implode(', ', array_map($this->parameter(...), $values)); } /** * Get the appropriate query parameter place-holder for a value. * - * @param mixed $value + * @param mixed $value * @return string */ public function parameter($value) @@ -154,7 +223,7 @@ public function parameter($value) /** * Quote the given string literal. * - * @param string|array $value + * @param string|array $value * @return string */ public function quoteString($value) @@ -166,6 +235,18 @@ public function quoteString($value) return "'$value'"; } + /** + * Escapes a value for safe SQL embedding. + * + * @param string|float|int|bool|null $value + * @param bool $binary + * @return string + */ + public function escape($value, $binary = false) + { + return $this->connection->escape($value, $binary); + } + /** * Determine if the given value is a raw expression. * @@ -178,14 +259,18 @@ public function isExpression($value) } /** - * Get the value of a raw expression. + * Transforms expressions to their scalar types. * - * @param \Illuminate\Database\Query\Expression $expression - * @return string + * @param \Illuminate\Contracts\Database\Query\Expression|string|int|float $expression + * @return string|int|float */ public function getValue($expression) { - return $expression->getValue(); + if ($this->isExpression($expression)) { + return $this->getValue($expression->getValue($this)); + } + + return $expression; } /** @@ -201,22 +286,26 @@ public function getDateFormat() /** * Get the grammar's table prefix. * + * @deprecated Use DB::getTablePrefix() + * * @return string */ public function getTablePrefix() { - return $this->tablePrefix; + return $this->connection->getTablePrefix(); } /** * Set the grammar's table prefix. * + * @deprecated Use DB::setTablePrefix() + * * @param string $prefix * @return $this */ public function setTablePrefix($prefix) { - $this->tablePrefix = $prefix; + $this->connection->setTablePrefix($prefix); return $this; } diff --git a/LazyLoadingViolationException.php b/LazyLoadingViolationException.php new file mode 100644 index 0000000000..f0a90f6c95 --- /dev/null +++ b/LazyLoadingViolationException.php @@ -0,0 +1,38 @@ +model = $class; + $this->relation = $relation; + } +} diff --git a/LostConnectionDetector.php b/LostConnectionDetector.php new file mode 100644 index 0000000000..486be8e20d --- /dev/null +++ b/LostConnectionDetector.php @@ -0,0 +1,95 @@ +getMessage(); + + return Str::contains($message, [ + 'server has gone away', + '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', + 'connection is no longer usable', + 'Login timeout expired', + 'SQLSTATE[HY000] [2002] Connection refused', + 'running with the --read-only option so it cannot execute this statement', + 'The connection is broken and recovery is not possible. The connection is marked by the client driver as unrecoverable. No attempt was made to restore the connection.', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Name or service not known', + 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo for', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected', + 'SSL error: unexpected eof', + 'SQLSTATE[HY000] [2002] Connection timed out', + 'SSL: Connection timed out', + 'SQLSTATE[HY000]: General error: 1105 The last transaction was aborted due to Seamless Scaling. Please retry.', + 'Temporary failure in name resolution', + 'SQLSTATE[08S01]: Communication link failure', + 'SQLSTATE[08006] [7] could not connect to server: Connection refused Is the server running on host', + 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: No route to host', + 'The client was disconnected by the server because of inactivity. See wait_timeout and interactive_timeout for configuring this behavior.', + 'SQLSTATE[08006] [7] could not translate host name', + 'TCP Provider: Error code 0x274C', + 'SQLSTATE[HY000] [2002] No such file or directory', + 'SSL: Operation timed out', + 'Reason: Server is in script upgrade mode. Only administrator can connect at this time.', + 'Unknown $curl_error_code: 77', + 'SSL: Handshake timed out', + 'SSL error: sslv3 alert unexpected message', + 'unrecognized SSL error code:', + 'SQLSTATE[HY000] [1045] Access denied for user', + 'SQLSTATE[HY000] [2002] No connection could be made because the target machine actively refused it', + 'SQLSTATE[HY000] [2002] A connection attempt failed because the connected party did not properly respond after a period of time, or established connection failed because connected host has failed to respond', + 'SQLSTATE[HY000] [2002] Network is unreachable', + 'SQLSTATE[HY000] [2002] The requested address is not valid in its context', + 'SQLSTATE[HY000] [2002] A socket operation was attempted to an unreachable network', + 'SQLSTATE[HY000] [2002] Operation now in progress', + 'SQLSTATE[HY000] [2002] Operation in progress', + 'SQLSTATE[HY000]: General error: 3989', + 'went away', + 'No such file or directory', + 'server is shutting down', + 'failed to connect to', + 'Channel connection is closed', + 'Connection lost', + 'Broken pipe', + 'SQLSTATE[25006]: Read only sql transaction: 7', + 'vtgate connection error: no healthy endpoints', + 'primary is not serving, there may be a reparent operation in progress', + 'current keyspace is being resharded', + 'no healthy tablet available', + 'transaction pool connection limit exceeded', + 'SSL operation failed with code 5', + ]); + } +} diff --git a/LostConnectionException.php b/LostConnectionException.php new file mode 100644 index 0000000000..c8e57e368d --- /dev/null +++ b/LostConnectionException.php @@ -0,0 +1,10 @@ +schemaGrammar)) { + $this->useDefaultSchemaGrammar(); + } + + return new MariaDbBuilder($this); + } + + /** + * Get the default schema grammar instance. + * + * @return \Illuminate\Database\Schema\Grammars\MariaDbGrammar + */ + protected function getDefaultSchemaGrammar() + { + return new SchemaGrammar($this); + } + + /** + * Get the schema state for the connection. + * + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return \Illuminate\Database\Schema\MariaDbSchemaState + */ + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) + { + return new MariaDbSchemaState($this, $files, $processFactory); + } + + /** + * Get the default post processor instance. + * + * @return \Illuminate\Database\Query\Processors\MariaDbProcessor + */ + protected function getDefaultPostProcessor() + { + return new MariaDbProcessor; + } +} diff --git a/MigrationServiceProvider.php b/MigrationServiceProvider.php index dc4b4b7140..037106c735 100755 --- a/MigrationServiceProvider.php +++ b/MigrationServiceProvider.php @@ -2,14 +2,39 @@ namespace Illuminate\Database; -use Illuminate\Support\ServiceProvider; -use Illuminate\Database\Migrations\Migrator; +use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Contracts\Support\DeferrableProvider; -use Illuminate\Database\Migrations\MigrationCreator; +use Illuminate\Database\Console\Migrations\FreshCommand; +use Illuminate\Database\Console\Migrations\InstallCommand; +use Illuminate\Database\Console\Migrations\MigrateCommand; +use Illuminate\Database\Console\Migrations\MigrateMakeCommand; +use Illuminate\Database\Console\Migrations\RefreshCommand; +use Illuminate\Database\Console\Migrations\ResetCommand; +use Illuminate\Database\Console\Migrations\RollbackCommand; +use Illuminate\Database\Console\Migrations\StatusCommand; use Illuminate\Database\Migrations\DatabaseMigrationRepository; +use Illuminate\Database\Migrations\MigrationCreator; +use Illuminate\Database\Migrations\Migrator; +use Illuminate\Support\ServiceProvider; class MigrationServiceProvider extends ServiceProvider implements DeferrableProvider { + /** + * The commands to be registered. + * + * @var array + */ + protected $commands = [ + 'Migrate' => MigrateCommand::class, + 'MigrateFresh' => FreshCommand::class, + 'MigrateInstall' => InstallCommand::class, + 'MigrateRefresh' => RefreshCommand::class, + 'MigrateReset' => ResetCommand::class, + 'MigrateRollback' => RollbackCommand::class, + 'MigrateStatus' => StatusCommand::class, + 'MigrateMake' => MigrateMakeCommand::class, + ]; + /** * Register the service provider. * @@ -22,6 +47,8 @@ public function register() $this->registerMigrator(); $this->registerCreator(); + + $this->registerCommands($this->commands); } /** @@ -32,7 +59,9 @@ public function register() protected function registerRepository() { $this->app->singleton('migration.repository', function ($app) { - $table = $app['config']['database.migrations']; + $migrations = $app['config']['database.migrations']; + + $table = is_array($migrations) ? ($migrations['table'] ?? null) : $migrations; return new DatabaseMigrationRepository($app['db'], $table); }); @@ -51,8 +80,10 @@ protected function registerMigrator() $this->app->singleton('migrator', function ($app) { $repository = $app['migration.repository']; - return new Migrator($repository, $app['db'], $app['files']); + return new Migrator($repository, $app['db'], $app['files'], $app['events']); }); + + $this->app->bind(Migrator::class, fn ($app) => $app['migrator']); } /** @@ -63,7 +94,123 @@ protected function registerMigrator() protected function registerCreator() { $this->app->singleton('migration.creator', function ($app) { - return new MigrationCreator($app['files']); + return new MigrationCreator($app['files'], $app->basePath('stubs')); + }); + } + + /** + * Register the given commands. + * + * @param array $commands + * @return void + */ + protected function registerCommands(array $commands) + { + foreach (array_keys($commands) as $command) { + $this->{"register{$command}Command"}(); + } + + $this->commands(array_values($commands)); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateCommand() + { + $this->app->singleton(MigrateCommand::class, function ($app) { + return new MigrateCommand($app['migrator'], $app[Dispatcher::class]); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateFreshCommand() + { + $this->app->singleton(FreshCommand::class, function ($app) { + return new FreshCommand($app['migrator']); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateInstallCommand() + { + $this->app->singleton(InstallCommand::class, function ($app) { + return new InstallCommand($app['migration.repository']); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateMakeCommand() + { + $this->app->singleton(MigrateMakeCommand::class, function ($app) { + // Once we have the migration creator registered, we will create the command + // and inject the creator. The creator is responsible for the actual file + // creation of the migrations, and may be extended by these developers. + $creator = $app['migration.creator']; + + $composer = $app['composer']; + + return new MigrateMakeCommand($creator, $composer); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateRefreshCommand() + { + $this->app->singleton(RefreshCommand::class); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateResetCommand() + { + $this->app->singleton(ResetCommand::class, function ($app) { + return new ResetCommand($app['migrator']); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateRollbackCommand() + { + $this->app->singleton(RollbackCommand::class, function ($app) { + return new RollbackCommand($app['migrator']); + }); + } + + /** + * Register the command. + * + * @return void + */ + protected function registerMigrateStatusCommand() + { + $this->app->singleton(StatusCommand::class, function ($app) { + return new StatusCommand($app['migrator']); }); } @@ -74,8 +221,8 @@ protected function registerCreator() */ public function provides() { - return [ - 'migrator', 'migration.repository', 'migration.creator', - ]; + return array_merge([ + 'migrator', 'migration.repository', 'migration.creator', Migrator::class, + ], array_values($this->commands)); } } diff --git a/Migrations/DatabaseMigrationRepository.php b/Migrations/DatabaseMigrationRepository.php index 1ace1a6ff7..45c4389d27 100755 --- a/Migrations/DatabaseMigrationRepository.php +++ b/Migrations/DatabaseMigrationRepository.php @@ -32,7 +32,6 @@ class DatabaseMigrationRepository implements MigrationRepositoryInterface * * @param \Illuminate\Database\ConnectionResolverInterface $resolver * @param string $table - * @return void */ public function __construct(Resolver $resolver, $table) { @@ -43,35 +42,52 @@ public function __construct(Resolver $resolver, $table) /** * Get the completed migrations. * - * @return array + * @return string[] */ public function getRan() { return $this->table() - ->orderBy('batch', 'asc') - ->orderBy('migration', 'asc') - ->pluck('migration')->all(); + ->orderBy('batch', 'asc') + ->orderBy('migration', 'asc') + ->pluck('migration')->all(); } /** - * Get list of migrations. + * Get the list of migrations. * * @param int $steps - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getMigrations($steps) { $query = $this->table()->where('batch', '>=', '1'); return $query->orderBy('batch', 'desc') - ->orderBy('migration', 'desc') - ->take($steps)->get()->all(); + ->orderBy('migration', 'desc') + ->limit($steps) + ->get() + ->all(); + } + + /** + * Get the list of the migrations by batch number. + * + * @param int $batch + * @return array{id: int, migration: string, batch: int}[] + */ + public function getMigrationsByBatch($batch) + { + return $this->table() + ->where('batch', $batch) + ->orderBy('migration', 'desc') + ->get() + ->all(); } /** * Get the last migration batch. * - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getLast() { @@ -83,14 +99,14 @@ public function getLast() /** * Get the completed migrations with their batch numbers. * - * @return array + * @return array[] */ public function getMigrationBatches() { return $this->table() - ->orderBy('batch', 'asc') - ->orderBy('migration', 'asc') - ->pluck('batch', 'migration')->all(); + ->orderBy('batch', 'asc') + ->orderBy('migration', 'asc') + ->pluck('batch', 'migration')->all(); } /** @@ -110,7 +126,7 @@ public function log($file, $batch) /** * Remove a migration from the log. * - * @param object $migration + * @param object{id?: int, migration: string, batch?: int} $migration * @return void */ public function delete($migration) @@ -169,6 +185,18 @@ public function repositoryExists() return $schema->hasTable($this->table); } + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository() + { + $schema = $this->getConnection()->getSchemaBuilder(); + + $schema->drop($this->table); + } + /** * Get a query builder for the migration table. * diff --git a/Migrations/Migration.php b/Migrations/Migration.php index ac1b9e7f1d..35c8d43be3 100755 --- a/Migrations/Migration.php +++ b/Migrations/Migration.php @@ -7,7 +7,7 @@ abstract class Migration /** * The name of the database connection to use. * - * @var string + * @var string|null */ protected $connection; @@ -21,10 +21,20 @@ abstract class Migration /** * Get the migration connection name. * - * @return string + * @return string|null */ public function getConnection() { return $this->connection; } + + /** + * Determine if this migration should run. + * + * @return bool + */ + public function shouldRun(): bool + { + return true; + } } diff --git a/Migrations/MigrationCreator.php b/Migrations/MigrationCreator.php index 88ab4035bc..7b9da51390 100755 --- a/Migrations/MigrationCreator.php +++ b/Migrations/MigrationCreator.php @@ -3,9 +3,9 @@ namespace Illuminate\Database\Migrations; use Closure; +use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Str; use InvalidArgumentException; -use Illuminate\Filesystem\Filesystem; class MigrationCreator { @@ -16,10 +16,17 @@ class MigrationCreator */ protected $files; + /** + * The custom app stubs directory. + * + * @var string + */ + protected $customStubPath; + /** * The registered post create hooks. * - * @var array + * @var (\Closure(string, string): void)[] */ protected $postCreate = []; @@ -27,11 +34,12 @@ class MigrationCreator * Create a new migration creator instance. * * @param \Illuminate\Filesystem\Filesystem $files - * @return void + * @param string $customStubPath */ - public function __construct(Filesystem $files) + public function __construct(Filesystem $files, $customStubPath) { $this->files = $files; + $this->customStubPath = $customStubPath; } /** @@ -39,30 +47,33 @@ public function __construct(Filesystem $files) * * @param string $name * @param string $path - * @param string $table - * @param bool $create + * @param string|null $table + * @param bool $create * @return string * * @throws \Exception */ public function create($name, $path, $table = null, $create = false) { - $this->ensureMigrationDoesntAlreadyExist($name); + $this->ensureMigrationDoesntAlreadyExist($name, $path); // First we will get the stub file for the migration, which serves as a type // of template for the migration. Once we have those we will populate the // various place-holders, save the file, and run the post create event. $stub = $this->getStub($table, $create); + $path = $this->getPath($name, $path); + + $this->files->ensureDirectoryExists(dirname($path)); + $this->files->put( - $path = $this->getPath($name, $path), - $this->populateStub($name, $stub, $table) + $path, $this->populateStub($stub, $table) ); // Next, we will fire any hooks that are supposed to fire after a migration is // created. Once that is done we'll be ready to return the full path to the // migration file so it can be used however it's needed by the developer. - $this->firePostCreateHooks($table); + $this->firePostCreateHooks($table, $path); return $path; } @@ -71,12 +82,21 @@ public function create($name, $path, $table = null, $create = false) * Ensure that a migration with the given name doesn't already exist. * * @param string $name + * @param string|null $migrationPath * @return void * * @throws \InvalidArgumentException */ - protected function ensureMigrationDoesntAlreadyExist($name) + protected function ensureMigrationDoesntAlreadyExist($name, $migrationPath = null) { + if (! empty($migrationPath)) { + $migrationFiles = $this->files->glob($migrationPath.'/*.php'); + + foreach ($migrationFiles as $migrationFile) { + $this->files->requireOnce($migrationFile); + } + } + if (class_exists($className = $this->getClassName($name))) { throw new InvalidArgumentException("A {$className} class already exists."); } @@ -85,41 +105,46 @@ protected function ensureMigrationDoesntAlreadyExist($name) /** * Get the migration stub file. * - * @param string $table - * @param bool $create + * @param string|null $table + * @param bool $create * @return string */ protected function getStub($table, $create) { if (is_null($table)) { - return $this->files->get($this->stubPath().'/blank.stub'); + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.stub') + ? $customPath + : $this->stubPath().'/migration.stub'; + } elseif ($create) { + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.create.stub') + ? $customPath + : $this->stubPath().'/migration.create.stub'; + } else { + $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.update.stub') + ? $customPath + : $this->stubPath().'/migration.update.stub'; } - // We also have stubs for creating new tables and modifying existing tables - // to save the developer some typing when they are creating a new tables - // or modifying existing tables. We'll grab the appropriate stub here. - $stub = $create ? 'create.stub' : 'update.stub'; - - return $this->files->get($this->stubPath()."/{$stub}"); + return $this->files->get($stub); } /** * Populate the place-holders in the migration stub. * - * @param string $name * @param string $stub - * @param string $table + * @param string|null $table * @return string */ - protected function populateStub($name, $stub, $table) + protected function populateStub($stub, $table) { - $stub = str_replace('DummyClass', $this->getClassName($name), $stub); - // Here we will replace the table place-holders with the table specified by // the developer, which is useful for quickly creating a tables creation // or update migration from the console instead of typing it manually. if (! is_null($table)) { - $stub = str_replace('DummyTable', $table, $stub); + $stub = str_replace( + ['DummyTable', '{{ table }}', '{{table}}'], + $table, $stub + ); } return $stub; @@ -129,7 +154,7 @@ protected function populateStub($name, $stub, $table) * Get the class name of a migration name. * * @param string $name - * @return string + * @return class-string<\Illuminate\Database\Migrations\Migration> */ protected function getClassName($name) { @@ -151,20 +176,21 @@ protected function getPath($name, $path) /** * Fire the registered post create hooks. * - * @param string $table + * @param string|null $table + * @param string $path * @return void */ - protected function firePostCreateHooks($table) + protected function firePostCreateHooks($table, $path) { foreach ($this->postCreate as $callback) { - call_user_func($callback, $table); + $callback($table, $path); } } /** * Register a post migration create hook. * - * @param \Closure $callback + * @param (\Closure(string, string): void) $callback * @return void */ public function afterCreate(Closure $callback) diff --git a/Migrations/MigrationRepositoryInterface.php b/Migrations/MigrationRepositoryInterface.php index 410326a9bd..49cc08e21a 100755 --- a/Migrations/MigrationRepositoryInterface.php +++ b/Migrations/MigrationRepositoryInterface.php @@ -7,29 +7,37 @@ interface MigrationRepositoryInterface /** * Get the completed migrations. * - * @return array + * @return string[] */ public function getRan(); /** - * Get list of migrations. + * Get the list of migrations. * * @param int $steps - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getMigrations($steps); + /** + * Get the list of the migrations by batch. + * + * @param int $batch + * @return array{id: int, migration: string, batch: int}[] + */ + public function getMigrationsByBatch($batch); + /** * Get the last migration batch. * - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getLast(); /** * Get the completed migrations with their batch numbers. * - * @return array + * @return array[] */ public function getMigrationBatches(); @@ -45,7 +53,7 @@ public function log($file, $batch); /** * Remove a migration from the log. * - * @param object $migration + * @param objectt{id?: int, migration: string, batch?: int} $migration * @return void */ public function delete($migration); @@ -71,6 +79,13 @@ public function createRepository(); */ public function repositoryExists(); + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository(); + /** * Set the information source to gather data. * diff --git a/Migrations/MigrationResult.php b/Migrations/MigrationResult.php new file mode 100644 index 0000000000..649eb5b269 --- /dev/null +++ b/Migrations/MigrationResult.php @@ -0,0 +1,10 @@ + + */ + protected static $requiredPathCache = []; + /** * The output interface implementation. * - * @var \Illuminate\Console\OutputStyle + * @var \Symfony\Component\Console\Output\OutputInterface */ protected $output; + /** + * The pending migrations to skip. + * + * @var list + */ + protected static $withoutMigrations = []; + /** * Create a new migrator instance. * * @param \Illuminate\Database\Migrations\MigrationRepositoryInterface $repository * @param \Illuminate\Database\ConnectionResolverInterface $resolver * @param \Illuminate\Filesystem\Filesystem $files - * @return void - */ - public function __construct(MigrationRepositoryInterface $repository, - Resolver $resolver, - Filesystem $files) - { + * @param \Illuminate\Contracts\Events\Dispatcher|null $dispatcher + */ + public function __construct( + MigrationRepositoryInterface $repository, + Resolver $resolver, + Filesystem $files, + ?Dispatcher $dispatcher = null, + ) { $this->files = $files; + $this->events = $dispatcher; $this->resolver = $resolver; $this->repository = $repository; } @@ -73,14 +117,12 @@ public function __construct(MigrationRepositoryInterface $repository, /** * Run the pending migrations at a given path. * - * @param array|string $paths - * @param array $options - * @return array + * @param string[]|string $paths + * @param array $options + * @return string[] */ public function run($paths = [], array $options = []) { - $this->notes = []; - // Once we grab all of the migration files for the path, we will compare them // against the migrations that have already been run for this package then // run each of the outstanding migrations against a database connection. @@ -101,23 +143,39 @@ public function run($paths = [], array $options = []) /** * Get the migration files that have not yet run. * - * @param array $files - * @param array $ran - * @return array + * @param string[] $files + * @param string[] $ran + * @return string[] */ protected function pendingMigrations($files, $ran) { - return Collection::make($files) - ->reject(function ($file) use ($ran) { - return in_array($this->getMigrationName($file), $ran); - })->values()->all(); + $migrationsToSkip = $this->migrationsToSkip(); + + return (new Collection($files)) + ->reject(fn ($file) => in_array($migrationName = $this->getMigrationName($file), $ran) || + in_array($migrationName, $migrationsToSkip) + ) + ->values() + ->all(); + } + + /** + * Get list of pending migrations to skip. + * + * @return list + */ + protected function migrationsToSkip() + { + return (new Collection(self::$withoutMigrations)) + ->map($this->getMigrationName(...)) + ->all(); } /** * Run an array of migrations. * - * @param array $migrations - * @param array $options + * @param string[] $migrations + * @param array $options * @return void */ public function runPending(array $migrations, array $options = []) @@ -126,7 +184,9 @@ public function runPending(array $migrations, array $options = []) // aren't, we will just make a note of it to the developer so they're aware // that all of the migrations have been run against this database system. if (count($migrations) === 0) { - $this->note('Nothing to migrate.'); + $this->fireMigrationEvent(new NoPendingMigrations('up')); + + $this->write(Info::class, 'Nothing to migrate'); return; } @@ -140,6 +200,10 @@ public function runPending(array $migrations, array $options = []) $step = $options['step'] ?? false; + $this->fireMigrationEvent(new MigrationsStarted('up', $options)); + + $this->write(Info::class, 'Running migrations.'); + // Once we have the array of migrations, we will spin through them and run the // migrations "up" so the changes are made to the databases. We'll then log // that the migration was run so we don't repeat it next time we execute. @@ -150,14 +214,18 @@ public function runPending(array $migrations, array $options = []) $batch++; } } + + $this->fireMigrationEvent(new MigrationsEnded('up', $options)); + + $this->output?->writeln(''); } /** * Run "up" a migration instance. * * @param string $file - * @param int $batch - * @param bool $pretend + * @param int $batch + * @param bool $pretend * @return void */ protected function runUp($file, $batch, $pretend) @@ -165,56 +233,64 @@ protected function runUp($file, $batch, $pretend) // First we will resolve a "real" instance of the migration class from this // migration file name. Once we have the instances we can run the actual // command such as "up" or "down", or we can just simulate the action. - $migration = $this->resolve( - $name = $this->getMigrationName($file) - ); + $migration = $this->resolvePath($file); + + $name = $this->getMigrationName($file); if ($pretend) { return $this->pretendToRun($migration, 'up'); } - $this->note("Migrating: {$name}"); + $shouldRunMigration = $migration instanceof Migration + ? $migration->shouldRun() + : true; - $this->runMigration($migration, 'up'); + if (! $shouldRunMigration) { + $this->fireMigrationEvent(new MigrationSkipped($name)); - // Once we have run a migrations class, we will log that it was run in this - // repository so that we don't try to run it next time we do a migration - // in the application. A migration repository keeps the migrate order. - $this->repository->log($name, $batch); + $this->write(Task::class, $name, fn () => MigrationResult::Skipped->value); + } else { + $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); - $this->note("Migrated: {$name}"); + // Once we have run a migrations class, we will log that it was run in this + // repository so that we don't try to run it next time we do a migration + // in the application. A migration repository keeps the migrate order. + $this->repository->log($name, $batch); + } } /** * Rollback the last migration operation. * - * @param array|string $paths - * @param array $options - * @return array + * @param string[]|string $paths + * @param array $options + * @return string[] */ public function rollback($paths = [], array $options = []) { - $this->notes = []; - // We want to pull in the last batch of migrations that ran on the previous // migration operation. We'll then reverse those migrations and run each // of them "down" to reverse the last migration "operation" which ran. $migrations = $this->getMigrationsForRollback($options); if (count($migrations) === 0) { - $this->note('Nothing to rollback.'); + $this->fireMigrationEvent(new NoPendingMigrations('down')); + + $this->write(Info::class, 'Nothing to rollback.'); return []; } - return $this->rollbackMigrations($migrations, $paths, $options); + return tap($this->rollbackMigrations($migrations, $paths, $options), function () { + $this->output?->writeln(''); + }); } /** * Get the migrations for a rollback operation. * - * @param array $options - * @return array + * @param array $options + * @return array{id: int, migration: string, batch: int}[] */ protected function getMigrationsForRollback(array $options) { @@ -222,6 +298,10 @@ protected function getMigrationsForRollback(array $options) return $this->repository->getMigrations($steps); } + if (($batch = $options['batch'] ?? 0) > 0) { + return $this->repository->getMigrationsByBatch($batch); + } + return $this->repository->getLast(); } @@ -229,9 +309,9 @@ protected function getMigrationsForRollback(array $options) * Rollback the given migrations. * * @param array $migrations - * @param array|string $paths - * @param array $options - * @return array + * @param string[]|string $paths + * @param array $options + * @return string[] */ protected function rollbackMigrations(array $migrations, $paths, array $options) { @@ -239,6 +319,10 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) $this->requireFiles($files = $this->getMigrationFiles($paths)); + $this->fireMigrationEvent(new MigrationsStarted('down', $options)); + + $this->write(Info::class, 'Rolling back migrations.'); + // Next we will run through all of the migrations and call the "down" method // which will reverse each migration in order. This getLast method on the // repository already returns these migration's names in reverse order. @@ -246,7 +330,7 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) $migration = (object) $migration; if (! $file = Arr::get($files, $migration->migration)) { - $this->note("Migration not found: {$migration->migration}"); + $this->write(TwoColumnDetail::class, $migration->migration, 'Migration not found'); continue; } @@ -259,39 +343,41 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) ); } + $this->fireMigrationEvent(new MigrationsEnded('down', $options)); + return $rolledBack; } /** * Rolls all of the currently applied migrations back. * - * @param array|string $paths + * @param string[]|string $paths * @param bool $pretend * @return array */ public function reset($paths = [], $pretend = false) { - $this->notes = []; - // Next, we will reverse the migration list so we can run them back in the // correct order for resetting this database. This will allow us to get // the database back into its "empty" state ready for the migrations. $migrations = array_reverse($this->repository->getRan()); if (count($migrations) === 0) { - $this->note('Nothing to rollback.'); + $this->write(Info::class, 'Nothing to rollback.'); return []; } - return $this->resetMigrations($migrations, $paths, $pretend); + return tap($this->resetMigrations($migrations, Arr::wrap($paths), $pretend), function () { + $this->output?->writeln(''); + }); } /** * Reset the given migrations. * - * @param array $migrations - * @param array $paths + * @param string[] $migrations + * @param string[] $paths * @param bool $pretend * @return array */ @@ -300,9 +386,7 @@ protected function resetMigrations(array $migrations, array $paths, $pretend = f // Since the getRan method that retrieves the migration name just gives us the // migration name, we will format the names into objects with the name as a // property on the objects so that we can pass it to the rollback method. - $migrations = collect($migrations)->map(function ($m) { - return (object) ['migration' => $m]; - })->all(); + $migrations = (new Collection($migrations))->map(fn ($m) => (object) ['migration' => $m])->all(); return $this->rollbackMigrations( $migrations, $paths, compact('pretend') @@ -314,7 +398,7 @@ protected function resetMigrations(array $migrations, array $paths, $pretend = f * * @param string $file * @param object $migration - * @param bool $pretend + * @param bool $pretend * @return void */ protected function runDown($file, $migration, $pretend) @@ -322,24 +406,20 @@ protected function runDown($file, $migration, $pretend) // First we will get the file name of the migration so we can resolve out an // instance of the migration. Once we get an instance we can either run a // pretend execution of the migration or we can run the real migration. - $instance = $this->resolve( - $name = $this->getMigrationName($file) - ); + $instance = $this->resolvePath($file); - $this->note("Rolling back: {$name}"); + $name = $this->getMigrationName($file); if ($pretend) { return $this->pretendToRun($instance, 'down'); } - $this->runMigration($instance, 'down'); + $this->write(Task::class, $name, fn () => $this->runMigration($instance, 'down')); // Once we have successfully run the migration "down" we will remove it from // the migration repository so it will be considered to have not been run // by the application then will be able to fire by any later operation. $this->repository->delete($migration); - - $this->note("Rolled back: {$name}"); } /** @@ -355,16 +435,20 @@ protected function runMigration($migration, $method) $migration->getConnection() ); - $callback = function () use ($migration, $method) { + $callback = function () use ($connection, $migration, $method) { if (method_exists($migration, $method)) { - $migration->{$method}(); + $this->fireMigrationEvent(new MigrationStarted($migration, $method)); + + $this->runMethod($connection, $migration, $method); + + $this->fireMigrationEvent(new MigrationEnded($migration, $method)); } }; $this->getSchemaGrammar($connection)->supportsSchemaTransactions() && $migration->withinTransaction - ? $connection->transaction($callback) - : $callback(); + ? $connection->transaction($callback) + : $callback(); } /** @@ -376,11 +460,20 @@ protected function runMigration($migration, $method) */ protected function pretendToRun($migration, $method) { - foreach ($this->getQueries($migration, $method) as $query) { - $name = get_class($migration); + $name = get_class($migration); - $this->note("{$name}: {$query['query']}"); + $reflectionClass = new ReflectionClass($migration); + + if ($reflectionClass->isAnonymous()) { + $name = $this->getMigrationName($reflectionClass->getFileName()); } + + $this->write(TwoColumnDetail::class, $name); + + $this->write( + BulletList::class, + (new Collection($this->getQueries($migration, $method)))->map(fn ($query) => $query['query']) + ); } /** @@ -399,13 +492,34 @@ protected function getQueries($migration, $method) $migration->getConnection() ); - return $db->pretend(function () use ($migration, $method) { + return $db->pretend(function () use ($db, $migration, $method) { if (method_exists($migration, $method)) { - $migration->{$method}(); + $this->runMethod($db, $migration, $method); } }); } + /** + * Run a migration method on the given connection. + * + * @param \Illuminate\Database\Connection $connection + * @param object $migration + * @param string $method + * @return void + */ + protected function runMethod($connection, $migration, $method) + { + $previousConnection = $this->resolver->getDefaultConnection(); + + try { + $this->resolver->setDefaultConnection($connection->getName()); + + $migration->{$method}(); + } finally { + $this->resolver->setDefaultConnection($previousConnection); + } + } + /** * Resolve a migration instance from a file. * @@ -414,32 +528,68 @@ protected function getQueries($migration, $method) */ public function resolve($file) { - $class = Str::studly(implode('_', array_slice(explode('_', $file), 4))); + $class = $this->getMigrationClass($file); return new $class; } + /** + * Resolve a migration instance from a migration path. + * + * @param string $path + * @return object + */ + protected function resolvePath(string $path) + { + $class = $this->getMigrationClass($this->getMigrationName($path)); + + if (class_exists($class) && realpath($path) == (new ReflectionClass($class))->getFileName()) { + return new $class; + } + + $migration = static::$requiredPathCache[$path] ??= $this->files->getRequire($path); + + if (is_object($migration)) { + return method_exists($migration, '__construct') + ? $this->files->getRequire($path) + : clone $migration; + } + + return new $class; + } + + /** + * Generate a migration class name based on the migration file name. + * + * @param string $migrationName + * @return string + */ + protected function getMigrationClass(string $migrationName): string + { + return Str::studly(implode('_', array_slice(explode('_', $migrationName), 4))); + } + /** * Get all of the migration files in a given path. * * @param string|array $paths - * @return array + * @return array */ public function getMigrationFiles($paths) { - return Collection::make($paths)->flatMap(function ($path) { - return Str::endsWith($path, '.php') ? [$path] : $this->files->glob($path.'/*_*.php'); - })->filter()->sortBy(function ($file) { - return $this->getMigrationName($file); - })->values()->keyBy(function ($file) { - return $this->getMigrationName($file); - })->all(); + return (new Collection($paths)) + ->flatMap(fn ($path) => str_ends_with($path, '.php') ? [$path] : $this->files->glob($path.'/*_*.php')) + ->filter() + ->values() + ->keyBy(fn ($file) => $this->getMigrationName($file)) + ->sortBy(fn ($file, $key) => $key) + ->all(); } /** * Require in all the migration files in a given path. * - * @param array $files + * @param string[] $files * @return void */ public function requireFiles(array $files) @@ -474,13 +624,24 @@ public function path($path) /** * Get all of the custom migration paths. * - * @return array + * @return string[] */ public function paths() { return $this->paths; } + /** + * Set the pending migrations to skip. + * + * @param list $migrations + * @return void + */ + public static function withoutMigrations(array $migrations) + { + static::$withoutMigrations = $migrations; + } + /** * Get the default connection name. * @@ -491,6 +652,28 @@ public function getConnection() return $this->connection; } + /** + * Execute the given callback using the given connection as the default connection. + * + * @template TReturn + * + * @param string $name + * @param (callable(): TReturn) $callback + * @return mixed + */ + public function usingConnection($name, callable $callback) + { + $previousConnection = $this->resolver->getDefaultConnection(); + + $this->setConnection($name); + + try { + return $callback(); + } finally { + $this->setConnection($previousConnection); + } + } + /** * Set the default connection name. * @@ -516,7 +699,26 @@ public function setConnection($name) */ public function resolveConnection($connection) { - return $this->resolver->connection($connection ?: $this->connection); + if (static::$connectionResolverCallback) { + return call_user_func( + static::$connectionResolverCallback, + $this->resolver, + $connection ?: $this->connection + ); + } else { + return $this->resolver->connection($connection ?: $this->connection); + } + } + + /** + * Set a connection resolver callback. + * + * @param \Closure(\Illuminate\Database\ConnectionResolverInterface, ?string): \Illuminate\Database\Connection $callback + * @return void + */ + public static function resolveConnectionsUsing(Closure $callback) + { + static::$connectionResolverCallback = $callback; } /** @@ -556,6 +758,26 @@ public function repositoryExists() return $this->repository->repositoryExists(); } + /** + * Determine if any migrations have been run. + * + * @return bool + */ + public function hasRunAnyMigrations() + { + return $this->repositoryExists() && count($this->repository->getRan()) > 0; + } + + /** + * Delete the migration repository data store. + * + * @return void + */ + public function deleteRepository() + { + $this->repository->deleteRepository(); + } + /** * Get the file system instance. * @@ -569,10 +791,10 @@ public function getFilesystem() /** * Set the output implementation that should be used by the console. * - * @param \Illuminate\Console\OutputStyle $output + * @param \Symfony\Component\Console\Output\OutputInterface $output * @return $this */ - public function setOutput(OutputStyle $output) + public function setOutput(OutputInterface $output) { $this->output = $output; @@ -580,15 +802,33 @@ public function setOutput(OutputStyle $output) } /** - * Write a note to the conosle's output. + * Write to the console's output. * - * @param string $message + * @param string $component + * @param array|string ...$arguments * @return void */ - protected function note($message) + protected function write($component, ...$arguments) { - if ($this->output) { - $this->output->writeln($message); + if ($this->output && class_exists($component)) { + (new $component($this->output))->render(...$arguments); + } else { + foreach ($arguments as $argument) { + if (is_callable($argument)) { + $argument(); + } + } } } + + /** + * Fire the given event for the migration. + * + * @param \Illuminate\Contracts\Database\Events\MigrationEvent $event + * @return void + */ + public function fireMigrationEvent($event) + { + $this->events?->dispatch($event); + } } diff --git a/Migrations/stubs/create.stub b/Migrations/stubs/migration.create.stub similarity index 50% rename from Migrations/stubs/create.stub rename to Migrations/stubs/migration.create.stub index 08e171bc55..168c622236 100755 --- a/Migrations/stubs/create.stub +++ b/Migrations/stubs/migration.create.stub @@ -1,31 +1,27 @@ bigIncrements('id'); + Schema::create('{{ table }}', function (Blueprint $table) { + $table->id(); $table->timestamps(); }); } /** * Reverse the migrations. - * - * @return void */ - public function down() + public function down(): void { - Schema::dropIfExists('DummyTable'); + Schema::dropIfExists('{{ table }}'); } -} +}; diff --git a/Migrations/stubs/blank.stub b/Migrations/stubs/migration.stub similarity index 65% rename from Migrations/stubs/blank.stub rename to Migrations/stubs/migration.stub index da4ce827a2..88fa2f36b0 100755 --- a/Migrations/stubs/blank.stub +++ b/Migrations/stubs/migration.stub @@ -1,28 +1,24 @@ count = $count; + + parent::__construct("$count records were found.", $code, $previous); + } + + /** + * Get the number of records found. + * + * @return int + */ + public function getCount() + { + return $this->count; + } +} diff --git a/MySqlConnection.php b/MySqlConnection.php index 1e7fff2f53..ab541c9aa5 100755 --- a/MySqlConnection.php +++ b/MySqlConnection.php @@ -2,15 +2,118 @@ namespace Illuminate\Database; -use PDO; -use Illuminate\Database\Schema\MySqlBuilder; -use Illuminate\Database\Query\Processors\MySqlProcessor; -use Doctrine\DBAL\Driver\PDOMySql\Driver as DoctrineDriver; +use Exception; use Illuminate\Database\Query\Grammars\MySqlGrammar as QueryGrammar; +use Illuminate\Database\Query\Processors\MySqlProcessor; use Illuminate\Database\Schema\Grammars\MySqlGrammar as SchemaGrammar; +use Illuminate\Database\Schema\MySqlBuilder; +use Illuminate\Database\Schema\MySqlSchemaState; +use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Str; +use PDO; class MySqlConnection extends Connection { + /** + * The last inserted ID generated by the server. + * + * @var string|int|null + */ + protected $lastInsertId; + + /** + * {@inheritdoc} + */ + public function getDriverTitle() + { + return $this->isMaria() ? 'MariaDB' : 'MySQL'; + } + + /** + * Run an insert statement against the database. + * + * @param string $query + * @param array $bindings + * @param string|null $sequence + * @return bool + */ + public function insert($query, $bindings = [], $sequence = null) + { + return $this->run($query, $bindings, function ($query, $bindings) use ($sequence) { + if ($this->pretending()) { + return true; + } + + $statement = $this->getPdo()->prepare($query); + + $this->bindValues($statement, $this->prepareBindings($bindings)); + + $this->recordsHaveBeenModified(); + + $result = $statement->execute(); + + $this->lastInsertId = $this->getPdo()->lastInsertId($sequence); + + return $result; + }); + } + + /** + * Escape a binary value for safe SQL embedding. + * + * @param string $value + * @return string + */ + protected function escapeBinary($value) + { + $hex = bin2hex($value); + + return "x'{$hex}'"; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return (bool) preg_match('#Integrity constraint violation: 1062#i', $exception->getMessage()); + } + + /** + * Get the connection's last insert ID. + * + * @return string|int|null + */ + public function getLastInsertId() + { + return $this->lastInsertId; + } + + /** + * Determine if the connected database is a MariaDB database. + * + * @return bool + */ + public function isMaria() + { + return str_contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB'); + } + + /** + * Get the server version for the connection. + * + * @return string + */ + public function getServerVersion(): string + { + return str_contains($version = parent::getServerVersion(), 'MariaDB') + ? Str::between($version, '5.5.5-', '-MariaDB') + : $version; + } + /** * Get the default query grammar instance. * @@ -18,7 +121,7 @@ class MySqlConnection extends Connection */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -42,43 +145,28 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); - } - - /** - * Get the default post processor instance. - * - * @return \Illuminate\Database\Query\Processors\MySqlProcessor - */ - protected function getDefaultPostProcessor() - { - return new MySqlProcessor; + return new SchemaGrammar($this); } /** - * Get the Doctrine DBAL driver. + * Get the schema state for the connection. * - * @return \Doctrine\DBAL\Driver\PDOMySql\Driver + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return \Illuminate\Database\Schema\MySqlSchemaState */ - protected function getDoctrineDriver() + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { - return new DoctrineDriver; + return new MySqlSchemaState($this, $files, $processFactory); } /** - * Bind values to their parameters in the given statement. + * Get the default post processor instance. * - * @param \PDOStatement $statement - * @param array $bindings - * @return void + * @return \Illuminate\Database\Query\Processors\MySqlProcessor */ - public function bindValues($statement, $bindings) + protected function getDefaultPostProcessor() { - foreach ($bindings as $key => $value) { - $statement->bindValue( - is_string($key) ? $key : $key + 1, $value, - is_int($value) || is_float($value) ? PDO::PARAM_INT : PDO::PARAM_STR - ); - } + return new MySqlProcessor; } } diff --git a/PostgresConnection.php b/PostgresConnection.php index 01804a7b8a..f80b5dce5d 100755 --- a/PostgresConnection.php +++ b/PostgresConnection.php @@ -2,14 +2,59 @@ namespace Illuminate\Database; -use Illuminate\Database\Schema\PostgresBuilder; -use Doctrine\DBAL\Driver\PDOPgSql\Driver as DoctrineDriver; -use Illuminate\Database\Query\Processors\PostgresProcessor; +use Exception; use Illuminate\Database\Query\Grammars\PostgresGrammar as QueryGrammar; +use Illuminate\Database\Query\Processors\PostgresProcessor; use Illuminate\Database\Schema\Grammars\PostgresGrammar as SchemaGrammar; +use Illuminate\Database\Schema\PostgresBuilder; +use Illuminate\Database\Schema\PostgresSchemaState; +use Illuminate\Filesystem\Filesystem; class PostgresConnection extends Connection { + /** + * {@inheritdoc} + */ + public function getDriverTitle() + { + return 'PostgreSQL'; + } + + /** + * Escape a binary value for safe SQL embedding. + * + * @param string $value + * @return string + */ + protected function escapeBinary($value) + { + $hex = bin2hex($value); + + return "'\x{$hex}'::bytea"; + } + + /** + * Escape a bool value for safe SQL embedding. + * + * @param bool $value + * @return string + */ + protected function escapeBool($value) + { + return $value ? 'true' : 'false'; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return '23505' === $exception->getCode(); + } + /** * Get the default query grammar instance. * @@ -17,7 +62,7 @@ class PostgresConnection extends Connection */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -41,26 +86,28 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** - * Get the default post processor instance. + * Get the schema state for the connection. * - * @return \Illuminate\Database\Query\Processors\PostgresProcessor + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * @return \Illuminate\Database\Schema\PostgresSchemaState */ - protected function getDefaultPostProcessor() + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { - return new PostgresProcessor; + return new PostgresSchemaState($this, $files, $processFactory); } /** - * Get the Doctrine DBAL driver. + * Get the default post processor instance. * - * @return \Doctrine\DBAL\Driver\PDOPgSql\Driver + * @return \Illuminate\Database\Query\Processors\PostgresProcessor */ - protected function getDoctrineDriver() + protected function getDefaultPostProcessor() { - return new DoctrineDriver; + return new PostgresProcessor; } } diff --git a/Query/Builder.php b/Query/Builder.php index 54254c4981..442affeddf 100755 --- a/Query/Builder.php +++ b/Query/Builder.php @@ -2,26 +2,41 @@ namespace Illuminate\Database\Query; +use BackedEnum; use Closure; -use RuntimeException; +use DatePeriod; use DateTimeInterface; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; -use InvalidArgumentException; -use Illuminate\Support\Collection; -use Illuminate\Pagination\Paginator; -use Illuminate\Support\Traits\Macroable; +use Illuminate\Contracts\Database\Query\Builder as BuilderContract; +use Illuminate\Contracts\Database\Query\ConditionExpression; +use Illuminate\Contracts\Database\Query\Expression as ExpressionContract; use Illuminate\Contracts\Support\Arrayable; -use Illuminate\Database\ConnectionInterface; -use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Database\Concerns\BuildsQueries; +use Illuminate\Database\Concerns\BuildsWhereDateClauses; +use Illuminate\Database\Concerns\ExplainsQueries; +use Illuminate\Database\ConnectionInterface; +use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Database\PostgresConnection; use Illuminate\Database\Query\Grammars\Grammar; use Illuminate\Database\Query\Processors\Processor; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Pagination\Paginator; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\LazyCollection; +use Illuminate\Support\Str; +use Illuminate\Support\Traits\ForwardsCalls; +use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; +use LogicException; +use RuntimeException; +use UnitEnum; -class Builder +use function Illuminate\Support\enum_value; + +class Builder implements BuilderContract { - use BuildsQueries, ForwardsCalls, Macroable { + /** @use \Illuminate\Database\Concerns\BuildsQueries<\stdClass> */ + use BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { __call as macroCall; } @@ -49,50 +64,74 @@ class Builder /** * The current query value bindings. * - * @var array + * @var array{ + * select: list, + * from: list, + * join: list, + * where: list, + * groupBy: list, + * having: list, + * order: list, + * union: list, + * unionOrder: list, + * } */ public $bindings = [ 'select' => [], - 'from' => [], - 'join' => [], - 'where' => [], + 'from' => [], + 'join' => [], + 'where' => [], + 'groupBy' => [], 'having' => [], - 'order' => [], - 'union' => [], + 'order' => [], + 'union' => [], + 'unionOrder' => [], ]; /** * An aggregate function and column to be run. * - * @var array + * @var array{ + * function: string, + * columns: array<\Illuminate\Contracts\Database\Query\Expression|string> + * }|null */ public $aggregate; /** * The columns that should be returned. * - * @var array + * @var array|null */ public $columns; /** * Indicates if the query returns distinct results. * - * @var bool + * Occasionally contains the columns that should be distinct. + * + * @var bool|array */ public $distinct = false; /** * The table which the query is targeting. * - * @var string + * @var \Illuminate\Database\Query\Expression|string */ public $from; + /** + * The index hint for the query. + * + * @var \Illuminate\Database\Query\IndexHint|null + */ + public $indexHint; + /** * The table joins for the query. * - * @var array + * @var array|null */ public $joins; @@ -106,106 +145,146 @@ class Builder /** * The groupings for the query. * - * @var array + * @var array|null */ public $groups; /** * The having constraints for the query. * - * @var array + * @var array|null */ public $havings; /** * The orderings for the query. * - * @var array + * @var array|null */ public $orders; /** * The maximum number of records to return. * - * @var int + * @var int|null */ public $limit; + /** + * The maximum number of records to return per group. + * + * @var array|null + */ + public $groupLimit; + /** * The number of records to skip. * - * @var int + * @var int|null */ public $offset; /** * The query union statements. * - * @var array + * @var array|null */ public $unions; /** * The maximum number of union records to return. * - * @var int + * @var int|null */ public $unionLimit; /** * The number of union records to skip. * - * @var int + * @var int|null */ public $unionOffset; /** * The orderings for the union query. * - * @var array + * @var array|null */ public $unionOrders; /** * Indicates whether row locking is being used. * - * @var string|bool + * @var string|bool|null */ public $lock; /** - * All of the available clause operators. + * The query execution timeout in seconds. + * + * @var int|null + */ + public $timeout; + + /** + * The callbacks that should be invoked before the query is executed. + * + * @var array + */ + public $beforeQueryCallbacks = []; + + /** + * The callbacks that should be invoked after retrieving data from the database. * * @var array */ + protected $afterQueryCallbacks = []; + + /** + * All of the available clause operators. + * + * @var string[] + */ public $operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', '<=>', 'like', 'like binary', 'not like', 'ilike', - '&', '|', '^', '<<', '>>', - 'rlike', 'regexp', 'not regexp', + '&', '|', '^', '<<', '>>', '&~', 'is', 'is not', + 'rlike', 'not rlike', 'regexp', 'not regexp', '~', '~*', '!~', '!~*', 'similar to', 'not similar to', 'not ilike', '~~*', '!~~*', ]; /** - * Whether use write pdo for select. + * All of the available bitwise operators. + * + * @var string[] + */ + public $bitwiseOperators = [ + '&', '|', '^', '<<', '>>', '&~', + ]; + + /** + * Whether to use write pdo for the select. * * @var bool */ public $useWritePdo = false; /** - * Create a new query builder instance. + * The custom arguments for the PDOStatement::fetchAll / fetch functions. * - * @param \Illuminate\Database\ConnectionInterface $connection - * @param \Illuminate\Database\Query\Grammars\Grammar $grammar - * @param \Illuminate\Database\Query\Processors\Processor $processor - * @return void + * @var array */ - public function __construct(ConnectionInterface $connection, - Grammar $grammar = null, - Processor $processor = null) - { + public array $fetchUsing = []; + + /** + * Create a new query builder instance. + */ + public function __construct( + ConnectionInterface $connection, + ?Grammar $grammar = null, + ?Processor $processor = null, + ) { $this->connection = $connection; $this->grammar = $grammar ?: $connection->getQueryGrammar(); $this->processor = $processor ?: $connection->getPostProcessor(); @@ -214,12 +293,23 @@ public function __construct(ConnectionInterface $connection, /** * Set the columns to be selected. * - * @param array|mixed $columns + * @param mixed $columns * @return $this */ public function select($columns = ['*']) { - $this->columns = is_array($columns) ? $columns : func_get_args(); + $this->columns = []; + $this->bindings['select'] = []; + + $columns = is_array($columns) ? $columns : func_get_args(); + + foreach ($columns as $as => $column) { + if (is_string($as) && $this->isQueryable($column)) { + $this->selectSub($column, $as); + } else { + $this->columns[] = $column; + } + } return $this; } @@ -227,9 +317,9 @@ public function select($columns = ['*']) /** * Add a subselect expression to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as - * @return \Illuminate\Database\Query\Builder|static + * @return $this * * @throws \InvalidArgumentException */ @@ -242,12 +332,25 @@ public function selectSub($query, $as) ); } + /** + * Add a select expression to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $expression + * @param string $as + * @return $this + */ + public function selectExpression($expression, $as) + { + return $this->selectRaw( + '('.$this->grammar->getValue($expression).') as '.$this->grammar->wrap($as) + ); + } + /** * Add a new "raw" select expression to the query. * * @param string $expression - * @param array $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function selectRaw($expression, array $bindings = []) { @@ -263,9 +366,9 @@ public function selectRaw($expression, array $bindings = []) /** * Makes "from" fetch from a subquery. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as - * @return \Illuminate\Database\Query\Builder|static + * @return $this * * @throws \InvalidArgumentException */ @@ -273,15 +376,15 @@ public function fromSub($query, $as) { [$query, $bindings] = $this->createSub($query); - return $this->fromRaw('('.$query.') as '.$this->grammar->wrap($as), $bindings); + return $this->fromRaw('('.$query.') as '.$this->grammar->wrapTable($as), $bindings); } /** - * Add a raw from clause to the query. + * Add a raw "from" clause to the query. * * @param string $expression - * @param mixed $bindings - * @return \Illuminate\Database\Query\Builder|static + * @param mixed $bindings + * @return $this */ public function fromRaw($expression, $bindings = []) { @@ -295,7 +398,7 @@ public function fromRaw($expression, $bindings = []) /** * Creates a subquery and parse it. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @return array */ protected function createSub($query) @@ -317,33 +420,106 @@ protected function createSub($query) * * @param mixed $query * @return array + * + * @throws \InvalidArgumentException */ protected function parseSub($query) { - if ($query instanceof self || $query instanceof EloquentBuilder) { + if ($query instanceof self || $query instanceof EloquentBuilder || $query instanceof Relation) { + $query = $this->prependDatabaseNameIfCrossDatabaseQuery($query); + return [$query->toSql(), $query->getBindings()]; } elseif (is_string($query)) { return [$query, []]; } else { - throw new InvalidArgumentException; + throw new InvalidArgumentException( + 'A subquery must be a query builder instance, a Closure, or a string.' + ); + } + } + + /** + * Prepend the database name if the given query is on another database. + * + * @param mixed $query + * @return mixed + */ + protected function prependDatabaseNameIfCrossDatabaseQuery($query) + { + if ($query->getConnection()->getDatabaseName() !== + $this->getConnection()->getDatabaseName()) { + $databaseName = $query->getConnection()->getDatabaseName(); + + if (! str_starts_with($query->from, $databaseName) && ! str_contains($query->from, '.')) { + $query->from($databaseName.'.'.$query->from); + } } + + return $query; } /** * Add a new select column to the query. * - * @param array|mixed $column + * @param mixed $column * @return $this */ public function addSelect($column) { - $column = is_array($column) ? $column : func_get_args(); + $columns = is_array($column) ? $column : func_get_args(); + + foreach ($columns as $as => $column) { + if (is_string($as) && $this->isQueryable($column)) { + if (is_null($this->columns)) { + $this->select($this->from.'.*'); + } - $this->columns = array_merge((array) $this->columns, $column); + $this->selectSub($column, $as); + } else { + if (is_array($this->columns) && in_array($column, $this->columns, true)) { + continue; + } + + $this->columns[] = $column; + } + } return $this; } + /** + * Add a vector-similarity selection to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Support\Arrayable|array|string $vector + * @param string|null $as + * @return $this + */ + public function selectVectorDistance($column, $vector, $as = null) + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->addBinding( + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + 'select', + ); + + $as = $this->getGrammar()->wrap($as ?? $column.'_distance'); + + return $this->addSelect( + new Expression("({$this->getGrammar()->wrap($column)} <=> ?) as {$as}") + ); + } + /** * Force the query to only return distinct results. * @@ -351,7 +527,13 @@ public function addSelect($column) */ public function distinct() { - $this->distinct = true; + $columns = func_get_args(); + + if (count($columns) > 0) { + $this->distinct = is_array($columns[0]) || is_bool($columns[0]) ? $columns[0] : $columns; + } else { + $this->distinct = true; + } return $this; } @@ -359,25 +541,69 @@ public function distinct() /** * Set the table which the query is targeting. * - * @param string $table + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $table + * @param string|null $as * @return $this */ - public function from($table) + public function from($table, $as = null) { - $this->from = $table; + if ($this->isQueryable($table)) { + return $this->fromSub($table, $as); + } + + $this->from = $as ? "{$table} as {$as}" : $table; return $this; } /** - * Add a join clause to the query. + * Add an index hint to suggest a query index. * - * @param string $table - * @param \Closure|string $first + * @param string $index + * @return $this + */ + public function useIndex($index) + { + $this->indexHint = new IndexHint('hint', $index); + + return $this; + } + + /** + * Add an index hint to force a query index. + * + * @param string $index + * @return $this + */ + public function forceIndex($index) + { + $this->indexHint = new IndexHint('force', $index); + + return $this; + } + + /** + * Add an index hint to ignore a query index. + * + * @param string $index + * @return $this + */ + public function ignoreIndex($index) + { + $this->indexHint = new IndexHint('ignore', $index); + + return $this; + } + + /** + * Add a "join" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @param string $type - * @param bool $where + * @param bool $where * @return $this */ public function join($table, $first, $operator = null, $second = null, $type = 'inner', $where = false) @@ -388,7 +614,7 @@ public function join($table, $first, $operator = null, $second = null, $type = ' // is trying to build a join with a complex "on" clause containing more than // one condition, so we'll add the join and call a Closure with the query. if ($first instanceof Closure) { - call_user_func($first, $join); + $first($join); $this->joins[] = $join; @@ -412,12 +638,12 @@ public function join($table, $first, $operator = null, $second = null, $type = ' /** * Add a "join where" clause to the query. * - * @param string $table - * @param \Closure|string $first + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string $operator - * @param string $second + * @param \Illuminate\Contracts\Database\Query\Expression|string $second * @param string $type - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function joinWhere($table, $first, $operator, $second, $type = 'inner') { @@ -425,16 +651,16 @@ public function joinWhere($table, $first, $operator, $second, $type = 'inner') } /** - * Add a subquery join clause to the query. + * Add a "subquery join" clause to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as - * @param \Closure|string $first + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @param string $type - * @param bool $where - * @return \Illuminate\Database\Query\Builder|static + * @param bool $where + * @return $this * * @throws \InvalidArgumentException */ @@ -442,21 +668,51 @@ public function joinSub($query, $as, $first, $operator = null, $second = null, $ { [$query, $bindings] = $this->createSub($query); - $expression = '('.$query.') as '.$this->grammar->wrap($as); + $expression = '('.$query.') as '.$this->grammar->wrapTable($as); $this->addBinding($bindings, 'join'); return $this->join(new Expression($expression), $first, $operator, $second, $type, $where); } + /** + * Add a "lateral join" clause to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @return $this + */ + public function joinLateral($query, string $as, string $type = 'inner') + { + [$query, $bindings] = $this->createSub($query); + + $expression = '('.$query.') as '.$this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + $this->joins[] = $this->newJoinLateralClause($this, $type, new Expression($expression)); + + return $this; + } + + /** + * Add a lateral left join to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @return $this + */ + public function leftJoinLateral($query, string $as) + { + return $this->joinLateral($query, $as, 'left'); + } + /** * Add a left join to the query. * - * @param string $table - * @param \Closure|string $first + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function leftJoin($table, $first, $operator = null, $second = null) { @@ -466,11 +722,11 @@ public function leftJoin($table, $first, $operator = null, $second = null) /** * Add a "join where" clause to the query. * - * @param string $table - * @param \Closure|string $first + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string $operator - * @param string $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function leftJoinWhere($table, $first, $operator, $second) { @@ -480,12 +736,12 @@ public function leftJoinWhere($table, $first, $operator, $second) /** * Add a subquery left join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as - * @param \Closure|string $first + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function leftJoinSub($query, $as, $first, $operator = null, $second = null) { @@ -495,11 +751,11 @@ public function leftJoinSub($query, $as, $first, $operator = null, $second = nul /** * Add a right join to the query. * - * @param string $table + * @param \Illuminate\Contracts\Database\Query\Expression|string $table * @param \Closure|string $first * @param string|null $operator - * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function rightJoin($table, $first, $operator = null, $second = null) { @@ -509,11 +765,11 @@ public function rightJoin($table, $first, $operator = null, $second = null) /** * Add a "right join where" clause to the query. * - * @param string $table - * @param \Closure|string $first + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string $operator - * @param string $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $second + * @return $this */ public function rightJoinWhere($table, $first, $operator, $second) { @@ -523,12 +779,12 @@ public function rightJoinWhere($table, $first, $operator, $second) /** * Add a subquery right join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as - * @param \Closure|string $first + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function rightJoinSub($query, $as, $first, $operator = null, $second = null) { @@ -538,11 +794,11 @@ public function rightJoinSub($query, $as, $first, $operator = null, $second = nu /** * Add a "cross join" clause to the query. * - * @param string $table - * @param \Closure|string|null $first + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string|null $first * @param string|null $operator - * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this */ public function crossJoin($table, $first = null, $operator = null, $second = null) { @@ -556,11 +812,73 @@ public function crossJoin($table, $first = null, $operator = null, $second = nul } /** - * Get a new join clause. + * Add a subquery cross join to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @param string $as + * @return $this + */ + public function crossJoinSub($query, $as) + { + [$query, $bindings] = $this->createSub($query); + + $expression = '('.$query.') as '.$this->grammar->wrapTable($as); + + $this->addBinding($bindings, 'join'); + + $this->joins[] = $this->newJoinClause($this, 'cross', new Expression($expression)); + + return $this; + } + + /** + * Add a straight join to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|string $first + * @param string|null $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this + */ + public function straightJoin($table, $first, $operator = null, $second = null) + { + return $this->join($table, $first, $operator, $second, 'straight_join'); + } + + /** + * Add a "straight join where" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first + * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $second + * @return $this + */ + public function straightJoinWhere($table, $first, $operator, $second) + { + return $this->joinWhere($table, $first, $operator, $second, 'straight_join'); + } + + /** + * Add a subquery straight join to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @param string $as + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first + * @param string|null $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second + * @return $this + */ + public function straightJoinSub($query, $as, $first, $operator = null, $second = null) + { + return $this->joinSub($query, $as, $first, $operator, $second, 'straight_join'); + } + + /** + * Get a new "join" clause. * - * @param \Illuminate\Database\Query\Builder $parentQuery * @param string $type - * @param string $table + * @param \Illuminate\Contracts\Database\Query\Expression|string $table * @return \Illuminate\Database\Query\JoinClause */ protected function newJoinClause(self $parentQuery, $type, $table) @@ -569,11 +887,23 @@ protected function newJoinClause(self $parentQuery, $type, $table) } /** - * Merge an array of where clauses and bindings. + * Get a new "join lateral" clause. + * + * @param string $type + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @return \Illuminate\Database\Query\JoinLateralClause + */ + protected function newJoinLateralClause(self $parentQuery, $type, $table) + { + return new JoinLateralClause($parentQuery, $type, $table); + } + + /** + * Merge an array of "where" clauses and bindings. * * @param array $wheres * @param array $bindings - * @return void + * @return $this */ public function mergeWheres($wheres, $bindings) { @@ -582,22 +912,32 @@ public function mergeWheres($wheres, $bindings) $this->bindings['where'] = array_values( array_merge($this->bindings['where'], (array) $bindings) ); + + return $this; } /** - * Add a basic where clause to the query. + * Add a basic "where" clause to the query. * - * @param string|array|\Closure $column - * @param mixed $operator - * @param mixed $value + * @param \Closure|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 the column is an array, we will assume it is an array of key-value pairs - // and can add them each as a where clause. We will maintain the boolean we - // received when the method was called and pass it into the nested where. + if ($column instanceof ConditionExpression) { + $type = 'Expression'; + + $this->wheres[] = compact('type', 'column', 'boolean'); + + return $this; + } + + // If the column is an array, we will assume it is an array of key-value pairs + // and can add them each as a where clause. We will maintain the boolean we + // received when the method was called and pass it into the nested where. if (is_array($column)) { return $this->addArrayOfWheres($column, $boolean); } @@ -609,13 +949,23 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' $value, $operator, func_num_args() === 2 ); - // If the columns is actually a Closure instance, we will assume the developer - // wants to begin a nested where statement which is wrapped in parenthesis. - // We'll add that Closure to the query then return back out immediately. - if ($column instanceof Closure) { + // If the column is actually a Closure instance, we will assume the developer + // wants to begin a nested where statement which is wrapped in parentheses. + // We will add that Closure to the query and return back out immediately. + if ($column instanceof Closure && is_null($operator)) { return $this->whereNested($column, $boolean); } + // If the column is a Closure instance and there is an operator value, we will + // assume the developer wants to run a subquery and then compare the result + // of that subquery with the given value that was provided to the method. + if ($this->isQueryable($column) && ! is_null($operator)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->where(new Expression('('.$sub.')'), $operator, $value, $boolean); + } + // If the given operator is not found in the list of valid operators we will // assume that the developer is just short-cutting the '=' operators and // we will set the operators to '=' and set the values appropriately. @@ -626,7 +976,7 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' // If the value is a Closure, it means the developer is performing an entire // sub-select within the query and we will need to compile the sub-select // within the where clause to get the appropriate query record results. - if ($value instanceof Closure) { + if ($this->isQueryable($value)) { return $this->whereSub($column, $operator, $value, $boolean); } @@ -634,34 +984,46 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' // where null clause to the query. So, we will allow a short-cut here to // that method for convenience so the developer doesn't have to check. if (is_null($value)) { - return $this->whereNull($column, $boolean, $operator !== '='); + return $this->whereNull($column, $boolean, ! in_array($operator, ['=', '<=>'], true)); } + $type = 'Basic'; + + $columnString = ($column instanceof ExpressionContract) + ? $this->grammar->getValue($column) + : $column; + // If the column is making a JSON reference we'll check to see if the value // is a boolean. If it is, we'll add the raw boolean string as an actual // value to the query to ensure this is properly handled by the query. - if (Str::contains($column, '->') && is_bool($value)) { + if (str_contains($columnString, '->') && is_bool($value)) { $value = new Expression($value ? 'true' : 'false'); + + if (is_string($column)) { + $type = 'JsonBoolean'; + } + } + + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; } // Now that we are working with just a simple query we can put the elements // in our array and add the query binding to our array of bindings that // will be bound to each SQL statements when it is finally executed. - $type = 'Basic'; - $this->wheres[] = compact( 'type', 'column', 'operator', 'value', 'boolean' ); - if (! $value instanceof Expression) { - $this->addBinding($value, 'where'); + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'where'); } return $this; } /** - * Add an array of where clauses to the query. + * Add an array of "where" clauses to the query. * * @param array $column * @param string $boolean @@ -673,9 +1035,9 @@ protected function addArrayOfWheres($column, $boolean, $method = 'where') return $this->whereNested(function ($query) use ($column, $method, $boolean) { foreach ($column as $key => $value) { if (is_numeric($key) && is_array($value)) { - $query->{$method}(...array_values($value)); + $query->{$method}(...array_values($value), boolean: $boolean); } else { - $query->$method($key, '=', $value, $boolean); + $query->{$method}($key, '=', $value, $boolean); } } }, $boolean); @@ -714,7 +1076,7 @@ public function prepareValueAndOperator($value, $operator, $useDefault = false) protected function invalidOperatorAndValue($operator, $value) { return is_null($value) && in_array($operator, $this->operators) && - ! in_array($operator, ['=', '<>', '!=']); + ! in_array($operator, ['=', '<=>', '<>', '!=']); } /** @@ -725,17 +1087,29 @@ protected function invalidOperatorAndValue($operator, $value) */ protected function invalidOperator($operator) { - return ! in_array(strtolower($operator), $this->operators, true) && - ! in_array(strtolower($operator), $this->grammar->getOperators(), true); + return ! is_string($operator) || (! in_array(strtolower($operator), $this->operators, true) && + ! in_array(strtolower($operator), $this->grammar->getOperators(), true)); + } + + /** + * Determine if the operator is a bitwise operator. + * + * @param string $operator + * @return bool + */ + protected function isBitwiseOperator($operator) + { + return in_array(strtolower($operator), $this->bitwiseOperators, true) || + in_array(strtolower($operator), $this->grammar->getBitwiseOperators(), true); } /** * Add an "or where" clause to the query. * - * @param string|array|\Closure $column + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhere($column, $operator = null, $value = null) { @@ -746,14 +1120,47 @@ 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|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') + { + if (is_array($column)) { + return $this->whereNested(function ($query) use ($column, $operator, $value, $boolean) { + $query->where($column, $operator, $value, $boolean); + }, $boolean.' not'); + } + + return $this->where($column, $operator, $value, $boolean.' not'); + } + + /** + * Add an "or where not" clause to the query. + * + * @param \Closure|string|array|\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 a "where" clause comparing two columns to the query. * - * @param string|array $first + * @param \Illuminate\Contracts\Database\Query\Expression|string|array $first * @param string|null $operator * @param string|null $second * @param string|null $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') { @@ -786,10 +1193,10 @@ public function whereColumn($first, $operator = null, $second = null, $boolean = /** * Add an "or where" clause comparing two columns to the query. * - * @param string|array $first + * @param \Illuminate\Contracts\Database\Query\Expression|string|array $first * @param string|null $operator * @param string|null $second - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orWhereColumn($first, $operator = null, $second = null) { @@ -797,10 +1204,79 @@ public function orWhereColumn($first, $operator = null, $second = null) } /** - * Add a raw where clause to the query. + * Add a vector similarity clause to the query, filtering by minimum similarity and ordering by similarity. * - * @param string $sql - * @param mixed $bindings + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Support\Arrayable|array|string $vector + * @param float $minSimilarity A value between 0.0 and 1.0, where 1.0 is identical. + * @param bool $order + * @return $this + */ + public function whereVectorSimilarTo($column, $vector, $minSimilarity = 0.6, $order = true) + { + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->whereVectorDistanceLessThan($column, $vector, 1 - $minSimilarity); + + if ($order) { + $this->orderByVectorDistance($column, $vector); + } + + return $this; + } + + /** + * Add a vector distance "where" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Support\Arrayable|array|string $vector + * @param float $maxDistance + * @param string $boolean + * @return $this + */ + public function whereVectorDistanceLessThan($column, $vector, $maxDistance, $boolean = 'and') + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + return $this->whereRaw( + "({$this->getGrammar()->wrap($column)} <=> ?) <= ?", + [ + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + $maxDistance, + ], + $boolean + ); + } + + /** + * Add a vector distance "or where" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Support\Arrayable|array|string $vector + * @param float $maxDistance + * @return $this + */ + public function orWhereVectorDistanceLessThan($column, $vector, $maxDistance) + { + return $this->whereVectorDistanceLessThan($column, $vector, $maxDistance, 'or'); + } + + /** + * Add a raw "where" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $sql + * @param mixed $bindings * @param string $boolean * @return $this */ @@ -814,36 +1290,101 @@ public function whereRaw($sql, $bindings = [], $boolean = 'and') } /** - * Add a raw or where clause to the query. + * Add a raw "or where" clause to the query. * * @param string $sql - * @param mixed $bindings - * @return \Illuminate\Database\Query\Builder|static + * @param mixed $bindings + * @return $this */ public function orWhereRaw($sql, $bindings = []) { return $this->whereRaw($sql, $bindings, 'or'); } + /** + * Add a "where like" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $value + * @param bool $caseSensitive + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereLike($column, $value, $caseSensitive = false, $boolean = 'and', $not = false) + { + $type = 'Like'; + + $this->wheres[] = compact('type', 'column', 'value', 'caseSensitive', 'boolean', 'not'); + + if (method_exists($this->grammar, 'prepareWhereLikeBinding')) { + $value = $this->grammar->prepareWhereLikeBinding($value, $caseSensitive); + } + + $this->addBinding($value); + + return $this; + } + + /** + * Add an "or where like" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $value + * @param bool $caseSensitive + * @return $this + */ + public function orWhereLike($column, $value, $caseSensitive = false) + { + return $this->whereLike($column, $value, $caseSensitive, 'or', false); + } + + /** + * Add a "where not like" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $value + * @param bool $caseSensitive + * @param string $boolean + * @return $this + */ + public function whereNotLike($column, $value, $caseSensitive = false, $boolean = 'and') + { + return $this->whereLike($column, $value, $caseSensitive, $boolean, true); + } + + /** + * Add an "or where not like" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $value + * @param bool $caseSensitive + * @return $this + */ + public function orWhereNotLike($column, $value, $caseSensitive = false) + { + return $this->whereNotLike($column, $value, $caseSensitive, 'or'); + } + /** * Add a "where in" clause to the query. * - * @param string $column - * @param mixed $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param mixed $values * @param string $boolean - * @param bool $not + * @param bool $not * @return $this + * + * @throws \InvalidArgumentException */ public function whereIn($column, $values, $boolean = 'and', $not = false) { $type = $not ? 'NotIn' : 'In'; // If the value is a query builder instance we will assume the developer wants to - // look for any values that exists within this given query. So we will add the + // look for any values that exist within this given query. So, we will add the // query accordingly so that this query is properly executed when it is run. - if ($values instanceof self || - $values instanceof EloquentBuilder || - $values instanceof Closure) { + if ($this->isQueryable($values)) { [$query, $bindings] = $this->createSub($values); $values = [new Expression($query)]; @@ -860,7 +1401,11 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) $this->wheres[] = compact('type', 'column', 'values', 'boolean'); - // Finally we'll add a binding for each values unless that value is an expression + if (count($values) !== count(Arr::flatten($values, 1))) { + throw new InvalidArgumentException('Nested arrays may not be passed to whereIn method.'); + } + + // Finally, we'll add a binding for each value unless that value is an expression // in which case we will just skip over it since it will be the query as a raw // string and not as a parameterized place-holder to be replaced by the PDO. $this->addBinding($this->cleanBindings($values), 'where'); @@ -871,9 +1416,9 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) /** * Add an "or where in" clause to the query. * - * @param string $column - * @param mixed $values - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param mixed $values + * @return $this */ public function orWhereIn($column, $values) { @@ -883,10 +1428,10 @@ public function orWhereIn($column, $values) /** * Add a "where not in" clause to the query. * - * @param string $column - * @param mixed $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param mixed $values * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereNotIn($column, $values, $boolean = 'and') { @@ -896,60 +1441,15 @@ public function whereNotIn($column, $values, $boolean = 'and') /** * Add an "or where not in" clause to the query. * - * @param string $column - * @param mixed $values - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param mixed $values + * @return $this */ public function orWhereNotIn($column, $values) { return $this->whereNotIn($column, $values, 'or'); } - /** - * Add a where in with a sub-select to the query. - * - * @param string $column - * @param \Closure $callback - * @param string $boolean - * @param bool $not - * @return $this - */ - protected function whereInSub($column, Closure $callback, $boolean, $not) - { - $type = $not ? 'NotInSub' : 'InSub'; - - // To create the exists sub-select, we will actually create a query and call the - // provided callback with the query so the developer may set any of the query - // conditions they want for the in clause, then we'll put it in this array. - call_user_func($callback, $query = $this->forSubQuery()); - - $this->wheres[] = compact('type', 'column', 'query', 'boolean'); - - $this->addBinding($query->getBindings(), 'where'); - - return $this; - } - - /** - * Add an external sub-select to the query. - * - * @param string $column - * @param \Illuminate\Database\Query\Builder|static $query - * @param string $boolean - * @param bool $not - * @return $this - */ - protected function whereInExistingQuery($column, $query, $boolean, $not) - { - $type = $not ? 'NotInSub' : 'InSub'; - - $this->wheres[] = compact('type', 'column', 'query', 'boolean'); - - $this->addBinding($query->getBindings(), 'where'); - - return $this; - } - /** * Add a "where in raw" clause for integer values to the query. * @@ -967,8 +1467,10 @@ public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = fal $values = $values->toArray(); } + $values = Arr::flatten($values); + foreach ($values as &$value) { - $value = (int) $value; + $value = (int) ($value instanceof BackedEnum ? $value->value : $value); } $this->wheres[] = compact('type', 'column', 'values', 'boolean'); @@ -976,6 +1478,18 @@ public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = fal return $this; } + /** + * Add an "or where in raw" clause for integer values to the query. + * + * @param string $column + * @param \Illuminate\Contracts\Support\Arrayable|array $values + * @return $this + */ + public function orWhereIntegerInRaw($column, $values) + { + return $this->whereIntegerInRaw($column, $values, 'or'); + } + /** * Add a "where not in raw" clause for integer values to the query. * @@ -990,18 +1504,32 @@ public function whereIntegerNotInRaw($column, $values, $boolean = 'and') } /** - * Add a "where null" clause to the query. + * Add an "or where not in raw" clause for integer values to the query. * * @param string $column + * @param \Illuminate\Contracts\Support\Arrayable|array $values + * @return $this + */ + public function orWhereIntegerNotInRaw($column, $values) + { + return $this->whereIntegerNotInRaw($column, $values, 'or'); + } + + /** + * Add a "where null" clause to the query. + * + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $columns * @param string $boolean - * @param bool $not + * @param bool $not * @return $this */ - public function whereNull($column, $boolean = 'and', $not = false) + public function whereNull($columns, $boolean = 'and', $not = false) { $type = $not ? 'NotNull' : 'Null'; - $this->wheres[] = compact('type', 'column', 'boolean'); + foreach (Arr::wrap($columns) as $column) { + $this->wheres[] = compact('type', 'column', 'boolean'); + } return $this; } @@ -1009,8 +1537,8 @@ public function whereNull($column, $boolean = 'and', $not = false) /** * Add an "or where null" clause to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $column + * @return $this */ public function orWhereNull($column) { @@ -1020,137 +1548,277 @@ public function orWhereNull($column) /** * Add a "where not null" clause to the query. * - * @param string $column + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $columns * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ - public function whereNotNull($column, $boolean = 'and') + public function whereNotNull($columns, $boolean = 'and') { - return $this->whereNull($column, $boolean, true); + return $this->whereNull($columns, $boolean, true); } /** - * Add a where between statement to the query. + * Add a "where between" statement to the query. * - * @param string $column - * @param array $values + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @param string $boolean * @param bool $not * @return $this */ - public function whereBetween($column, array $values, $boolean = 'and', $not = false) + public function whereBetween($column, iterable $values, $boolean = 'and', $not = false) { $type = 'between'; + if ($this->isQueryable($column)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->whereBetween(new Expression('('.$sub.')'), $values, $boolean, $not); + } + + if ($values instanceof DatePeriod) { + $values = $this->resolveDatePeriodBounds($values); + } + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); - $this->addBinding($this->cleanBindings($values), 'where'); + $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'where'); return $this; } /** - * Add an or where between statement to the query. + * Add a "where between" statement using columns to the query. * - * @param string $column - * @param array $values - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $boolean + * @param bool $not + * @return $this */ - public function orWhereBetween($column, array $values) + public function whereBetweenColumns($column, array $values, $boolean = 'and', $not = false) { - return $this->whereBetween($column, $values, 'or'); + $type = 'betweenColumns'; + + if ($this->isQueryable($column)) { + [$sub, $bindings] = $this->createSub($column); + + return $this->addBinding($bindings, 'where') + ->whereBetweenColumns(new Expression('('.$sub.')'), $values, $boolean, $not); + } + + $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); + + return $this; } /** - * Add a where not between statement to the query. + * Add an "or where between" statement to the query. * - * @param string $column - * @param array $values - * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ - public function whereNotBetween($column, array $values, $boolean = 'and') + public function orWhereBetween($column, iterable $values) { - return $this->whereBetween($column, $values, $boolean, true); + return $this->whereBetween($column, $values, 'or'); } /** - * Add an or where not between statement to the query. + * Add an "or where between" statement using columns to the query. * - * @param string $column - * @param array $values - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ - public function orWhereNotBetween($column, array $values) + public function orWhereBetweenColumns($column, array $values) { - return $this->whereNotBetween($column, $values, 'or'); + return $this->whereBetweenColumns($column, $values, 'or'); } /** - * Add an "or where not null" clause to the query. + * Add a "where not between" statement to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $boolean + * @return $this */ - public function orWhereNotNull($column) + public function whereNotBetween($column, iterable $values, $boolean = 'and') { - return $this->whereNotNull($column, 'or'); + return $this->whereBetween($column, $values, $boolean, true); } /** - * Add a "where date" statement to the query. + * Add a "where not between" statement using columns to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ - public function whereDate($column, $operator, $value = null, $boolean = 'and') + public function whereNotBetweenColumns($column, array $values, $boolean = 'and') { - [$value, $operator] = $this->prepareValueAndOperator( - $value, $operator, func_num_args() === 2 - ); - - if ($value instanceof DateTimeInterface) { - $value = $value->format('Y-m-d'); - } - - return $this->addDateBasedWhere('Date', $column, $operator, $value, $boolean); + return $this->whereBetweenColumns($column, $values, $boolean, true); } /** - * Add an "or where date" statement to the query. + * Add an "or where not between" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ - public function orWhereDate($column, $operator, $value = null) + public function orWhereNotBetween($column, iterable $values) { - [$value, $operator] = $this->prepareValueAndOperator( - $value, $operator, func_num_args() === 2 - ); - - return $this->whereDate($column, $operator, $value, 'or'); + return $this->whereNotBetween($column, $values, 'or'); } /** - * Add a "where time" statement to the query. + * Add an "or where not between" statement using columns to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value - * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ - public function whereTime($column, $operator, $value = null, $boolean = 'and') + public function orWhereNotBetweenColumns($column, array $values) { - [$value, $operator] = $this->prepareValueAndOperator( - $value, $operator, func_num_args() === 2 + return $this->whereNotBetweenColumns($column, $values, 'or'); + } + + /** + * Add a "where between columns" statement using a value to the query. + * + * @param mixed $value + * @param array{\Illuminate\Contracts\Database\Query\Expression|string, \Illuminate\Contracts\Database\Query\Expression|string} $columns + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereValueBetween($value, array $columns, $boolean = 'and', $not = false) + { + $type = 'valueBetween'; + + $this->wheres[] = compact('type', 'value', 'columns', 'boolean', 'not'); + + $this->addBinding($value, 'where'); + + return $this; + } + + /** + * Add an "or where between columns" statement using a value to the query. + * + * @param mixed $value + * @param array{\Illuminate\Contracts\Database\Query\Expression|string, \Illuminate\Contracts\Database\Query\Expression|string} $columns + * @return $this + */ + public function orWhereValueBetween($value, array $columns) + { + return $this->whereValueBetween($value, $columns, 'or'); + } + + /** + * Add a "where not between columns" statement using a value to the query. + * + * @param mixed $value + * @param array{\Illuminate\Contracts\Database\Query\Expression|string, \Illuminate\Contracts\Database\Query\Expression|string} $columns + * @param string $boolean + * @return $this + */ + public function whereValueNotBetween($value, array $columns, $boolean = 'and') + { + return $this->whereValueBetween($value, $columns, $boolean, true); + } + + /** + * Add an "or where not between columns" statement using a value to the query. + * + * @param mixed $value + * @param array{\Illuminate\Contracts\Database\Query\Expression|string, \Illuminate\Contracts\Database\Query\Expression|string} $columns + * @return $this + */ + public function orWhereValueNotBetween($value, array $columns) + { + return $this->whereValueNotBetween($value, $columns, 'or'); + } + + /** + * Add an "or where not null" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this + */ + public function orWhereNotNull($column) + { + return $this->whereNotNull($column, 'or'); + } + + /** + * Add a "where date" statement to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator + * @param \DateTimeInterface|string|null $value + * @param string $boolean + * @return $this + */ + public function whereDate($column, $operator, $value = null, $boolean = 'and') + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + + if ($value instanceof DateTimeInterface) { + $value = $value->format('Y-m-d'); + } + + return $this->addDateBasedWhere('Date', $column, $operator, $value, $boolean); + } + + /** + * Add an "or where date" statement to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator + * @param \DateTimeInterface|string|null $value + * @return $this + */ + public function orWhereDate($column, $operator, $value = null) + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + return $this->whereDate($column, $operator, $value, 'or'); + } + + /** + * Add a "where time" statement to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator + * @param \DateTimeInterface|string|null $value + * @param string $boolean + * @return $this + */ + public function whereTime($column, $operator, $value = null, $boolean = 'and') + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 ); + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + if ($value instanceof DateTimeInterface) { $value = $value->format('H:i:s'); } @@ -1161,10 +1829,10 @@ public function whereTime($column, $operator, $value = null, $boolean = 'and') /** * Add an "or where time" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator + * @param \DateTimeInterface|string|null $value + * @return $this */ public function orWhereTime($column, $operator, $value = null) { @@ -1178,11 +1846,11 @@ public function orWhereTime($column, $operator, $value = null) /** * Add a "where day" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereDay($column, $operator, $value = null, $boolean = 'and') { @@ -1190,20 +1858,33 @@ public function whereDay($column, $operator, $value = null, $boolean = 'and') $value, $operator, func_num_args() === 2 ); + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + if ($value instanceof DateTimeInterface) { $value = $value->format('d'); } + if (! $value instanceof ExpressionContract) { + $value = sprintf('%02d', $value); + } + return $this->addDateBasedWhere('Day', $column, $operator, $value, $boolean); } /** * Add an "or where day" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value + * @return $this */ public function orWhereDay($column, $operator, $value = null) { @@ -1211,17 +1892,17 @@ public function orWhereDay($column, $operator, $value = null) $value, $operator, func_num_args() === 2 ); - return $this->addDateBasedWhere('Day', $column, $operator, $value, 'or'); + return $this->whereDay($column, $operator, $value, 'or'); } /** * Add a "where month" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereMonth($column, $operator, $value = null, $boolean = 'and') { @@ -1229,20 +1910,33 @@ public function whereMonth($column, $operator, $value = null, $boolean = 'and') $value, $operator, func_num_args() === 2 ); + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + if ($value instanceof DateTimeInterface) { $value = $value->format('m'); } + if (! $value instanceof ExpressionContract) { + $value = sprintf('%02d', $value); + } + return $this->addDateBasedWhere('Month', $column, $operator, $value, $boolean); } /** * Add an "or where month" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value + * @return $this */ public function orWhereMonth($column, $operator, $value = null) { @@ -1250,17 +1944,17 @@ public function orWhereMonth($column, $operator, $value = null) $value, $operator, func_num_args() === 2 ); - return $this->addDateBasedWhere('Month', $column, $operator, $value, 'or'); + return $this->whereMonth($column, $operator, $value, 'or'); } /** * Add a "where year" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string|int $value + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function whereYear($column, $operator, $value = null, $boolean = 'and') { @@ -1268,6 +1962,15 @@ public function whereYear($column, $operator, $value = null, $boolean = 'and') $value, $operator, func_num_args() === 2 ); + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $value = $this->flattenValue($value); + if ($value instanceof DateTimeInterface) { $value = $value->format('Y'); } @@ -1278,10 +1981,10 @@ public function whereYear($column, $operator, $value = null, $boolean = 'and') /** * Add an "or where year" statement to the query. * - * @param string $column - * @param string $operator - * @param \DateTimeInterface|string|int $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator + * @param \DateTimeInterface|string|int|null $value + * @return $this */ public function orWhereYear($column, $operator, $value = null) { @@ -1289,14 +1992,14 @@ public function orWhereYear($column, $operator, $value = null) $value, $operator, func_num_args() === 2 ); - return $this->addDateBasedWhere('Year', $column, $operator, $value, 'or'); + return $this->whereYear($column, $operator, $value, 'or'); } /** * Add a date based (year, month, day, time) statement to the query. * * @param string $type - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string $operator * @param mixed $value * @param string $boolean @@ -1306,7 +2009,7 @@ protected function addDateBasedWhere($type, $column, $operator, $value, $boolean { $this->wheres[] = compact('column', 'type', 'boolean', 'operator', 'value'); - if (! $value instanceof Expression) { + if (! $value instanceof ExpressionContract) { $this->addBinding($value, 'where'); } @@ -1314,15 +2017,14 @@ protected function addDateBasedWhere($type, $column, $operator, $value, $boolean } /** - * Add a nested where statement to the query. + * Add a nested "where" statement to the query. * - * @param \Closure $callback - * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @param string $boolean + * @return $this */ public function whereNested(Closure $callback, $boolean = 'and') { - call_user_func($callback, $query = $this->forNestedWhere()); + $callback($query = $this->forNestedWhere()); return $this->addNestedWhereQuery($query, $boolean); } @@ -1340,7 +2042,7 @@ public function forNestedWhere() /** * Add another query builder as a nested where to the query builder. * - * @param \Illuminate\Database\Query\Builder|static $query + * @param \Illuminate\Database\Query\Builder $query * @param string $boolean * @return $this */ @@ -1360,20 +2062,24 @@ public function addNestedWhereQuery($query, $boolean = 'and') /** * Add a full sub-select to the query. * - * @param string $column - * @param string $operator - * @param \Closure $callback - * @param string $boolean + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string $operator + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $callback + * @param string $boolean * @return $this */ - protected function whereSub($column, $operator, Closure $callback, $boolean) + protected function whereSub($column, $operator, $callback, $boolean) { $type = 'Sub'; - // Once we have the query instance we can simply execute it so it can add all - // of the sub-select's conditions to itself, and then we can cache it off - // in the array of where clauses for the "main" parent query instance. - call_user_func($callback, $query = $this->forSubQuery()); + if ($callback instanceof Closure) { + // Once we have the query instance we can simply execute it so it can add all + // of the sub-select's conditions to itself, and then we can cache it off + // in the array of where clauses for the "main" parent query instance. + $callback($query = $this->forSubQuery()); + } else { + $query = $callback instanceof EloquentBuilder ? $callback->toBase() : $callback; + } $this->wheres[] = compact( 'type', 'column', 'operator', 'query', 'boolean' @@ -1385,64 +2091,67 @@ protected function whereSub($column, $operator, Closure $callback, $boolean) } /** - * Add an exists clause to the query. + * Add an "exists" clause to the query. * - * @param \Closure $callback - * @param string $boolean - * @param bool $not + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $callback + * @param string $boolean + * @param bool $not * @return $this */ - public function whereExists(Closure $callback, $boolean = 'and', $not = false) + public function whereExists($callback, $boolean = 'and', $not = false) { - $query = $this->forSubQuery(); + if ($callback instanceof Closure) { + $query = $this->forSubQuery(); - // Similar to the sub-select clause, we will create a new query instance so - // the developer may cleanly specify the entire exists query and we will - // compile the whole thing in the grammar and insert it into the SQL. - call_user_func($callback, $query); + // Similar to the sub-select clause, we will create a new query instance so + // the developer may cleanly specify the entire exists query and we will + // compile the whole thing in the grammar and insert it into the SQL. + $callback($query); + } else { + $query = $callback instanceof EloquentBuilder ? $callback->toBase() : $callback; + } return $this->addWhereExistsQuery($query, $boolean, $not); } /** - * Add an or exists clause to the query. + * Add an "or where exists" clause to the query. * - * @param \Closure $callback - * @param bool $not - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $callback + * @param bool $not + * @return $this */ - public function orWhereExists(Closure $callback, $not = false) + public function orWhereExists($callback, $not = false) { return $this->whereExists($callback, 'or', $not); } /** - * Add a where not exists clause to the query. + * Add a "where not exists" clause to the query. * - * @param \Closure $callback - * @param string $boolean - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $callback + * @param string $boolean + * @return $this */ - public function whereNotExists(Closure $callback, $boolean = 'and') + public function whereNotExists($callback, $boolean = 'and') { return $this->whereExists($callback, $boolean, true); } /** - * Add a where not exists clause to the query. + * Add an "or where not exists" clause to the query. * - * @param \Closure $callback - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $callback + * @return $this */ - public function orWhereNotExists(Closure $callback) + public function orWhereNotExists($callback) { return $this->orWhereExists($callback, true); } /** - * Add an exists clause to the query. + * Add an "exists" clause to the query. * - * @param \Illuminate\Database\Query\Builder $query * @param string $boolean * @param bool $not * @return $this @@ -1461,11 +2170,13 @@ public function addWhereExistsQuery(self $query, $boolean = 'and', $not = false) /** * Adds a where condition using row values. * - * @param array $columns + * @param array $columns * @param string $operator - * @param array $values + * @param array $values * @param string $boolean * @return $this + * + * @throws \InvalidArgumentException */ public function whereRowValues($columns, $operator, $values, $boolean = 'and') { @@ -1483,11 +2194,11 @@ public function whereRowValues($columns, $operator, $values, $boolean = 'and') } /** - * Adds a or where condition using row values. + * Adds an or where condition using row values. * - * @param array $columns + * @param array $columns * @param string $operator - * @param array $values + * @param array $values * @return $this */ public function orWhereRowValues($columns, $operator, $values) @@ -1510,7 +2221,7 @@ public function whereJsonContains($column, $value, $boolean = 'and', $not = fals $this->wheres[] = compact('type', 'column', 'value', 'boolean', 'not'); - if (! $value instanceof Expression) { + if (! $value instanceof ExpressionContract) { $this->addBinding($this->grammar->prepareBindingForJsonContains($value)); } @@ -1518,7 +2229,7 @@ public function whereJsonContains($column, $value, $boolean = 'and', $not = fals } /** - * Add a "or where JSON contains" clause to the query. + * Add an "or where JSON contains" clause to the query. * * @param string $column * @param mixed $value @@ -1543,7 +2254,7 @@ public function whereJsonDoesntContain($column, $value, $boolean = 'and') } /** - * Add a "or where JSON not contains" clause to the query. + * Add an "or where JSON not contains" clause to the query. * * @param string $column * @param mixed $value @@ -1555,114 +2266,365 @@ public function orWhereJsonDoesntContain($column, $value) } /** - * Add a "where JSON length" clause to the query. + * Add a "where JSON overlaps" clause to the query. * * @param string $column - * @param mixed $operator * @param mixed $value * @param string $boolean + * @param bool $not * @return $this */ - public function whereJsonLength($column, $operator, $value = null, $boolean = 'and') + public function whereJsonOverlaps($column, $value, $boolean = 'and', $not = false) { - $type = 'JsonLength'; - - [$value, $operator] = $this->prepareValueAndOperator( - $value, $operator, func_num_args() === 2 - ); + $type = 'JsonOverlaps'; - $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean'); + $this->wheres[] = compact('type', 'column', 'value', 'boolean', 'not'); - if (! $value instanceof Expression) { - $this->addBinding($value); + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->grammar->prepareBindingForJsonContains($value)); } return $this; } /** - * Add a "or where JSON length" clause to the query. + * Add an "or where JSON overlaps" clause to the query. * * @param string $column - * @param mixed $operator * @param mixed $value * @return $this */ - public function orWhereJsonLength($column, $operator, $value = null) + public function orWhereJsonOverlaps($column, $value) { - [$value, $operator] = $this->prepareValueAndOperator( - $value, $operator, func_num_args() === 2 - ); - - return $this->whereJsonLength($column, $operator, $value, 'or'); + return $this->whereJsonOverlaps($column, $value, 'or'); } /** - * Handles dynamic "where" clauses to the query. + * Add a "where JSON not overlap" clause to the query. * - * @param string $method - * @param string $parameters + * @param string $column + * @param mixed $value + * @param string $boolean * @return $this */ - public function dynamicWhere($method, $parameters) + public function whereJsonDoesntOverlap($column, $value, $boolean = 'and') { - $finder = substr($method, 5); - - $segments = preg_split( - '/(And|Or)(?=[A-Z])/', $finder, -1, PREG_SPLIT_DELIM_CAPTURE - ); - - // The connector variable will determine which connector will be used for the - // query condition. We will change it as we come across new boolean values - // in the dynamic method strings, which could contain a number of these. - $connector = 'and'; - - $index = 0; - - foreach ($segments as $segment) { - // If the segment is not a boolean connector, we can assume it is a column's name - // and we will add it to the query as a new constraint as a where clause, then - // we can keep iterating through the dynamic method string's segments again. - if ($segment !== 'And' && $segment !== 'Or') { - $this->addDynamic($segment, $connector, $parameters, $index); - - $index++; - } - - // Otherwise, we will store the connector so we know how the next where clause we - // find in the query should be connected to the previous ones, meaning we will - // have the proper boolean connector to connect the next where clause found. - else { - $connector = $segment; - } - } - - return $this; + return $this->whereJsonOverlaps($column, $value, $boolean, true); } /** - * Add a single dynamic where clause statement to the query. + * Add an "or where JSON not overlap" clause to the query. * - * @param string $segment - * @param string $connector - * @param array $parameters - * @param int $index - * @return void + * @param string $column + * @param mixed $value + * @return $this */ - protected function addDynamic($segment, $connector, $parameters, $index) + public function orWhereJsonDoesntOverlap($column, $value) { - // Once we have parsed out the columns and formatted the boolean operators we - // are ready to add it to this query as a where clause just like any other - // clause on the query. Then we'll increment the parameter index values. - $bool = strtolower($connector); - - $this->where(Str::snake($segment), '=', $parameters[$index], $bool); + return $this->whereJsonDoesntOverlap($column, $value, 'or'); } /** - * Add a "group by" clause to the query. + * Add a clause that determines if a JSON path exists to the query. * - * @param array ...$groups + * @param string $column + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereJsonContainsKey($column, $boolean = 'and', $not = false) + { + $type = 'JsonContainsKey'; + + $this->wheres[] = compact('type', 'column', 'boolean', 'not'); + + return $this; + } + + /** + * Add an "or" clause that determines if a JSON path exists to the query. + * + * @param string $column + * @return $this + */ + public function orWhereJsonContainsKey($column) + { + return $this->whereJsonContainsKey($column, 'or'); + } + + /** + * Add a clause that determines if a JSON path does not exist to the query. + * + * @param string $column + * @param string $boolean + * @return $this + */ + public function whereJsonDoesntContainKey($column, $boolean = 'and') + { + return $this->whereJsonContainsKey($column, $boolean, true); + } + + /** + * Add an "or" clause that determines if a JSON path does not exist to the query. + * + * @param string $column + * @return $this + */ + public function orWhereJsonDoesntContainKey($column) + { + return $this->whereJsonDoesntContainKey($column, 'or'); + } + + /** + * Add a "where JSON length" clause to the query. + * + * @param string $column + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereJsonLength($column, $operator, $value = null, $boolean = 'and') + { + $type = 'JsonLength'; + + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + // If the given operator is not found in the list of valid operators we will + // assume that the developer is just short-cutting the '=' operators and + // we will set the operators to '=' and set the values appropriately. + if ($this->invalidOperator($operator)) { + [$value, $operator] = [$operator, '=']; + } + + $this->wheres[] = compact('type', 'column', 'operator', 'value', 'boolean'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding((int) $this->flattenValue($value)); + } + + return $this; + } + + /** + * Add an "or where JSON length" clause to the query. + * + * @param string $column + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereJsonLength($column, $operator, $value = null) + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + return $this->whereJsonLength($column, $operator, $value, 'or'); + } + + /** + * Handles dynamic "where" clauses to the query. + * + * @param string $method + * @param array $parameters + * @return $this + */ + public function dynamicWhere($method, $parameters) + { + $finder = substr($method, 5); + + $segments = preg_split( + '/(And|Or)(?=[A-Z])/', $finder, -1, PREG_SPLIT_DELIM_CAPTURE + ); + + // The connector variable will determine which connector will be used for the + // query condition. We will change it as we come across new boolean values + // in the dynamic method strings, which could contain a number of these. + $connector = 'and'; + + $index = 0; + + foreach ($segments as $segment) { + // If the segment is not a boolean connector, we can assume it is a column's name + // and we will add it to the query as a new constraint as a where clause, then + // we can keep iterating through the dynamic method string's segments again. + if ($segment !== 'And' && $segment !== 'Or') { + $this->addDynamic($segment, $connector, $parameters, $index); + + $index++; + } + + // Otherwise, we will store the connector so we know how the next where clause we + // find in the query should be connected to the previous ones, meaning we will + // have the proper boolean connector to connect the next where clause found. + else { + $connector = $segment; + } + } + + return $this; + } + + /** + * Add a single dynamic "where" clause statement to the query. + * + * @param string $segment + * @param string $connector + * @param array $parameters + * @param int $index + * @return void + */ + protected function addDynamic($segment, $connector, $parameters, $index) + { + // Once we have parsed out the columns and formatted the boolean operators we + // are ready to add it to this query as a where clause just like any other + // clause on the query. Then we'll increment the parameter index values. + $bool = strtolower($connector); + + $this->where(Str::snake($segment), '=', $parameters[$index], $bool); + } + + /** + * Add a "where fulltext" clause to the query. + * + * @param string|string[] $columns + * @param string $value + * @param string $boolean + * @return $this + */ + public function whereFullText($columns, $value, array $options = [], $boolean = 'and') + { + $type = 'Fulltext'; + + $columns = (array) $columns; + + $this->wheres[] = compact('type', 'columns', 'value', 'options', 'boolean'); + + $this->addBinding($value); + + return $this; + } + + /** + * Add an "or where fulltext" clause to the query. + * + * @param string|string[] $columns + * @param string $value + * @return $this + */ + public function orWhereFullText($columns, $value, array $options = []) + { + return $this->whereFullText($columns, $value, $options, 'or'); + } + + /** + * Add a "where" clause to the query for multiple columns with "and" conditions between them. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereAll($columns, $operator = null, $value = null, $boolean = 'and') + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + $this->whereNested(function ($query) use ($columns, $operator, $value) { + foreach ($columns as $column) { + $query->where($column, $operator, $value, 'and'); + } + }, $boolean); + + return $this; + } + + /** + * Add an "or where" clause to the query for multiple columns with "and" conditions between them. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereAll($columns, $operator = null, $value = null) + { + return $this->whereAll($columns, $operator, $value, 'or'); + } + + /** + * Add a "where" clause to the query for multiple columns with "or" conditions between them. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereAny($columns, $operator = null, $value = null, $boolean = 'and') + { + [$value, $operator] = $this->prepareValueAndOperator( + $value, $operator, func_num_args() === 2 + ); + + $this->whereNested(function ($query) use ($columns, $operator, $value) { + foreach ($columns as $column) { + $query->where($column, $operator, $value, 'or'); + } + }, $boolean); + + return $this; + } + + /** + * Add an "or where" clause to the query for multiple columns with "or" conditions between them. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereAny($columns, $operator = null, $value = null) + { + return $this->whereAny($columns, $operator, $value, 'or'); + } + + /** + * Add a "where not" clause to the query for multiple columns where none of the conditions should be true. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereNone($columns, $operator = null, $value = null, $boolean = 'and') + { + return $this->whereAny($columns, $operator, $value, $boolean.' not'); + } + + /** + * Add an "or where not" clause to the query for multiple columns where none of the conditions should be true. + * + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns + * @param mixed $operator + * @param mixed $value + * @return $this + */ + public function orWhereNone($columns, $operator = null, $value = null) + { + return $this->whereNone($columns, $operator, $value, 'or'); + } + + /** + * Add a "group by" clause to the query. + * + * @param array|\Illuminate\Contracts\Database\Query\Expression|string ...$groups * @return $this */ public function groupBy(...$groups) @@ -1677,12 +2639,27 @@ public function groupBy(...$groups) return $this; } + /** + * Add a raw "groupBy" clause to the query. + * + * @param string $sql + * @return $this + */ + public function groupByRaw($sql, array $bindings = []) + { + $this->groups[] = new Expression($sql); + + $this->addBinding($bindings, 'groupBy'); + + return $this; + } + /** * Add a "having" clause to the query. * - * @param string $column - * @param string|null $operator - * @param string|null $value + * @param \Illuminate\Contracts\Database\Query\Expression|\Closure|string $column + * @param \DateTimeInterface|string|int|float|null $operator + * @param \Illuminate\Contracts\Database\Query\Expression|\DateTimeInterface|string|int|float|null $value * @param string $boolean * @return $this */ @@ -1690,6 +2667,14 @@ public function having($column, $operator = null, $value = null, $boolean = 'and { $type = 'Basic'; + if ($column instanceof ConditionExpression) { + $type = 'Expression'; + + $this->havings[] = compact('type', 'column', 'boolean'); + + return $this; + } + // Here we will make some assumptions about the operator. If only 2 values are // passed to the method, we will assume that the operator is an equals sign // and keep going. Otherwise, we'll require the operator to be passed in. @@ -1697,6 +2682,10 @@ public function having($column, $operator = null, $value = null, $boolean = 'and $value, $operator, func_num_args() === 2 ); + if ($column instanceof Closure && is_null($operator)) { + return $this->havingNested($column, $boolean); + } + // If the given operator is not found in the list of valid operators we will // assume that the developer is just short-cutting the '=' operators and // we will set the operators to '=' and set the values appropriately. @@ -1704,22 +2693,26 @@ public function having($column, $operator = null, $value = null, $boolean = 'and [$value, $operator] = [$operator, '=']; } + if ($this->isBitwiseOperator($operator)) { + $type = 'Bitwise'; + } + $this->havings[] = compact('type', 'column', 'operator', 'value', 'boolean'); - if (! $value instanceof Expression) { - $this->addBinding($value, 'having'); + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->flattenValue($value), 'having'); } return $this; } /** - * Add a "or having" clause to the query. + * Add an "or having" clause to the query. * - * @param string $column - * @param string|null $operator - * @param string|null $value - * @return \Illuminate\Database\Query\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|\Closure|string $column + * @param \DateTimeInterface|string|int|float|null $operator + * @param \Illuminate\Contracts\Database\Query\Expression|\DateTimeInterface|string|int|float|null $value + * @return $this */ public function orHaving($column, $operator = null, $value = null) { @@ -1731,30 +2724,178 @@ public function orHaving($column, $operator = null, $value = null) } /** - * Add a "having between " clause to the query. + * Add a nested "having" statement to the query. + * + * @param string $boolean + * @return $this + */ + public function havingNested(Closure $callback, $boolean = 'and') + { + $callback($query = $this->forNestedWhere()); + + return $this->addNestedHavingQuery($query, $boolean); + } + + /** + * Add another query builder as a nested having to the query builder. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $boolean + * @return $this + */ + public function addNestedHavingQuery($query, $boolean = 'and') + { + if (count($query->havings)) { + $type = 'Nested'; + + $this->havings[] = compact('type', 'query', 'boolean'); + + $this->addBinding($query->getRawBindings()['having'], 'having'); + } + + return $this; + } + + /** + * Add a "having null" clause to the query. + * + * @param array|string $columns + * @param string $boolean + * @param bool $not + * @return $this + */ + public function havingNull($columns, $boolean = 'and', $not = false) + { + $type = $not ? 'NotNull' : 'Null'; + + foreach (Arr::wrap($columns) as $column) { + $this->havings[] = compact('type', 'column', 'boolean'); + } + + return $this; + } + + /** + * Add an "or having null" clause to the query. + * + * @param string $column + * @return $this + */ + public function orHavingNull($column) + { + return $this->havingNull($column, 'or'); + } + + /** + * Add a "having not null" clause to the query. + * + * @param array|string $columns + * @param string $boolean + * @return $this + */ + public function havingNotNull($columns, $boolean = 'and') + { + return $this->havingNull($columns, $boolean, true); + } + + /** + * Add an "or having not null" clause to the query. + * + * @param string $column + * @return $this + */ + public function orHavingNotNull($column) + { + return $this->havingNotNull($column, 'or'); + } + + /** + * Add a "having between" clause to the query. * * @param string $column - * @param array $values * @param string $boolean * @param bool $not - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ - public function havingBetween($column, array $values, $boolean = 'and', $not = false) + public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) { $type = 'between'; + if ($values instanceof DatePeriod) { + $values = $this->resolveDatePeriodBounds($values); + } + $this->havings[] = compact('type', 'column', 'values', 'boolean', 'not'); - $this->addBinding($this->cleanBindings($values), 'having'); + $this->addBinding(array_slice($this->cleanBindings(Arr::flatten($values)), 0, 2), 'having'); return $this; } /** - * Add a raw having clause to the query. + * Add a "having not between" clause to the query. + * + * @param string $column + * @param iterable $values + * @param string $boolean + * @return $this + */ + public function havingNotBetween($column, iterable $values, $boolean = 'and') + { + return $this->havingBetween($column, $values, $boolean, true); + } + + /** + * Add an "or having between" clause to the query. + * + * @param string $column + * @param iterable $values + * @return $this + */ + public function orHavingBetween($column, iterable $values) + { + return $this->havingBetween($column, $values, 'or'); + } + + /** + * Add an "or having not between" clause to the query. + * + * @param string $column + * @param iterable $values + * @return $this + */ + public function orHavingNotBetween($column, iterable $values) + { + return $this->havingBetween($column, $values, 'or', true); + } + + /** + * Resolve the start and end dates from a DatePeriod. + * + * @param \DatePeriod $period + * @return array{\DateTimeInterface, \DateTimeInterface} + */ + protected function resolveDatePeriodBounds(DatePeriod $period) + { + [$start, $end] = [$period->getStartDate(), $period->getEndDate()]; + + if ($end === null) { + $end = clone $start; + + $recurrences = $period->getRecurrences(); + + for ($i = 0; $i < $recurrences; $i++) { + $end = $end->add($period->getDateInterval()); + } + } + + return [$start, $end]; + } + + /** + * Add a raw "having" clause to the query. * * @param string $sql - * @param array $bindings * @param string $boolean * @return $this */ @@ -1770,11 +2911,10 @@ public function havingRaw($sql, array $bindings = [], $boolean = 'and') } /** - * Add a raw or having clause to the query. + * Add a raw "or having" clause to the query. * * @param string $sql - * @param array $bindings - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function orHavingRaw($sql, array $bindings = []) { @@ -1784,7 +2924,7 @@ public function orHavingRaw($sql, array $bindings = []) /** * Add an "order by" clause to the query. * - * @param string $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @param string $direction * @return $this * @@ -1792,6 +2932,14 @@ public function orHavingRaw($sql, array $bindings = []) */ public function orderBy($column, $direction = 'asc') { + if ($this->isQueryable($column)) { + [$query, $bindings] = $this->createSub($column); + + $column = new Expression('('.$query.')'); + + $this->addBinding($bindings, $this->unions ? 'unionOrder' : 'order'); + } + $direction = strtolower($direction); if (! in_array($direction, ['asc', 'desc'], true)) { @@ -1809,7 +2957,7 @@ public function orderBy($column, $direction = 'asc') /** * Add a descending "order by" clause to the query. * - * @param string $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orderByDesc($column) @@ -1820,8 +2968,8 @@ public function orderByDesc($column) /** * Add an "order by" clause for a timestamp to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ public function latest($column = 'created_at') { @@ -1831,18 +2979,51 @@ public function latest($column = 'created_at') /** * Add an "order by" clause for a timestamp to the query. * - * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string $column + * @return $this */ public function oldest($column = 'created_at') { return $this->orderBy($column, 'asc'); } + /** + * Add a vector-distance "order by" clause to the query. + * + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \Illuminate\Support\Collection|\Illuminate\Contracts\Support\Arrayable|array $vector + * @return $this + */ + public function orderByVectorDistance($column, $vector) + { + $this->ensureConnectionSupportsVectors(); + + if (is_string($vector)) { + $vector = Str::of($vector)->toEmbeddings(cache: true); + } + + $this->addBinding( + json_encode( + $vector instanceof Arrayable + ? $vector->toArray() + : $vector, + flags: JSON_THROW_ON_ERROR + ), + $this->unions ? 'unionOrder' : 'order' + ); + + $this->{$this->unions ? 'unionOrders' : 'orders'}[] = [ + 'column' => new Expression("({$this->getGrammar()->wrap($column)} <=> ?)"), + 'direction' => 'asc', + ]; + + return $this; + } + /** * Put the query's results in random order. * - * @param string $seed + * @param string|int $seed * @return $this */ public function inRandomOrder($seed = '') @@ -1863,7 +3044,7 @@ public function orderByRaw($sql, $bindings = []) $this->{$this->unions ? 'unionOrders' : 'orders'}[] = compact('type', 'sql'); - $this->addBinding($bindings, 'order'); + $this->addBinding($bindings, $this->unions ? 'unionOrder' : 'order'); return $this; } @@ -1872,7 +3053,7 @@ public function orderByRaw($sql, $bindings = []) * Alias to set the "offset" value of the query. * * @param int $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function skip($value) { @@ -1889,7 +3070,7 @@ public function offset($value) { $property = $this->unions ? 'unionOffset' : 'offset'; - $this->$property = max(0, $value); + $this->$property = max(0, (int) $value); return $this; } @@ -1898,7 +3079,7 @@ public function offset($value) * Alias to set the "limit" value of the query. * * @param int $value - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function take($value) { @@ -1916,7 +3097,23 @@ public function limit($value) $property = $this->unions ? 'unionLimit' : 'limit'; if ($value >= 0) { - $this->$property = $value; + $this->$property = ! is_null($value) ? (int) $value : null; + } + + return $this; + } + + /** + * Add a "group limit" clause to the query. + * + * @param int $value + * @param string $column + * @return $this + */ + public function groupLimit($value, $column) + { + if ($value >= 0) { + $this->groupLimit = compact('value', 'column'); } return $this; @@ -1927,11 +3124,33 @@ public function limit($value) * * @param int $page * @param int $perPage - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function forPage($page, $perPage = 15) { - return $this->skip(($page - 1) * $perPage)->take($perPage); + return $this->offset(($page - 1) * $perPage)->limit($perPage); + } + + /** + * Constrain the query to the previous "page" of results before a given ID. + * + * @param int $perPage + * @param int|null $lastId + * @param string $column + * @return $this + */ + public function forPageBeforeId($perPage = 15, $lastId = 0, $column = 'id') + { + $this->orders = $this->removeExistingOrdersFor($column); + + if (is_null($lastId)) { + $this->whereNotNull($column); + } else { + $this->where($column, '<', $lastId); + } + + return $this->orderBy($column, 'desc') + ->limit($perPage); } /** @@ -1940,18 +3159,52 @@ public function forPage($page, $perPage = 15) * @param int $perPage * @param int|null $lastId * @param string $column - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id') { $this->orders = $this->removeExistingOrdersFor($column); - if (! is_null($lastId)) { + if (is_null($lastId)) { + $this->whereNotNull($column); + } else { $this->where($column, '>', $lastId); } return $this->orderBy($column, 'asc') - ->take($perPage); + ->limit($perPage); + } + + /** + * Remove all existing orders and optionally add a new order. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string|null $column + * @param string $direction + * @return $this + */ + public function reorder($column = null, $direction = 'asc') + { + $this->orders = null; + $this->unionOrders = null; + $this->bindings['order'] = []; + $this->bindings['unionOrder'] = []; + + if ($column) { + return $this->orderBy($column, $direction); + } + + return $this; + } + + /** + * Add descending "reorder" clause to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string|null $column + * @return $this + */ + public function reorderDesc($column) + { + return $this->reorder($column, 'desc'); } /** @@ -1962,79 +3215,150 @@ public function forPageAfterId($perPage = 15, $lastId = 0, $column = 'id') */ protected function removeExistingOrdersFor($column) { - return Collection::make($this->orders) - ->reject(function ($order) use ($column) { - return isset($order['column']) - ? $order['column'] === $column : false; - })->values()->all(); + return (new Collection($this->orders)) + ->reject(fn ($order) => isset($order['column']) && $order['column'] === $column) + ->values() + ->all(); } /** - * Add a union statement to the query. + * Add a "union" statement to the query. * - * @param \Illuminate\Database\Query\Builder|\Closure $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $query * @param bool $all - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ public function union($query, $all = false) { if ($query instanceof Closure) { - call_user_func($query, $query = $this->newQuery()); + $query($query = $this->newQuery()); } $this->unions[] = compact('query', 'all'); - $this->addBinding($query->getBindings(), 'union'); + $this->addBinding($query->getBindings(), 'union'); + + return $this; + } + + /** + * Add a "union all" statement to the query. + * + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $query + * @return $this + */ + public function unionAll($query) + { + return $this->union($query, true); + } + + /** + * Lock the selected rows in the table. + * + * @param string|bool $value + * @return $this + */ + public function lock($value = true) + { + $this->lock = $value; + + if (! is_null($this->lock)) { + $this->useWritePdo(); + } + + return $this; + } + + /** + * Lock the selected rows in the table for updating. + * + * @return $this + */ + public function lockForUpdate() + { + return $this->lock(true); + } + + /** + * Share lock the selected rows in the table. + * + * @return $this + */ + public function sharedLock() + { + return $this->lock(false); + } + + /** + * Set a query execution timeout in seconds. + * + * @param int|null $seconds + * @return $this + * + * @throws InvalidArgumentException + */ + public function timeout(?int $seconds): static + { + if ($seconds !== null && $seconds <= 0) { + throw new InvalidArgumentException('Timeout must be greater than zero.'); + } + + $this->timeout = $seconds; return $this; } /** - * Add a union all statement to the query. + * Register a closure to be invoked before the query is executed. * - * @param \Illuminate\Database\Query\Builder|\Closure $query - * @return \Illuminate\Database\Query\Builder|static + * @return $this */ - public function unionAll($query) + public function beforeQuery(callable $callback) { - return $this->union($query, true); + $this->beforeQueryCallbacks[] = $callback; + + return $this; } /** - * Lock the selected rows in the table. + * Invoke the "before query" modification callbacks. * - * @param string|bool $value - * @return $this + * @return void */ - public function lock($value = true) + public function applyBeforeQueryCallbacks() { - $this->lock = $value; - - if (! is_null($this->lock)) { - $this->useWritePdo(); + foreach ($this->beforeQueryCallbacks as $callback) { + $callback($this); } - return $this; + $this->beforeQueryCallbacks = []; } /** - * Lock the selected rows in the table for updating. + * Register a closure to be invoked after the query is executed. * - * @return \Illuminate\Database\Query\Builder + * @return $this */ - public function lockForUpdate() + public function afterQuery(Closure $callback) { - return $this->lock(true); + $this->afterQueryCallbacks[] = $callback; + + return $this; } /** - * Share lock the selected rows in the table. + * Invoke the "after query" modification callbacks. * - * @return \Illuminate\Database\Query\Builder + * @param mixed $result + * @return mixed */ - public function sharedLock() + public function applyAfterQueryCallbacks($result) { - return $this->lock(false); + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; } /** @@ -2044,21 +3368,60 @@ public function sharedLock() */ public function toSql() { + $this->applyBeforeQueryCallbacks(); + return $this->grammar->compileSelect($this); } + /** + * Get the raw SQL representation of the query with embedded bindings. + * + * @return string + */ + public function toRawSql() + { + return $this->grammar->substituteBindingsIntoRawSql( + $this->toSql(), $this->connection->prepareBindings($this->getBindings()) + ); + } + /** * Execute a query for a single record by ID. * - * @param int $id - * @param array $columns - * @return mixed|static + * @param int|string $id + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns + * @return \stdClass|null */ public function find($id, $columns = ['*']) { return $this->where('id', '=', $id)->first($columns); } + /** + * Execute a query for a single record by ID or call a callback. + * + * @template TValue + * + * @param mixed $id + * @param (\Closure(): TValue)|string|\Illuminate\Contracts\Database\Query\Expression|array $columns + * @param (\Closure(): TValue)|null $callback + * @return \stdClass|TValue + */ + public function findOr($id, $columns = ['*'], ?Closure $callback = null) + { + if ($columns instanceof Closure) { + $callback = $columns; + + $columns = ['*']; + } + + if (! is_null($data = $this->find($id, $columns))) { + return $data; + } + + return $callback(); + } + /** * Get a single column's value from the first result of a query. * @@ -2069,20 +3432,56 @@ public function value($column) { $result = (array) $this->first([$column]); - return count($result) > 0 ? reset($result) : null; + return count($result) > 0 ? array_first($result) : null; + } + + /** + * Get a single expression value from the first result of a query. + * + * @return mixed + */ + public function rawValue(string $expression, array $bindings = []) + { + $result = (array) $this->selectRaw($expression, $bindings)->first(); + + return count($result) > 0 ? array_first($result) : null; + } + + /** + * Get a single column's value from the first result of a query if it's the sole matching record. + * + * @param string $column + * @return mixed + * + * @throws \Illuminate\Database\RecordsNotFoundException + * @throws \Illuminate\Database\MultipleRecordsFoundException + */ + public function soleValue($column) + { + $result = (array) $this->sole([$column]); + + return array_first($result); } /** * Execute the query as a "select" statement. * - * @param array|string $columns - * @return \Illuminate\Support\Collection + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns + * @return \Illuminate\Support\Collection */ public function get($columns = ['*']) { - return collect($this->onceWithColumns(Arr::wrap($columns), function () { - return $this->processor->processSelect($this, $this->runSelect()); - })); + $original = $this->columns; + + $this->columns ??= Arr::wrap($columns); + + $items = new Collection($this->processor->processSelect($this, $this->runSelect())); + + $this->columns = $original; + + return $this->applyAfterQueryCallbacks( + isset($this->groupLimit) ? $this->withoutGroupLimitKeys($items) : $items + ); } /** @@ -2093,26 +3492,55 @@ public function get($columns = ['*']) protected function runSelect() { return $this->connection->select( - $this->toSql(), $this->getBindings(), ! $this->useWritePdo + $this->toSql(), $this->getBindings(), ! $this->useWritePdo, $this->fetchUsing ); } + /** + * Remove the group limit keys from the results in the collection. + * + * @param \Illuminate\Support\Collection $items + * @return \Illuminate\Support\Collection + */ + protected function withoutGroupLimitKeys($items) + { + $keysToRemove = ['laravel_row']; + + if (is_string($this->groupLimit['column'])) { + $column = last(explode('.', $this->groupLimit['column'])); + + $keysToRemove[] = '@laravel_group := '.$this->grammar->wrap($column); + $keysToRemove[] = '@laravel_group := '.$this->grammar->wrap('pivot_'.$column); + } + + $items->each(function ($item) use ($keysToRemove) { + foreach ($keysToRemove as $key) { + unset($item->$key); + } + }); + + return $items; + } + /** * Paginate the given query into a simple paginator. * - * @param int $perPage - * @param array $columns + * @param int|\Closure $perPage + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns * @param string $pageName * @param int|null $page - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + * @param \Closure|int|null $total + * @return \Illuminate\Pagination\LengthAwarePaginator */ - public function paginate($perPage = 15, $columns = ['*'], $pageName = 'page', $page = null) + public function paginate($perPage = 15, $columns = ['*'], $pageName = 'page', $page = null, $total = null) { $page = $page ?: Paginator::resolveCurrentPage($pageName); - $total = $this->getCountForPagination($columns); + $total = value($total) ?? $this->getCountForPagination(); - $results = $total ? $this->forPage($page, $perPage)->get($columns) : collect(); + $perPage = value($perPage, $total); + + $results = $total ? $this->forPage($page, $perPage)->get($columns) : new Collection; return $this->paginator($results, $total, $perPage, $page, [ 'path' => Paginator::resolveCurrentPath(), @@ -2126,7 +3554,7 @@ public function paginate($perPage = 15, $columns = ['*'], $pageName = 'page', $p * This is more efficient on larger data-sets, etc. * * @param int $perPage - * @param array $columns + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns * @param string $pageName * @param int|null $page * @return \Illuminate\Contracts\Pagination\Paginator @@ -2135,7 +3563,7 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag { $page = $page ?: Paginator::resolveCurrentPage($pageName); - $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(), @@ -2143,11 +3571,61 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag ]); } + /** + * Get a paginator only supporting simple next and previous links. + * + * This is more efficient on larger data-sets, etc. + * + * @param int|null $perPage + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns + * @param string $cursorName + * @param \Illuminate\Pagination\Cursor|string|null $cursor + * @return \Illuminate\Contracts\Pagination\CursorPaginator + */ + public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'cursor', $cursor = null) + { + 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->orders) && empty($this->unionOrders)) { + $this->enforceOrderBy(); + } + + $reverseDirection = function ($order) { + if (! isset($order['direction'])) { + return $order; + } + + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + }; + + if ($shouldReverse) { + $this->orders = (new Collection($this->orders))->map($reverseDirection)->toArray(); + $this->unionOrders = (new Collection($this->unionOrders))->map($reverseDirection)->toArray(); + } + + $orders = ! empty($this->unionOrders) ? $this->unionOrders : $this->orders; + + return (new Collection($orders)) + ->filter(fn ($order) => Arr::has($order, 'direction')) + ->values(); + } + /** * Get the count of the total records for the paginator. * - * @param array $columns - * @return int + * @param array $columns + * @return int<0, max> */ public function getCountForPagination($columns = ['*']) { @@ -2156,9 +3634,7 @@ public function getCountForPagination($columns = ['*']) // Once we have run the pagination count query, we will get the resulting count and // take into account what type of query it was. When there is a group by we will // just return the count of the entire results set since that will be correct. - if (isset($this->groups)) { - return count($results); - } elseif (! isset($results[0])) { + if (! isset($results[0])) { return 0; } elseif (is_object($results[0])) { return (int) $results[0]->aggregate; @@ -2170,37 +3646,63 @@ public function getCountForPagination($columns = ['*']) /** * Run a pagination count query. * - * @param array $columns - * @return array + * @param array $columns + * @return array */ protected function runPaginationCountQuery($columns = ['*']) { - $without = $this->unions ? ['orders', 'limit', 'offset'] : ['columns', 'orders', 'limit', 'offset']; + if ($this->groups || $this->havings) { + $clone = $this->cloneForPaginationCount(); + + if (is_null($clone->columns) && ! empty($this->joins)) { + $clone->select($this->from.'.*'); + } + + return $this->newQuery() + ->from(new Expression('('.$clone->toSql().') as '.$this->grammar->wrap('aggregate_table'))) + ->mergeBindings($clone) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } + + $without = $this->unions ? ['unionOrders', 'unionLimit', 'unionOffset'] : ['columns', 'orders', 'limit', 'offset']; return $this->cloneWithout($without) - ->cloneWithoutBindings($this->unions ? ['order'] : ['select', 'order']) - ->setAggregate('count', $this->withoutSelectAliases($columns)) - ->get()->all(); + ->cloneWithoutBindings($this->unions ? ['unionOrder'] : ['select', 'order']) + ->setAggregate('count', $this->withoutSelectAliases($columns)) + ->get()->all(); + } + + /** + * Clone the existing query instance for usage in a pagination subquery. + * + * @return self + */ + protected function cloneForPaginationCount() + { + return $this->cloneWithout(['orders', 'limit', 'offset']) + ->cloneWithoutBindings(['order']); } /** * Remove the column aliases since they will break count queries. * - * @param array $columns - * @return array + * @param array $columns + * @return array */ protected function withoutSelectAliases(array $columns) { return array_map(function ($column) { return is_string($column) && ($aliasPosition = stripos($column, ' as ')) !== false - ? substr($column, 0, $aliasPosition) : $column; + ? substr($column, 0, $aliasPosition) + : $column; }, $columns); } /** - * Get a generator for the given query. + * Get a lazy collection for the given query. * - * @return \Generator + * @return \Illuminate\Support\LazyCollection */ public function cursor() { @@ -2208,53 +3710,13 @@ public function cursor() $this->columns = ['*']; } - return $this->connection->cursor( - $this->toSql(), $this->getBindings(), ! $this->useWritePdo - ); - } - - /** - * Chunk the results of a query by comparing numeric IDs. - * - * @param int $count - * @param callable $callback - * @param string $column - * @param string|null $alias - * @return bool - */ - public function chunkById($count, callable $callback, $column = 'id', $alias = null) - { - $alias = $alias ?: $column; - - $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; - } - - $lastId = $results->last()->{$alias}; - - unset($results); - } while ($countResults == $count); - - return true; + return (new LazyCollection(function () { + yield from $this->connection->cursor( + $this->toSql(), $this->getBindings(), ! $this->useWritePdo, $this->fetchUsing + ); + }))->map(function ($item) { + return $this->applyAfterQueryCallbacks(new Collection([$item]))->first(); + })->reject(fn ($item) => is_null($item)); } /** @@ -2272,28 +3734,29 @@ protected function enforceOrderBy() } /** - * Get an array with the values of a given column. + * Get a collection instance containing the values of a given column. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string|null $key - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function pluck($column, $key = null) { + $original = $this->columns; + // First, we will need to select the results of the query accounting for the // given columns / key. Once we have the results, we will be able to take // the results and get the exact data that was requested for the query. - $queryResult = $this->onceWithColumns( - is_null($key) ? [$column] : [$column, $key], - function () { - return $this->processor->processSelect( - $this, $this->runSelect() - ); - } - ); + $this->columns ??= is_null($key) || $key === $column + ? [$column] + : [$column, $key]; + + $queryResult = $this->processor->processSelect($this, $this->runSelect()); + + $this->columns = $original; if (empty($queryResult)) { - return collect(); + return new Collection; } // If the columns are qualified with a table or have an alias, we cannot use @@ -2303,9 +3766,11 @@ function () { $key = $this->stripTableForPluck($key); - return is_array($queryResult[0]) - ? $this->pluckFromArrayColumn($queryResult, $column, $key) - : $this->pluckFromObjectColumn($queryResult, $column, $key); + return $this->applyAfterQueryCallbacks( + is_array($queryResult[0]) + ? $this->pluckFromArrayColumn($queryResult, $column, $key) + : $this->pluckFromObjectColumn($queryResult, $column, $key) + ); } /** @@ -2316,15 +3781,25 @@ function () { */ protected function stripTableForPluck($column) { - return is_null($column) ? $column : last(preg_split('~\.| ~', $column)); + if (is_null($column)) { + return $column; + } + + $columnString = $column instanceof ExpressionContract + ? $this->grammar->getValue($column) + : $column; + + $separator = str_contains(strtolower($columnString), ' as ') ? ' as ' : '\.'; + + return last(preg_split('~'.$separator.'~i', $columnString)); } /** * Retrieve column values from rows represented as objects. * * @param array $queryResult - * @param string $column - * @param string $key + * @param string $column + * @param string $key * @return \Illuminate\Support\Collection */ protected function pluckFromObjectColumn($queryResult, $column, $key) @@ -2341,15 +3816,15 @@ protected function pluckFromObjectColumn($queryResult, $column, $key) } } - return collect($results); + return new Collection($results); } /** * Retrieve column values from rows represented as arrays. * * @param array $queryResult - * @param string $column - * @param string $key + * @param string $column + * @param string $key * @return \Illuminate\Support\Collection */ protected function pluckFromArrayColumn($queryResult, $column, $key) @@ -2366,7 +3841,7 @@ protected function pluckFromArrayColumn($queryResult, $column, $key) } } - return collect($results); + return new Collection($results); } /** @@ -2388,13 +3863,15 @@ public function implode($column, $glue = '') */ public function exists() { + $this->applyBeforeQueryCallbacks(); + $results = $this->connection->select( $this->grammar->compileExists($this), $this->getBindings(), ! $this->useWritePdo ); - // If the results has rows, we will get the row and see if the exists column is a - // boolean true. If there is no results for this query we will return false as - // there are no rows for this query at all and we can return that info here. + // If the results have rows, we will get the row and see if the exists column is a + // boolean true. If there are no results for this query we will return false as + // there are no rows for this query at all, and we can return that info here. if (isset($results[0])) { $results = (array) $results[0]; @@ -2414,11 +3891,31 @@ public function doesntExist() return ! $this->exists(); } + /** + * Execute the given callback if no rows exist for the current query. + * + * @return mixed + */ + public function existsOr(Closure $callback) + { + return $this->exists() ? true : $callback(); + } + + /** + * Execute the given callback if rows exist for the current query. + * + * @return mixed + */ + public function doesntExistOr(Closure $callback) + { + return $this->doesntExist() ? true : $callback(); + } + /** * Retrieve the "count" result of the query. * - * @param string $columns - * @return int + * @param \Illuminate\Contracts\Database\Query\Expression|string $columns + * @return int<0, max> */ public function count($columns = '*') { @@ -2428,7 +3925,7 @@ public function count($columns = '*') /** * Retrieve the minimum value of a given column. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return mixed */ public function min($column) @@ -2439,7 +3936,7 @@ public function min($column) /** * Retrieve the maximum value of a given column. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return mixed */ public function max($column) @@ -2450,7 +3947,7 @@ public function max($column) /** * Retrieve the sum of the values of a given column. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return mixed */ public function sum($column) @@ -2463,7 +3960,7 @@ public function sum($column) /** * Retrieve the average of the values of a given column. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return mixed */ public function avg($column) @@ -2474,7 +3971,7 @@ public function avg($column) /** * Alias for the "avg" method. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return mixed */ public function average($column) @@ -2486,15 +3983,15 @@ public function average($column) * Execute an aggregate function on the database. * * @param string $function - * @param array $columns + * @param array $columns * @return mixed */ public function aggregate($function, $columns = ['*']) { - $results = $this->cloneWithout($this->unions ? [] : ['columns']) - ->cloneWithoutBindings($this->unions ? [] : ['select']) - ->setAggregate($function, $columns) - ->get($columns); + $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + ->setAggregate($function, $columns) + ->get($columns); if (! $results->isEmpty()) { return array_change_key_case((array) $results[0])['aggregate']; @@ -2505,7 +4002,7 @@ public function aggregate($function, $columns = ['*']) * Execute a numeric aggregate function on the database. * * @param string $function - * @param array $columns + * @param array $columns * @return float|int */ public function numericAggregate($function, $columns = ['*']) @@ -2526,15 +4023,16 @@ public function numericAggregate($function, $columns = ['*']) // If the result doesn't contain a decimal place, we will assume it is an int then // cast it to one. When it does we will cast it to a float since it needs to be // cast to the expected data type for the developers out of pure convenience. - return strpos((string) $result, '.') === false - ? (int) $result : (float) $result; + return ! str_contains((string) $result, '.') + ? (int) $result + : (float) $result; } /** * Set the aggregate property without running the query. * * @param string $function - * @param array $columns + * @param array<\Illuminate\Contracts\Database\Query\Expression|string> $columns * @return $this */ protected function setAggregate($function, $columns) @@ -2551,52 +4049,98 @@ protected function setAggregate($function, $columns) } /** - * Execute the given callback while selecting the given columns. + * Insert new records into the database. * - * After running the callback, the columns are reset to the original value. - * - * @param array $columns - * @param callable $callback - * @return mixed + * @return bool */ - protected function onceWithColumns($columns, $callback) + public function insert(array $values) { - $original = $this->columns; + // Since every insert gets treated like a batch insert, we will make sure the + // bindings are structured in a way that is convenient when building these + // inserts statements by verifying these elements are actually an array. + if (empty($values)) { + return true; + } - if (is_null($original)) { - $this->columns = $columns; + if (! is_array(array_first($values))) { + $values = [$values]; } - $result = $callback(); + // Here, we will sort the insert keys for every record so that each insert is + // in the same order for the record. We need to make sure this is the case + // so there are not any errors or problems when inserting these records. + else { + foreach ($values as $key => $value) { + ksort($value); - $this->columns = $original; + $values[$key] = $value; + } + } - return $result; + $this->applyBeforeQueryCallbacks(); + + // Finally, we will run this query against the database connection and return + // the results. We will need to also flatten these bindings before running + // the query so they are all in one huge, flattened array for execution. + return $this->connection->insert( + $this->grammar->compileInsert($this, $values), + $this->cleanBindings(Arr::flatten($values, 1)) + ); + } + + /** + * Insert new records into the database while ignoring errors. + * + * @return int<0, max> + */ + public function insertOrIgnore(array $values) + { + if (empty($values)) { + return 0; + } + + if (! is_array(array_first($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + $this->applyBeforeQueryCallbacks(); + + return $this->connection->affectingStatement( + $this->grammar->compileInsertOrIgnore($this, $values), + $this->cleanBindings(Arr::flatten($values, 1)) + ); } /** - * Insert a new record into the database. + * Insert new records into the database while ignoring specific conflicts and returning specified columns. * - * @param array $values - * @return bool + * @param non-empty-string|non-empty-array $uniqueBy + * @param non-empty-array $returning + * @return \Illuminate\Support\Collection */ - public function insert(array $values) + public function insertOrIgnoreReturning(array $values, array|string $uniqueBy, array $returning = ['*']) { - // Since every insert gets treated like a batch insert, we will make sure the - // bindings are structured in a way that is convenient when building these - // inserts statements by verifying these elements are actually an array. if (empty($values)) { - return true; + return new Collection; } - if (! is_array(reset($values))) { - $values = [$values]; + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); } - // Here, we will sort the insert keys for every record so that each insert is - // in the same order for the record. We need to make sure this is the case - // so there are not any errors or problems when inserting these records. - else { + if ($returning === []) { + throw new InvalidArgumentException('The returning columns must not be empty.'); + } + + if (! is_array(array_first($values))) { + $values = [$values]; + } else { foreach ($values as $key => $value) { ksort($value); @@ -2604,24 +4148,27 @@ public function insert(array $values) } } - // Finally, we will run this query against the database connection and return - // the results. We will need to also flatten these bindings before running - // the query so they are all in one huge, flattened array for execution. - return $this->connection->insert( - $this->grammar->compileInsert($this, $values), - $this->cleanBindings(Arr::flatten($values, 1)) + $this->applyBeforeQueryCallbacks(); + + $sql = $this->grammar->compileInsertOrIgnoreReturning($this, $values, (array) $uniqueBy, $returning); + + $this->connection->recordsHaveBeenModified(); + + return new Collection( + $this->connection->selectFromWriteConnection($sql, $this->cleanBindings(Arr::flatten($values, 1))) ); } /** * Insert a new record and get the value of the primary key. * - * @param array $values * @param string|null $sequence * @return int */ public function insertGetId(array $values, $sequence = null) { + $this->applyBeforeQueryCallbacks(); + $sql = $this->grammar->compileInsertGetId($this, $values, $sequence); $values = $this->cleanBindings($values); @@ -2632,49 +4179,160 @@ public function insertGetId(array $values, $sequence = null) /** * Insert new records into the table using a subquery. * - * @param array $columns - * @param \Closure|\Illuminate\Database\Query\Builder|string $query - * @return bool + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query + * @return int */ public function insertUsing(array $columns, $query) { + $this->applyBeforeQueryCallbacks(); + [$sql, $bindings] = $this->createSub($query); - return $this->connection->insert( + return $this->connection->affectingStatement( $this->grammar->compileInsertUsing($this, $columns, $sql), $this->cleanBindings($bindings) ); } /** - * Update a record in the database. + * Insert new records into the table using a subquery while ignoring errors. * - * @param array $values + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @return int */ + public function insertOrIgnoreUsing(array $columns, $query) + { + $this->applyBeforeQueryCallbacks(); + + [$sql, $bindings] = $this->createSub($query); + + return $this->connection->affectingStatement( + $this->grammar->compileInsertOrIgnoreUsing($this, $columns, $sql), + $this->cleanBindings($bindings) + ); + } + + /** + * Update records in the database. + * + * @return int<0, max> + */ public function update(array $values) { - $sql = $this->grammar->compileUpdate($this, $values); + $this->applyBeforeQueryCallbacks(); + + $values = (new Collection($values))->map(function ($value) { + if (! $value instanceof self && ! $value instanceof EloquentBuilder && ! $value instanceof Relation) { + return ['value' => $value, 'bindings' => match (true) { + $value instanceof Collection => $value->all(), + $value instanceof UnitEnum => enum_value($value), + default => $value, + }]; + } + + [$query, $bindings] = $this->parseSub($value); + + return ['value' => new Expression("({$query})"), 'bindings' => fn () => $bindings]; + }); + + $sql = $this->grammar->compileUpdate($this, $values->map(fn ($value) => $value['value'])->all()); + + return $this->connection->update($sql, $this->cleanBindings( + $this->grammar->prepareBindingsForUpdate($this->bindings, $values->map(fn ($value) => $value['bindings'])->all()) + )); + } + + /** + * Update records in a PostgreSQL database using the update from syntax. + * + * @return int + * + * @throws \LogicException + */ + public function updateFrom(array $values) + { + if (! method_exists($this->grammar, 'compileUpdateFrom')) { + throw new LogicException('This database engine does not support the updateFrom method.'); + } + + $this->applyBeforeQueryCallbacks(); + + $sql = $this->grammar->compileUpdateFrom($this, $values); return $this->connection->update($sql, $this->cleanBindings( - $this->grammar->prepareBindingsForUpdate($this->bindings, $values) + $this->grammar->prepareBindingsForUpdateFrom($this->bindings, $values) )); } /** * Insert or update a record matching the attributes, and fill it with values. * - * @param array $attributes - * @param array $values * @return bool */ - public function updateOrInsert(array $attributes, array $values = []) + public function updateOrInsert(array $attributes, array|callable $values = []) { - if (! $this->where($attributes)->exists()) { + $exists = $this->where($attributes)->exists(); + + if ($values instanceof Closure) { + $values = $values($exists); + } + + if (! $exists) { return $this->insert(array_merge($attributes, $values)); } - return (bool) $this->take(1)->update($values); + if (empty($values)) { + return true; + } + + return (bool) $this->limit(1)->update($values); + } + + /** + * Insert new records or update the existing ones. + * + * @param non-empty-string|non-empty-array $uniqueBy + * @return int + */ + public function upsert(array $values, array|string $uniqueBy, ?array $update = null) + { + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); + } + + if (empty($values)) { + return 0; + } elseif ($update === []) { + return (int) $this->insert($values); + } + + if (! is_array(array_first($values))) { + $values = [$values]; + } else { + foreach ($values as $key => $value) { + ksort($value); + + $values[$key] = $value; + } + } + + if (is_null($update)) { + $update = array_keys(array_first($values)); + } + + $this->applyBeforeQueryCallbacks(); + + $bindings = $this->cleanBindings(array_merge( + Arr::flatten($values, 1), + (new Collection($update)) + ->reject(fn ($value, $key) => is_int($key)) + ->all() + )); + + return $this->connection->affectingStatement( + $this->grammar->compileUpsert($this, $values, (array) $uniqueBy, $update), + $bindings + ); } /** @@ -2682,8 +4340,9 @@ public function updateOrInsert(array $attributes, array $values = []) * * @param string $column * @param float|int $amount - * @param array $extra - * @return int + * @return int<0, max> + * + * @throws \InvalidArgumentException */ public function increment($column, $amount = 1, array $extra = []) { @@ -2691,11 +4350,31 @@ public function increment($column, $amount = 1, array $extra = []) throw new InvalidArgumentException('Non-numeric value passed to increment method.'); } - $wrapped = $this->grammar->wrap($column); + return $this->incrementEach([$column => $amount], $extra); + } + + /** + * Increment the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int<0, max> + * + * @throws \InvalidArgumentException + */ + public function incrementEach(array $columns, array $extra = []) + { + foreach ($columns as $column => $amount) { + if (! is_numeric($amount)) { + throw new InvalidArgumentException("Non-numeric value passed as increment amount for column: '$column'."); + } elseif (! is_string($column)) { + throw new InvalidArgumentException('Non-associative array passed to incrementEach method.'); + } - $columns = array_merge([$column => $this->raw("$wrapped + $amount")], $extra); + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} + $amount"); + } - return $this->update($columns); + return $this->update(array_merge($columns, $extra)); } /** @@ -2703,8 +4382,9 @@ public function increment($column, $amount = 1, array $extra = []) * * @param string $column * @param float|int $amount - * @param array $extra - * @return int + * @return int<0, max> + * + * @throws \InvalidArgumentException */ public function decrement($column, $amount = 1, array $extra = []) { @@ -2712,15 +4392,35 @@ public function decrement($column, $amount = 1, array $extra = []) throw new InvalidArgumentException('Non-numeric value passed to decrement method.'); } - $wrapped = $this->grammar->wrap($column); + return $this->decrementEach([$column => $amount], $extra); + } + + /** + * Decrement the given column's values by the given amounts. + * + * @param array $columns + * @param array $extra + * @return int<0, max> + * + * @throws \InvalidArgumentException + */ + public function decrementEach(array $columns, array $extra = []) + { + foreach ($columns as $column => $amount) { + if (! is_numeric($amount)) { + throw new InvalidArgumentException("Non-numeric value passed as decrement amount for column: '$column'."); + } elseif (! is_string($column)) { + throw new InvalidArgumentException('Non-associative array passed to decrementEach method.'); + } - $columns = array_merge([$column => $this->raw("$wrapped - $amount")], $extra); + $columns[$column] = $this->raw("{$this->grammar->wrap($column)} - $amount"); + } - return $this->update($columns); + return $this->update(array_merge($columns, $extra)); } /** - * Delete a record from the database. + * Delete records from the database. * * @param mixed $id * @return int @@ -2734,6 +4434,8 @@ public function delete($id = null) $this->where($this->from.'.id', '=', $id); } + $this->applyBeforeQueryCallbacks(); + return $this->connection->delete( $this->grammar->compileDelete($this), $this->cleanBindings( $this->grammar->prepareBindingsForDelete($this->bindings) @@ -2742,12 +4444,14 @@ public function delete($id = null) } /** - * Run a truncate statement on the table. + * Run a "truncate" statement on the table. * * @return void */ public function truncate() { + $this->applyBeforeQueryCallbacks(); + foreach ($this->grammar->compileTruncate($this) as $sql => $bindings) { $this->connection->statement($sql, $bindings); } @@ -2773,21 +4477,69 @@ protected function forSubQuery() return $this->newQuery(); } + /** + * Get all of the query builder's columns in a text-only array with all expressions evaluated. + * + * @return list + */ + public function getColumns() + { + return ! is_null($this->columns) + ? array_map(fn ($column) => $this->grammar->getValue($column), $this->columns) + : []; + } + /** * Create a raw database expression. * * @param mixed $value - * @return \Illuminate\Database\Query\Expression + * @return \Illuminate\Contracts\Database\Query\Expression */ public function raw($value) { return $this->connection->raw($value); } + /** + * Get the query builder instances that are used in the union of the query. + * + * @return \Illuminate\Support\Collection + */ + protected function getUnionBuilders() + { + return isset($this->unions) + ? (new Collection($this->unions))->pluck('query') + : new Collection; + } + + /** + * Get the "limit" value for the query or null if it's not set. + * + * @return mixed + */ + public function getLimit() + { + $value = $this->unions ? $this->unionLimit : $this->limit; + + return ! is_null($value) ? (int) $value : null; + } + + /** + * Get the "offset" value for the query or null if it's not set. + * + * @return mixed + */ + public function getOffset() + { + $value = $this->unions ? $this->unionOffset : $this->offset; + + return ! is_null($value) ? (int) $value : null; + } + /** * Get the current query value bindings in a flattened array. * - * @return array + * @return list */ public function getBindings() { @@ -2797,7 +4549,17 @@ public function getBindings() /** * Get the raw array of bindings. * - * @return array + * @return array{ + * select: list, + * from: list, + * join: list, + * where: list, + * groupBy: list, + * having: list, + * order: list, + * union: list, + * unionOrder: list, + * } */ public function getRawBindings() { @@ -2807,8 +4569,8 @@ public function getRawBindings() /** * Set the bindings on the query builder. * - * @param array $bindings - * @param string $type + * @param list $bindings + * @param "select"|"from"|"join"|"where"|"groupBy"|"having"|"order"|"union"|"unionOrder" $type * @return $this * * @throws \InvalidArgumentException @@ -2827,8 +4589,8 @@ public function setBindings(array $bindings, $type = 'where') /** * Add a binding to the query. * - * @param mixed $value - * @param string $type + * @param mixed $value + * @param "select"|"from"|"join"|"where"|"groupBy"|"having"|"order"|"union"|"unionOrder" $type * @return $this * * @throws \InvalidArgumentException @@ -2840,18 +4602,32 @@ public function addBinding($value, $type = 'where') } if (is_array($value)) { - $this->bindings[$type] = array_values(array_merge($this->bindings[$type], $value)); + $this->bindings[$type] = array_values(array_map( + $this->castBinding(...), + array_merge($this->bindings[$type], $value), + )); } else { - $this->bindings[$type][] = $value; + $this->bindings[$type][] = $this->castBinding($value); } return $this; } + /** + * Cast the given binding value. + * + * @param mixed $value + * @return mixed + */ + public function castBinding($value) + { + return enum_value($value); + } + /** * Merge an array of bindings into our bindings. * - * @param \Illuminate\Database\Query\Builder $query + * @param self $query * @return $this */ public function mergeBindings(self $query) @@ -2864,14 +4640,39 @@ public function mergeBindings(self $query) /** * Remove all of the expressions from a list of bindings. * - * @param array $bindings - * @return array + * @param array $bindings + * @return list */ - protected function cleanBindings(array $bindings) + public function cleanBindings(array $bindings) { - return array_values(array_filter($bindings, function ($binding) { - return ! $binding instanceof Expression; - })); + return (new Collection($bindings)) + ->reject(function ($binding) { + return $binding instanceof ExpressionContract; + }) + ->map($this->castBinding(...)) + ->values() + ->all(); + } + + /** + * Get a scalar type value from an unknown type of input. + * + * @param mixed $value + * @return mixed + */ + protected function flattenValue($value) + { + return is_array($value) ? head(Arr::flatten($value)) : $value; + } + + /** + * Get the default key name of the table. + * + * @return string + */ + protected function defaultKeyName() + { + return 'id'; } /** @@ -2884,6 +4685,20 @@ public function getConnection() return $this->connection; } + /** + * Ensure the database connection supports vector queries. + * + * @return void + * + * @throws \RuntimeException + */ + protected function ensureConnectionSupportsVectors() + { + if (! $this->connection instanceof PostgresConnection) { + throw new RuntimeException('Vector distance queries are only supported by Postgres.'); + } + } + /** * Get the database query processor instance. * @@ -2905,7 +4720,7 @@ public function getGrammar() } /** - * Use the write pdo for query. + * Use the "write" PDO connection when executing the query. * * @return $this */ @@ -2916,15 +4731,51 @@ public function useWritePdo() return $this; } + /** + * Specify arguments for the PDOStatement::fetchAll / fetch functions. + * + * @param mixed ...$fetchUsing + * @return $this + */ + public function fetchUsing(...$fetchUsing) + { + $this->fetchUsing = $fetchUsing; + + return $this; + } + + /** + * Determine if the value is a query builder instance or a Closure. + * + * @param mixed $value + * @return bool + */ + protected function isQueryable($value) + { + return $value instanceof self || + $value instanceof EloquentBuilder || + $value instanceof Relation || + $value instanceof Closure; + } + + /** + * Clone the query. + * + * @return static + */ + public function clone() + { + return clone $this; + } + /** * Clone the query without the given properties. * - * @param array $properties * @return static */ public function cloneWithout(array $properties) { - return tap(clone $this, function ($clone) use ($properties) { + return tap($this->clone(), function ($clone) use ($properties) { foreach ($properties as $property) { $clone->{$property} = null; } @@ -2934,23 +4785,71 @@ public function cloneWithout(array $properties) /** * Clone the query without the given bindings. * - * @param array $except * @return static */ public function cloneWithoutBindings(array $except) { - return tap(clone $this, function ($clone) use ($except) { + return tap($this->clone(), function ($clone) use ($except) { foreach ($except as $type) { $clone->bindings[$type] = []; } }); } + /** + * Dump the current SQL and bindings. + * + * @param mixed ...$args + * @return $this + */ + public function dump(...$args) + { + dump( + $this->toSql(), + $this->getBindings(), + ...$args, + ); + + return $this; + } + + /** + * Dump the raw current SQL with embedded bindings. + * + * @return $this + */ + public function dumpRawSql() + { + dump($this->toRawSql()); + + return $this; + } + + /** + * Die and dump the current SQL and bindings. + * + * @return never + */ + public function dd() + { + dd($this->toSql(), $this->getBindings()); + } + + /** + * Die and dump the current SQL with embedded bindings. + * + * @return never + */ + public function ddRawSql() + { + dd($this->toRawSql()); + } + /** * Handle dynamic method calls into the method. * * @param string $method - * @param array $parameters + * @param array $parameters * @return mixed * * @throws \BadMethodCallException @@ -2961,7 +4860,7 @@ public function __call($method, $parameters) return $this->macroCall($method, $parameters); } - if (Str::startsWith($method, 'where')) { + if (str_starts_with($method, 'where')) { return $this->dynamicWhere($method, $parameters); } diff --git a/Query/Expression.php b/Query/Expression.php index de69029980..839dea833a 100755 --- a/Query/Expression.php +++ b/Query/Expression.php @@ -2,43 +2,32 @@ namespace Illuminate\Database\Query; -class Expression -{ - /** - * The value of the expression. - * - * @var mixed - */ - protected $value; +use Illuminate\Contracts\Database\Query\Expression as ExpressionContract; +use Illuminate\Database\Grammar; +/** + * @template TValue of string|int|float + */ +class Expression implements ExpressionContract +{ /** * Create a new raw query expression. * - * @param mixed $value - * @return void + * @param TValue $value */ - public function __construct($value) - { - $this->value = $value; + public function __construct( + protected $value, + ) { } /** * Get the value of the expression. * - * @return mixed + * @param \Illuminate\Database\Grammar $grammar + * @return TValue */ - public function getValue() + public function getValue(Grammar $grammar) { return $this->value; } - - /** - * Get the value of the expression. - * - * @return string - */ - public function __toString() - { - return (string) $this->getValue(); - } } diff --git a/Query/Grammars/Grammar.php b/Query/Grammars/Grammar.php index 3d6608de26..aa0dd52344 100755 --- a/Query/Grammars/Grammar.php +++ b/Query/Grammars/Grammar.php @@ -2,15 +2,20 @@ namespace Illuminate\Database\Query\Grammars; -use RuntimeException; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Concerns\CompilesJsonPaths; +use Illuminate\Database\Grammar as BaseGrammar; use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\JoinClause; -use Illuminate\Database\Grammar as BaseGrammar; +use Illuminate\Database\Query\JoinLateralClause; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use RuntimeException; class Grammar extends BaseGrammar { + use CompilesJsonPaths; + /** * The grammar specific operators. * @@ -19,14 +24,22 @@ class Grammar extends BaseGrammar protected $operators = []; /** - * The components that make up a select clause. + * The grammar specific bitwise operators. * * @var array */ + protected $bitwiseOperators = []; + + /** + * The components that make up a select clause. + * + * @var string[] + */ protected $selectComponents = [ 'aggregate', 'columns', 'from', + 'indexHint', 'joins', 'wheres', 'groups', @@ -34,7 +47,6 @@ class Grammar extends BaseGrammar 'orders', 'limit', 'offset', - 'unions', 'lock', ]; @@ -46,10 +58,21 @@ class Grammar extends BaseGrammar */ public function compileSelect(Builder $query) { - if ($query->unions && $query->aggregate) { + if (($query->unions || $query->havings) && $query->aggregate) { return $this->compileUnionAggregate($query); } + // If a "group limit" is in place, we will need to compile the SQL to use a + // different syntax. This primarily supports limits on eager loads using + // Eloquent. We'll also set the columns if they have not been defined. + if (isset($query->groupLimit)) { + if (is_null($query->columns)) { + $query->columns = ['*']; + } + + return $this->compileGroupLimit($query); + } + // If the query does not have any columns set, we'll set the columns to the // * character to just get all of the columns from the database. Then we // can build the query and concatenate all the pieces together as one. @@ -66,6 +89,10 @@ public function compileSelect(Builder $query) $this->compileComponents($query)) ); + if ($query->unions) { + $sql = $this->wrapUnion($sql).' '.$this->compileUnions($query); + } + $query->columns = $original; return $sql; @@ -82,10 +109,7 @@ protected function compileComponents(Builder $query) $sql = []; foreach ($this->selectComponents as $component) { - // To compile the query, we'll spin through each component of the query and - // see if that component exists. If it does we'll just call the compiler - // function for the component which is responsible for making the SQL. - if (isset($query->$component) && ! is_null($query->$component)) { + if (isset($query->$component)) { $method = 'compile'.ucfirst($component); $sql[$component] = $this->$method($query, $query->$component); @@ -99,7 +123,7 @@ protected function compileComponents(Builder $query) * Compile an aggregated select clause. * * @param \Illuminate\Database\Query\Builder $query - * @param array $aggregate + * @param array{function: string, columns: array<\Illuminate\Contracts\Database\Query\Expression|string>} $aggregate * @return string */ protected function compileAggregate(Builder $query, $aggregate) @@ -109,7 +133,9 @@ protected function compileAggregate(Builder $query, $aggregate) // If the query has a "distinct" constraint and we're not asking for all columns // we need to prepend "distinct" onto the column name so that the query takes // it into account when it performs the aggregating operations on the data. - if ($query->distinct && $column !== '*') { + if (is_array($query->distinct)) { + $column = 'distinct '.$this->columnize($query->distinct); + } elseif ($query->distinct && $column !== '*') { $column = 'distinct '.$column; } @@ -132,7 +158,11 @@ protected function compileColumns(Builder $query, $columns) return; } - $select = $query->distinct ? 'select distinct ' : 'select '; + if ($query->distinct) { + $select = 'select distinct '; + } else { + $select = 'select '; + } return $select.$this->columnize($columns); } @@ -158,26 +188,58 @@ protected function compileFrom(Builder $query, $table) */ protected function compileJoins(Builder $query, $joins) { - return collect($joins)->map(function ($join) use ($query) { + return (new Collection($joins))->map(function ($join) use ($query) { $table = $this->wrapTable($join->table); $nestedJoins = is_null($join->joins) ? '' : ' '.$this->compileJoins($query, $join->joins); $tableAndNestedJoins = is_null($join->joins) ? $table : '('.$table.$nestedJoins.')'; - return trim("{$join->type} join {$tableAndNestedJoins} {$this->compileWheres($join)}"); + if ($join instanceof JoinLateralClause) { + return $this->compileJoinLateral($join, $tableAndNestedJoins); + } + + $joinWord = ($join->type === 'straight_join' && $this->supportsStraightJoins()) ? '' : ' join'; + + return trim("{$join->type}{$joinWord} {$tableAndNestedJoins} {$this->compileWheres($join)}"); })->implode(' '); } + /** + * Compile a "lateral join" clause. + * + * @param \Illuminate\Database\Query\JoinLateralClause $join + * @param string $expression + * @return string + * + * @throws \RuntimeException + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + throw new RuntimeException('This database engine does not support lateral joins.'); + } + + /** + * Determine if the grammar supports straight joins. + * + * @return bool + * + * @throws \RuntimeException + */ + protected function supportsStraightJoins() + { + throw new RuntimeException('This database engine does not support straight joins.'); + } + /** * Compile the "where" portions of the query. * * @param \Illuminate\Database\Query\Builder $query * @return string */ - protected function compileWheres(Builder $query) + public function compileWheres(Builder $query) { - // Each type of where clauses has its own compiler function which is responsible + // Each type of where clause has its own compiler function, which is responsible // for actually creating the where clauses SQL. This helps keep the code nice // and maintainable since each clause has a very small method that it uses. if (is_null($query->wheres)) { @@ -202,9 +264,9 @@ protected function compileWheres(Builder $query) */ protected function compileWheresToArray($query) { - return collect($query->wheres)->map(function ($where) use ($query) { - return $where['boolean'].' '.$this->{"where{$where['type']}"}($query, $where); - })->all(); + return (new Collection($query->wheres)) + ->map(fn ($where) => $where['boolean'].' '.$this->{"where{$where['type']}"}($query, $where)) + ->all(); } /** @@ -230,7 +292,7 @@ protected function concatenateWhereClauses($query, $sql) */ protected function whereRaw(Builder $query, $where) { - return $where['sql']; + return $where['sql'] instanceof Expression ? $where['sql']->getValue($this) : $where['sql']; } /** @@ -244,81 +306,91 @@ protected function whereBasic(Builder $query, $where) { $value = $this->parameter($where['value']); - return $this->wrap($where['column']).' '.$where['operator'].' '.$value; + $operator = str_replace('?', '??', $where['operator']); + + return $this->wrap($where['column']).' '.$operator.' '.$value; } /** - * Compile a "where in" clause. + * Compile a bitwise operator where clause. * * @param \Illuminate\Database\Query\Builder $query * @param array $where * @return string */ - protected function whereIn(Builder $query, $where) + protected function whereBitwise(Builder $query, $where) { - if (! empty($where['values'])) { - return $this->wrap($where['column']).' in ('.$this->parameterize($where['values']).')'; - } - - return '0 = 1'; + return $this->whereBasic($query, $where); } /** - * Compile a "where not in" clause. + * Compile a "where like" clause. * * @param \Illuminate\Database\Query\Builder $query * @param array $where * @return string + * + * @throws \RuntimeException */ - protected function whereNotIn(Builder $query, $where) + protected function whereLike(Builder $query, $where) { - if (! empty($where['values'])) { - return $this->wrap($where['column']).' not in ('.$this->parameterize($where['values']).')'; + if ($where['caseSensitive']) { + throw new RuntimeException('This database engine does not support case sensitive like operations.'); } - return '1 = 1'; + $where['operator'] = $where['not'] ? 'not like' : 'like'; + + return $this->whereBasic($query, $where); } /** - * Compile a "where not in raw" clause. - * - * For safety, whereIntegerInRaw ensures this method is only used with integer values. + * Compile a "where in" clause. * * @param \Illuminate\Database\Query\Builder $query * @param array $where * @return string */ - protected function whereNotInRaw(Builder $query, $where) + protected function whereIn(Builder $query, $where) { if (! empty($where['values'])) { - return $this->wrap($where['column']).' not in ('.implode(', ', $where['values']).')'; + return $this->wrap($where['column']).' in ('.$this->parameterize($where['values']).')'; } - return '1 = 1'; + return '0 = 1'; } /** - * Compile a where in sub-select clause. + * Compile a "where not in" clause. * * @param \Illuminate\Database\Query\Builder $query * @param array $where * @return string */ - protected function whereInSub(Builder $query, $where) + protected function whereNotIn(Builder $query, $where) { - return $this->wrap($where['column']).' in ('.$this->compileSelect($where['query']).')'; + if (! empty($where['values'])) { + return $this->wrap($where['column']).' not in ('.$this->parameterize($where['values']).')'; + } + + return '1 = 1'; } /** - * Compile a where not in sub-select clause. + * Compile a "where not in raw" clause. + * + * For safety, whereIntegerInRaw ensures this method is only used with integer values. * * @param \Illuminate\Database\Query\Builder $query * @param array $where * @return string */ - protected function whereNotInSub(Builder $query, $where) + protected function whereNotInRaw(Builder $query, $where) { - return $this->wrap($where['column']).' not in ('.$this->compileSelect($where['query']).')'; + if (! empty($where['values'])) { + return $this->wrap($where['column']).' not in ('.implode(', ', $where['values']).')'; + } + + return '1 = 1'; } /** @@ -374,13 +446,49 @@ protected function whereBetween(Builder $query, $where) { $between = $where['not'] ? 'not between' : 'between'; - $min = $this->parameter(reset($where['values'])); + $min = $this->parameter(is_array($where['values']) ? array_first($where['values']) : $where['values'][0]); - $max = $this->parameter(end($where['values'])); + $max = $this->parameter(is_array($where['values']) ? array_last($where['values']) : $where['values'][1]); return $this->wrap($where['column']).' '.$between.' '.$min.' and '.$max; } + /** + * Compile a "between" where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBetweenColumns(Builder $query, $where) + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->wrap(is_array($where['values']) ? array_first($where['values']) : $where['values'][0]); + + $max = $this->wrap(is_array($where['values']) ? array_last($where['values']) : $where['values'][1]); + + return $this->wrap($where['column']).' '.$between.' '.$min.' and '.$max; + } + + /** + * Compile a "value between" where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereValueBetween(Builder $query, $where) + { + $between = $where['not'] ? 'not between' : 'between'; + + $min = $this->wrap(is_array($where['columns']) ? array_first($where['columns']) : $where['columns'][0]); + + $max = $this->wrap(is_array($where['columns']) ? array_last($where['columns']) : $where['columns'][1]); + + return $this->parameter($where['value']).' '.$between.' '.$min.' and '.$max; + } + /** * Compile a "where date" clause. * @@ -457,7 +565,7 @@ protected function dateBasedWhere($type, Builder $query, $where) } /** - * Compile a where clause comparing two columns.. + * Compile a where clause comparing two columns. * * @param \Illuminate\Database\Query\Builder $query * @param array $where @@ -480,7 +588,7 @@ protected function whereNested(Builder $query, $where) // Here we will calculate what portion of the string we need to remove. If this // is a join clause query, we need to remove the "on" portion of the SQL and // if it is a normal query we need to take the leading "where" of queries. - $offset = $query instanceof JoinClause ? 3 : 6; + $offset = $where['query'] instanceof JoinClause ? 3 : 6; return '('.substr($this->compileWheres($where['query']), $offset).')'; } @@ -488,8 +596,8 @@ protected function whereNested(Builder $query, $where) /** * Compile a where condition with a sub-select. * - * @param \Illuminate\Database\Query\Builder $query - * @param array $where + * @param \Illuminate\Database\Query\Builder $query + * @param array $where * @return string */ protected function whereSub(Builder $query, $where) @@ -539,6 +647,24 @@ protected function whereRowValues(Builder $query, $where) return '('.$columns.') '.$where['operator'].' ('.$values.')'; } + /** + * Compile a "where JSON boolean" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereJsonBoolean(Builder $query, $where) + { + $column = $this->wrapJsonBooleanSelector($where['column']); + + $value = $this->wrapJsonBooleanValue( + $this->parameter($where['value']) + ); + + return $column.' '.$where['operator'].' '.$value; + } + /** * Compile a "where JSON contains" clause. * @@ -551,7 +677,8 @@ protected function whereJsonContains(Builder $query, $where) $not = $where['not'] ? 'not ' : ''; return $not.$this->compileJsonContains( - $where['column'], $this->parameter($where['value']) + $where['column'], + $this->parameter($where['value']) ); } @@ -569,6 +696,37 @@ protected function compileJsonContains($column, $value) throw new RuntimeException('This database engine does not support JSON contains operations.'); } + /** + * Compile a "where JSON overlaps" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereJsonOverlaps(Builder $query, $where) + { + $not = $where['not'] ? 'not ' : ''; + + return $not.$this->compileJsonOverlaps( + $where['column'], + $this->parameter($where['value']) + ); + } + + /** + * Compile a "JSON overlaps" statement into SQL. + * + * @param string $column + * @param string $value + * @return string + * + * @throws \RuntimeException + */ + protected function compileJsonOverlaps($column, $value) + { + throw new RuntimeException('This database engine does not support JSON overlaps operations.'); + } + /** * Prepare the binding for a "JSON contains" statement. * @@ -577,7 +735,36 @@ protected function compileJsonContains($column, $value) */ public function prepareBindingForJsonContains($binding) { - return json_encode($binding); + return json_encode($binding, JSON_UNESCAPED_UNICODE); + } + + /** + * Compile a "where JSON contains key" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereJsonContainsKey(Builder $query, $where) + { + $not = $where['not'] ? 'not ' : ''; + + return $not.$this->compileJsonContainsKey( + $where['column'] + ); + } + + /** + * Compile a "JSON contains key" statement into SQL. + * + * @param string $column + * @return string + * + * @throws \RuntimeException + */ + protected function compileJsonContainsKey($column) + { + throw new RuntimeException('This database engine does not support JSON contains key operations.'); } /** @@ -590,7 +777,9 @@ public function prepareBindingForJsonContains($binding) protected function whereJsonLength(Builder $query, $where) { return $this->compileJsonLength( - $where['column'], $where['operator'], $this->parameter($where['value']) + $where['column'], + $where['operator'], + $this->parameter($where['value']) ); } @@ -609,6 +798,43 @@ protected function compileJsonLength($column, $operator, $value) throw new RuntimeException('This database engine does not support JSON length operations.'); } + /** + * Compile a "JSON value cast" statement into SQL. + * + * @param string $value + * @return string + */ + public function compileJsonValueCast($value) + { + return $value; + } + + /** + * Compile a "where fulltext" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + * + * @throws \RuntimeException + */ + public function whereFullText(Builder $query, $where) + { + throw new RuntimeException('This database engine does not support fulltext search operations.'); + } + + /** + * Compile a clause based on an expression. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + public function whereExpression(Builder $query, $where) + { + return $where['column']->getValue($this); + } + /** * Compile the "group by" portions of the query. * @@ -625,20 +851,19 @@ protected function compileGroups(Builder $query, $groups) * Compile the "having" portions of the query. * * @param \Illuminate\Database\Query\Builder $query - * @param array $havings * @return string */ - protected function compileHavings(Builder $query, $havings) + protected function compileHavings(Builder $query) { - $sql = implode(' ', array_map([$this, 'compileHaving'], $havings)); - - return 'having '.$this->removeLeadingBoolean($sql); + return 'having '.$this->removeLeadingBoolean((new Collection($query->havings))->map(function ($having) { + return $having['boolean'].' '.$this->compileHaving($having); + })->implode(' ')); } /** * Compile a single having clause. * - * @param array $having + * @param array $having * @return string */ protected function compileHaving(array $having) @@ -646,19 +871,22 @@ protected function compileHaving(array $having) // If the having clause is "raw", we can just return the clause straight away // without doing any more processing on it. Otherwise, we will compile the // clause into SQL based on the components that make it up from builder. - if ($having['type'] === 'Raw') { - return $having['boolean'].' '.$having['sql']; - } elseif ($having['type'] === 'between') { - return $this->compileHavingBetween($having); - } - - return $this->compileBasicHaving($having); + return match ($having['type']) { + 'Raw' => $having['sql'], + 'between' => $this->compileHavingBetween($having), + 'Null' => $this->compileHavingNull($having), + 'NotNull' => $this->compileHavingNotNull($having), + 'bit' => $this->compileHavingBit($having), + 'Expression' => $this->compileHavingExpression($having), + 'Nested' => $this->compileNestedHavings($having), + default => $this->compileBasicHaving($having), + }; } /** * Compile a basic having clause. * - * @param array $having + * @param array $having * @return string */ protected function compileBasicHaving($having) @@ -667,7 +895,7 @@ protected function compileBasicHaving($having) $parameter = $this->parameter($having['value']); - return $having['boolean'].' '.$column.' '.$having['operator'].' '.$parameter; + return $column.' '.$having['operator'].' '.$parameter; } /** @@ -686,7 +914,70 @@ protected function compileHavingBetween($having) $max = $this->parameter(last($having['values'])); - return $having['boolean'].' '.$column.' '.$between.' '.$min.' and '.$max; + return $column.' '.$between.' '.$min.' and '.$max; + } + + /** + * Compile a having null clause. + * + * @param array $having + * @return string + */ + protected function compileHavingNull($having) + { + $column = $this->wrap($having['column']); + + return $column.' is null'; + } + + /** + * Compile a having not null clause. + * + * @param array $having + * @return string + */ + protected function compileHavingNotNull($having) + { + $column = $this->wrap($having['column']); + + return $column.' is not null'; + } + + /** + * Compile a having clause involving a bit operator. + * + * @param array $having + * @return string + */ + protected function compileHavingBit($having) + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return '('.$column.' '.$having['operator'].' '.$parameter.') != 0'; + } + + /** + * Compile a having clause involving an expression. + * + * @param array $having + * @return string + */ + protected function compileHavingExpression($having) + { + return $having['column']->getValue($this); + } + + /** + * Compile a nested having clause. + * + * @param array $having + * @return string + */ + protected function compileNestedHavings($having) + { + return '('.substr($this->compileHavings($having['query']), 7).')'; } /** @@ -714,17 +1005,19 @@ protected function compileOrders(Builder $query, $orders) */ protected function compileOrdersToArray(Builder $query, $orders) { - return array_map(function ($order) { - return ! isset($order['sql']) - ? $this->wrap($order['column']).' '.$order['direction'] - : $order['sql']; + return array_map(function ($order) use ($query) { + if (isset($order['sql']) && $order['sql'] instanceof Expression) { + return $order['sql']->getValue($query->getGrammar()); + } + + return $order['sql'] ?? $this->wrap($order['column']).' '.$order['direction']; }, $orders); } /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) @@ -744,6 +1037,66 @@ protected function compileLimit(Builder $query, $limit) return 'limit '.(int) $limit; } + /** + * Compile a group limit clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileGroupLimit(Builder $query) + { + $selectBindings = array_merge($query->getRawBindings()['select'], $query->getRawBindings()['order']); + + $query->setBindings($selectBindings, 'select'); + $query->setBindings([], 'order'); + + $limit = (int) $query->groupLimit['value']; + $offset = $query->offset; + + if (isset($offset)) { + $offset = (int) $offset; + $limit += $offset; + + $query->offset = null; + } + + $components = $this->compileComponents($query); + + $components['columns'] .= $this->compileRowNumber( + $query->groupLimit['column'], + $components['orders'] ?? '' + ); + + unset($components['orders']); + + $table = $this->wrap('laravel_table'); + $row = $this->wrap('laravel_row'); + + $sql = $this->concatenate($components); + + $sql = 'select * from ('.$sql.') as '.$table.' where '.$row.' <= '.$limit; + + if (isset($offset)) { + $sql .= ' and '.$row.' > '.$offset; + } + + return $sql.' order by '.$row; + } + + /** + * Compile a row number clause. + * + * @param string $partition + * @param string $orders + * @return string + */ + protected function compileRowNumber($partition, $orders) + { + $over = trim('partition by '.$this->wrap($partition).' '.$orders); + + return ', row_number() over ('.$over.') as '.$this->wrap('laravel_row'); + } + /** * Compile the "offset" portions of the query. * @@ -795,7 +1148,18 @@ protected function compileUnion(array $union) { $conjunction = $union['all'] ? ' union all ' : ' union '; - return $conjunction.$union['query']->toSql(); + return $conjunction.$this->wrapUnion($union['query']->toSql()); + } + + /** + * Wrap a union subquery in parentheses. + * + * @param string $sql + * @return string + */ + protected function wrapUnion($sql) + { + return '('.$sql.')'; } /** @@ -840,28 +1204,62 @@ public function compileInsert(Builder $query, array $values) // basic routine regardless of an amount of records given to us to insert. $table = $this->wrapTable($query->from); - if (! is_array(reset($values))) { + if (empty($values)) { + return "insert into {$table} default values"; + } + + if (! is_array(array_first($values))) { $values = [$values]; } - $columns = $this->columnize(array_keys(reset($values))); + $columns = $this->columnize(array_keys(array_first($values))); // We need to build a list of parameter place-holders of values that are bound - // to the query. Each insert should have the exact same amount of parameter + // to the query. Each insert should have the exact same number of parameter // bindings so we will loop through the record and parameterize them all. - $parameters = collect($values)->map(function ($record) { - return '('.$this->parameterize($record).')'; - })->implode(', '); + $parameters = (new Collection($values)) + ->map(fn ($record) => '('.$this->parameterize($record).')') + ->implode(', '); return "insert into $table ($columns) values $parameters"; } + /** + * Compile an insert ignore statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + * + * @throws \RuntimeException + */ + public function compileInsertOrIgnore(Builder $query, array $values) + { + throw new RuntimeException('This database engine does not support inserting while ignoring errors.'); + } + + /** + * Compile an insert or ignore statement with a returning clause into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $returning + * @return string + * + * @throws \RuntimeException + */ + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $uniqueBy, array $returning) + { + throw new RuntimeException('This database engine does not support insert or ignore with returning.'); + } + /** * Compile an insert and get ID statement into SQL. * * @param \Illuminate\Database\Query\Builder $query - * @param array $values - * @param string $sequence + * @param array $values + * @param string|null $sequence * @return string */ public function compileInsertGetId(Builder $query, $values, $sequence) @@ -879,7 +1277,28 @@ public function compileInsertGetId(Builder $query, $values, $sequence) */ public function compileInsertUsing(Builder $query, array $columns, string $sql) { - return "insert into {$this->wrapTable($query->from)} ({$this->columnize($columns)}) $sql"; + $table = $this->wrapTable($query->from); + + if (empty($columns) || $columns === ['*']) { + return "insert into {$table} $sql"; + } + + return "insert into {$table} ({$this->columnize($columns)}) $sql"; + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $columns + * @param string $sql + * @return string + * + * @throws \RuntimeException + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql) + { + throw new RuntimeException('This database engine does not support inserting while ignoring errors.'); } /** @@ -889,32 +1308,79 @@ public function compileInsertUsing(Builder $query, array $columns, string $sql) * @param array $values * @return string */ - public function compileUpdate(Builder $query, $values) + public function compileUpdate(Builder $query, array $values) { $table = $this->wrapTable($query->from); - // Each one of the columns in the update statements needs to be wrapped in the - // keyword identifiers, also a place-holder needs to be created for each of - // the values in the list of bindings so we can make the sets statements. - $columns = collect($values)->map(function ($value, $key) { - return $this->wrap($key).' = '.$this->parameter($value); - })->implode(', '); + $columns = $this->compileUpdateColumns($query, $values); - // If the query has any "join" clauses, we will setup the joins on the builder - // and compile them so we can attach them to this update, as update queries - // can get join statements to attach to other tables when they're needed. - $joins = ''; + $where = $this->compileWheres($query); - if (isset($query->joins)) { - $joins = ' '.$this->compileJoins($query, $query->joins); - } + return trim( + isset($query->joins) + ? $this->compileUpdateWithJoins($query, $table, $columns, $where) + : $this->compileUpdateWithoutJoins($query, $table, $columns, $where) + ); + } - // Of course, update queries may also be constrained by where clauses so we'll - // need to compile the where clauses and attach it to the query so only the - // intended records are updated by the SQL statements we generate to run. - $wheres = $this->compileWheres($query); + /** + * Compile the columns for an update statement. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + protected function compileUpdateColumns(Builder $query, array $values) + { + return (new Collection($values)) + ->map(fn ($value, $key) => $this->wrap($key).' = '.$this->parameter($value)) + ->implode(', '); + } - return trim("update {$table}{$joins} set $columns $wheres"); + /** + * Compile an update statement without joins into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $columns + * @param string $where + * @return string + */ + protected function compileUpdateWithoutJoins(Builder $query, $table, $columns, $where) + { + return "update {$table} set {$columns} {$where}"; + } + + /** + * Compile an update statement with joins into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $columns + * @param string $where + * @return string + */ + protected function compileUpdateWithJoins(Builder $query, $table, $columns, $where) + { + $joins = $this->compileJoins($query, $query->joins); + + return "update {$table} {$joins} set {$columns} {$where}"; + } + + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + * + * @throws \RuntimeException + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + throw new RuntimeException('This database engine does not support upserts.'); } /** @@ -926,7 +1392,9 @@ public function compileUpdate(Builder $query, $values) */ public function prepareBindingsForUpdate(array $bindings, array $values) { - $cleanBindings = Arr::except($bindings, ['join', 'select']); + $cleanBindings = Arr::except($bindings, ['select', 'join']); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); return array_values( array_merge($bindings['join'], $values, Arr::flatten($cleanBindings)) @@ -941,9 +1409,45 @@ public function prepareBindingsForUpdate(array $bindings, array $values) */ public function compileDelete(Builder $query) { - $wheres = is_array($query->wheres) ? $this->compileWheres($query) : ''; + $table = $this->wrapTable($query->from); - return trim("delete from {$this->wrapTable($query->from)} $wheres"); + $where = $this->compileWheres($query); + + return trim( + isset($query->joins) + ? $this->compileDeleteWithJoins($query, $table, $where) + : $this->compileDeleteWithoutJoins($query, $table, $where) + ); + } + + /** + * Compile a delete statement without joins into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $where + * @return string + */ + protected function compileDeleteWithoutJoins(Builder $query, $table, $where) + { + return "delete from {$table} {$where}"; + } + + /** + * Compile a delete statement with joins into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $where + * @return string + */ + protected function compileDeleteWithJoins(Builder $query, $table, $where) + { + $alias = last(explode(' as ', $table)); + + $joins = $this->compileJoins($query, $query->joins); + + return "delete {$alias} from {$table} {$joins} {$where}"; } /** @@ -954,7 +1458,9 @@ public function compileDelete(Builder $query) */ public function prepareBindingsForDelete(array $bindings) { - return Arr::flatten($bindings); + return Arr::flatten( + Arr::except($bindings, 'select') + ); } /** @@ -965,7 +1471,7 @@ public function prepareBindingsForDelete(array $bindings) */ public function compileTruncate(Builder $query) { - return ['truncate '.$this->wrapTable($query->from) => []]; + return ['truncate table '.$this->wrapTable($query->from) => []]; } /** @@ -980,6 +1486,16 @@ protected function compileLock(Builder $query, $value) return is_string($value) ? $value : ''; } + /** + * Compile a query to get the number of open connections for a database. + * + * @return string|null + */ + public function compileThreadCount() + { + return null; + } + /** * Determine if the grammar supports savepoints. * @@ -1013,90 +1529,31 @@ public function compileSavepointRollBack($name) } /** - * Wrap a value in keyword identifiers. - * - * @param \Illuminate\Database\Query\Expression|string $value - * @param bool $prefixAlias - * @return string - */ - public function wrap($value, $prefixAlias = false) - { - if ($this->isExpression($value)) { - return $this->getValue($value); - } - - // If the value being wrapped has a column alias we will need to separate out - // the pieces so we can wrap each of the segments of the expression on its - // own, and then join these both back together using the "as" connector. - if (stripos($value, ' as ') !== false) { - return $this->wrapAliasedValue($value, $prefixAlias); - } - - // If the given value is a JSON selector we will wrap it differently than a - // traditional value. We will need to split this path and wrap each part - // wrapped, etc. Otherwise, we will simply wrap the value as a string. - if ($this->isJsonSelector($value)) { - return $this->wrapJsonSelector($value); - } - - return $this->wrapSegments(explode('.', $value)); - } - - /** - * Wrap the given JSON selector. + * Wrap the given JSON selector for boolean values. * * @param string $value * @return string */ - protected function wrapJsonSelector($value) + protected function wrapJsonBooleanSelector($value) { - throw new RuntimeException('This database engine does not support JSON operations.'); + return $this->wrapJsonSelector($value); } /** - * Split the given JSON selector into the field and the optional path and wrap them separately. - * - * @param string $column - * @return array - */ - protected function wrapJsonFieldAndPath($column) - { - $parts = explode('->', $column, 2); - - $field = $this->wrap($parts[0]); - - $path = count($parts) > 1 ? ', '.$this->wrapJsonPath($parts[1], '->') : ''; - - return [$field, $path]; - } - - /** - * Wrap the given JSON path. + * Wrap the given JSON boolean value. * * @param string $value - * @param string $delimiter * @return string */ - protected function wrapJsonPath($value, $delimiter = '->') + protected function wrapJsonBooleanValue($value) { - return '\'$."'.str_replace($delimiter, '"."', $value).'"\''; - } - - /** - * Determine if the given string is a JSON selector. - * - * @param string $value - * @return bool - */ - protected function isJsonSelector($value) - { - return Str::contains($value, '->'); + return $value; } /** * Concatenate an array of segments, removing empties. * - * @param array $segments + * @param array $segments * @return string */ protected function concatenate($segments) @@ -1117,6 +1574,44 @@ protected function removeLeadingBoolean($value) return preg_replace('/and |or /i', '', $value, 1); } + /** + * Substitute the given bindings into the given raw SQL query. + * + * @param string $sql + * @param array $bindings + * @return string + */ + public function substituteBindingsIntoRawSql($sql, $bindings) + { + $bindings = array_map(fn ($value) => $this->escape($value, is_resource($value) || gettype($value) === 'resource (closed)'), $bindings); + + $query = ''; + + $isStringLiteral = false; + + for ($i = 0; $i < strlen($sql); $i++) { + $char = $sql[$i]; + $nextChar = $sql[$i + 1] ?? null; + + // Single quotes can be escaped as '' according to the SQL standard while + // MySQL uses \'. Postgres has operators like ?| that must get encoded + // in PHP like ??|. We should skip over the escaped characters here. + if (in_array($char.$nextChar, ["\'", "''", '??'])) { + $query .= $char.$nextChar; + $i += 1; + } elseif ($char === "'") { // Starting / leaving string literal... + $query .= $char; + $isStringLiteral = ! $isStringLiteral; + } elseif ($char === '?' && ! $isStringLiteral) { // Substitutable binding... + $query .= array_shift($bindings) ?? '?'; + } else { // Normal character... + $query .= $char; + } + } + + return $query; + } + /** * Get the grammar specific operators. * @@ -1126,4 +1621,14 @@ public function getOperators() { return $this->operators; } + + /** + * Get the grammar specific bitwise operators. + * + * @return array + */ + public function getBitwiseOperators() + { + return $this->bitwiseOperators; + } } diff --git a/Query/Grammars/MariaDbGrammar.php b/Query/Grammars/MariaDbGrammar.php new file mode 100755 index 0000000000..9ffc39d74b --- /dev/null +++ b/Query/Grammars/MariaDbGrammar.php @@ -0,0 +1,69 @@ +wrapJsonFieldAndPath($value); + + return 'json_value('.$field.$path.')'; + } +} diff --git a/Query/Grammars/MySqlGrammar.php b/Query/Grammars/MySqlGrammar.php index bfbedbb381..013d30e4ae 100755 --- a/Query/Grammars/MySqlGrammar.php +++ b/Query/Grammars/MySqlGrammar.php @@ -2,57 +2,252 @@ namespace Illuminate\Database\Query\Grammars; -use Illuminate\Support\Arr; use Illuminate\Database\Query\Builder; -use Illuminate\Database\Query\JsonExpression; +use Illuminate\Database\Query\JoinLateralClause; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use InvalidArgumentException; class MySqlGrammar extends Grammar { /** * The grammar specific operators. * - * @var array + * @var string[] */ protected $operators = ['sounds like']; /** - * The components that make up a select clause. + * Compile a select query into SQL. * - * @var array + * @param \Illuminate\Database\Query\Builder $query + * @return string */ - protected $selectComponents = [ - 'aggregate', - 'columns', - 'from', - 'joins', - 'wheres', - 'groups', - 'havings', - 'orders', - 'limit', - 'offset', - 'lock', - ]; + public function compileSelect(Builder $query) + { + $sql = parent::compileSelect($query); + + if ($query->timeout === null) { + return $sql; + } + + $milliseconds = $query->timeout * 1000; + + return preg_replace( + '/^select\b/i', + 'select /*+ MAX_EXECUTION_TIME('.$milliseconds.') */', + $sql, + 1 + ); + } /** - * Compile a select query into SQL. + * Compile a "where like" clause. * * @param \Illuminate\Database\Query\Builder $query + * @param array $where * @return string */ - public function compileSelect(Builder $query) + protected function whereLike(Builder $query, $where) { - if ($query->unions && $query->aggregate) { - return $this->compileUnionAggregate($query); + $where['operator'] = $where['not'] ? 'not ' : ''; + + $where['operator'] .= $where['caseSensitive'] ? 'like binary' : 'like'; + + return $this->whereBasic($query, $where); + } + + /** + * Add a "where null" clause to the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereNull(Builder $query, $where) + { + $columnValue = (string) $this->getValue($where['column']); + + if ($this->isJsonSelector($columnValue)) { + [$field, $path] = $this->wrapJsonFieldAndPath($columnValue); + + return '(json_extract('.$field.$path.') is null OR json_type(json_extract('.$field.$path.')) = \'NULL\')'; } - $sql = parent::compileSelect($query); + return parent::whereNull($query, $where); + } + + /** + * Add a "where not null" clause to the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereNotNull(Builder $query, $where) + { + $columnValue = (string) $this->getValue($where['column']); + + if ($this->isJsonSelector($columnValue)) { + [$field, $path] = $this->wrapJsonFieldAndPath($columnValue); - if ($query->unions) { - $sql = '('.$sql.') '.$this->compileUnions($query); + return '(json_extract('.$field.$path.') is not null AND json_type(json_extract('.$field.$path.')) != \'NULL\')'; } - return $sql; + return parent::whereNotNull($query, $where); + } + + /** + * Compile a "where fulltext" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + public function whereFullText(Builder $query, $where) + { + $columns = $this->columnize($where['columns']); + + $value = $this->parameter($where['value']); + + $mode = ($where['options']['mode'] ?? []) === 'boolean' + ? ' in boolean mode' + : ' in natural language mode'; + + $expanded = ($where['options']['expanded'] ?? []) && ($where['options']['mode'] ?? []) !== 'boolean' + ? ' with query expansion' + : ''; + + return "match ({$columns}) against (".$value."{$mode}{$expanded})"; + } + + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + * + * @throws \InvalidArgumentException + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + $index = $indexHint->index; + + $indexes = array_map('trim', explode(',', $index)); + + foreach ($indexes as $i) { + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $i)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + } + + return match ($indexHint->type) { + 'hint' => "use index ({$index})", + 'force' => "force index ({$index})", + default => "ignore index ({$index})", + }; + } + + /** + * Compile a group limit clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileGroupLimit(Builder $query) + { + return $this->useLegacyGroupLimit($query) + ? $this->compileLegacyGroupLimit($query) + : parent::compileGroupLimit($query); + } + + /** + * Determine whether to use a legacy group limit clause for MySQL < 8.0. + * + * @param \Illuminate\Database\Query\Builder $query + * @return bool + */ + public function useLegacyGroupLimit(Builder $query) + { + $version = $query->getConnection()->getServerVersion(); + + return ! $query->getConnection()->isMaria() && version_compare($version, '8.0.11', '<'); + } + + /** + * Compile a group limit clause for MySQL < 8.0. + * + * Derived from https://softonsofa.com/tweaking-eloquent-relations-how-to-get-n-related-models-per-parent/. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileLegacyGroupLimit(Builder $query) + { + $limit = (int) $query->groupLimit['value']; + $offset = $query->offset; + + if (isset($offset)) { + $offset = (int) $offset; + $limit += $offset; + + $query->offset = null; + } + + $column = last(explode('.', $query->groupLimit['column'])); + $column = $this->wrap($column); + + $partition = ', @laravel_row := if(@laravel_group = '.$column.', @laravel_row + 1, 1) as `laravel_row`'; + $partition .= ', @laravel_group := '.$column; + + $orders = (array) $query->orders; + + array_unshift($orders, [ + 'column' => $query->groupLimit['column'], + 'direction' => 'asc', + ]); + + $query->orders = $orders; + + $components = $this->compileComponents($query); + + $sql = $this->concatenate($components); + + $from = '(select @laravel_row := 0, @laravel_group := 0) as `laravel_vars`, ('.$sql.') as `laravel_table`'; + + $sql = 'select `laravel_table`.*'.$partition.' from '.$from.' having `laravel_row` <= '.$limit; + + if (isset($offset)) { + $sql .= ' and `laravel_row` > '.$offset; + } + + return $sql.' order by `laravel_row`'; + } + + /** + * Compile an insert ignore statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + public function compileInsertOrIgnore(Builder $query, array $values) + { + return Str::replaceFirst('insert', 'insert ignore', $this->compileInsert($query, $values)); + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $columns + * @param string $sql + * @return string + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql) + { + return Str::replaceFirst('insert', 'insert ignore', $this->compileInsertUsing($query, $columns, $sql)); } /** @@ -69,6 +264,33 @@ protected function compileJsonContains($column, $value) return 'json_contains('.$field.', '.$value.$path.')'; } + /** + * Compile a "JSON overlaps" statement into SQL. + * + * @param string $column + * @param string $value + * @return string + */ + protected function compileJsonOverlaps($column, $value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_overlaps('.$field.', '.$value.$path.')'; + } + + /** + * Compile a "JSON contains key" statement into SQL. + * + * @param string $column + * @return string + */ + protected function compileJsonContainsKey($column) + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'ifnull(json_contains_path('.$field.', \'one\''.$path.'), 0)'; + } + /** * Compile a "JSON length" statement into SQL. * @@ -85,27 +307,35 @@ protected function compileJsonLength($column, $operator, $value) } /** - * Compile a single union statement. + * Compile a "JSON value cast" statement into SQL. * - * @param array $union + * @param string $value * @return string */ - protected function compileUnion(array $union) + public function compileJsonValueCast($value) { - $conjunction = $union['all'] ? ' union all ' : ' union '; - - return $conjunction.'('.$union['query']->toSql().')'; + return 'cast('.$value.' as json)'; } /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string + * + * @throws \InvalidArgumentException */ public function compileRandom($seed) { - return 'RAND('.$seed.')'; + if ($seed === '' || $seed === null) { + return 'RAND()'; + } + + if (! is_numeric($seed)) { + throw new InvalidArgumentException('The seed value must be numeric.'); + } + + return 'RAND('.(int) $seed.')'; } /** @@ -125,65 +355,33 @@ protected function compileLock(Builder $query, $value) } /** - * Compile an update statement into SQL. + * Compile an insert statement into SQL. * * @param \Illuminate\Database\Query\Builder $query * @param array $values * @return string */ - public function compileUpdate(Builder $query, $values) + public function compileInsert(Builder $query, array $values) { - $table = $this->wrapTable($query->from); - - // Each one of the columns in the update statements needs to be wrapped in the - // keyword identifiers, also a place-holder needs to be created for each of - // the values in the list of bindings so we can make the sets statements. - $columns = $this->compileUpdateColumns($values); - - // If the query has any "join" clauses, we will setup the joins on the builder - // and compile them so we can attach them to this update, as update queries - // can get join statements to attach to other tables when they're needed. - $joins = ''; - - if (isset($query->joins)) { - $joins = ' '.$this->compileJoins($query, $query->joins); - } - - // Of course, update queries may also be constrained by where clauses so we'll - // need to compile the where clauses and attach it to the query so only the - // intended records are updated by the SQL statements we generate to run. - $where = $this->compileWheres($query); - - $sql = rtrim("update {$table}{$joins} set $columns $where"); - - // If the query has an order by clause we will compile it since MySQL supports - // order bys on update statements. We'll compile them using the typical way - // of compiling order bys. Then they will be appended to the SQL queries. - if (! empty($query->orders)) { - $sql .= ' '.$this->compileOrders($query, $query->orders); - } - - // Updates on MySQL also supports "limits", which allow you to easily update a - // single record very easily. This is not supported by all database engines - // so we have customized this update compiler here in order to add it in. - if (isset($query->limit)) { - $sql .= ' '.$this->compileLimit($query, $query->limit); + if (empty($values)) { + $values = [[]]; } - return rtrim($sql); + return parent::compileInsert($query, $values); } /** - * Compile all of the columns for an update statement. + * Compile the columns for an update statement. * + * @param \Illuminate\Database\Query\Builder $query * @param array $values * @return string */ - protected function compileUpdateColumns($values) + protected function compileUpdateColumns(Builder $query, array $values) { - return collect($values)->map(function ($value, $key) { + return (new Collection($values))->map(function ($value, $key) { if ($this->isJsonSelector($key)) { - return $this->compileJsonUpdateColumn($key, new JsonExpression($value)); + return $this->compileJsonUpdateColumn($key, $value); } return $this->wrap($key).' = '.$this->parameter($value); @@ -191,84 +389,137 @@ protected function compileUpdateColumns($values) } /** - * Prepares a JSON column being updated using the JSON_SET function. + * Compile an "upsert" statement into SQL. * - * @param string $key - * @param \Illuminate\Database\Query\JsonExpression $value + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update * @return string */ - protected function compileJsonUpdateColumn($key, JsonExpression $value) + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) { - [$field, $path] = $this->wrapJsonFieldAndPath($key); + $useUpsertAlias = $query->connection->getConfig('use_upsert_alias'); - return "{$field} = json_set({$field}{$path}, {$value->getValue()})"; + $sql = $this->compileInsert($query, $values); + + if ($useUpsertAlias) { + $sql .= ' as laravel_upsert_alias'; + } + + $sql .= ' on duplicate key update '; + + $columns = (new Collection($update))->map(function ($value, $key) use ($useUpsertAlias) { + if (! is_numeric($key)) { + return $this->wrap($key).' = '.$this->parameter($value); + } + + return $useUpsertAlias + ? $this->wrap($value).' = '.$this->wrap('laravel_upsert_alias').'.'.$this->wrap($value) + : $this->wrap($value).' = values('.$this->wrap($value).')'; + })->implode(', '); + + return $sql.$columns; } /** - * Prepare the bindings for an update statement. + * Compile a "lateral join" clause. * - * Booleans, integers, and doubles are inserted into JSON updates as raw values. + * @param \Illuminate\Database\Query\JoinLateralClause $join + * @param string $expression + * @return string + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + return trim("{$join->type} join lateral {$expression} on true"); + } + + /** + * {@inheritdoc} + */ + protected function supportsStraightJoins() + { + return true; + } + + /** + * Prepare a JSON column being updated using the JSON_SET function. * - * @param array $bindings - * @param array $values - * @return array + * @param string $key + * @param mixed $value + * @return string */ - public function prepareBindingsForUpdate(array $bindings, array $values) + protected function compileJsonUpdateColumn($key, $value) { - $values = collect($values)->reject(function ($value, $column) { - return $this->isJsonSelector($column) && is_bool($value); - })->all(); + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } elseif (is_array($value)) { + $value = 'cast(? as json)'; + } else { + $value = $this->parameter($value); + } - return parent::prepareBindingsForUpdate($bindings, $values); + [$field, $path] = $this->wrapJsonFieldAndPath($key); + + return "{$field} = json_set({$field}{$path}, {$value})"; } /** - * Compile a delete statement into SQL. + * Compile an update statement without joins into SQL. * * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $columns + * @param string $where * @return string */ - public function compileDelete(Builder $query) + protected function compileUpdateWithoutJoins(Builder $query, $table, $columns, $where) { - $table = $this->wrapTable($query->from); + $sql = parent::compileUpdateWithoutJoins($query, $table, $columns, $where); + + if (! empty($query->orders)) { + $sql .= ' '.$this->compileOrders($query, $query->orders); + } - $where = is_array($query->wheres) ? $this->compileWheres($query) : ''; + if (isset($query->limit)) { + $sql .= ' '.$this->compileLimit($query, $query->limit); + } - return isset($query->joins) - ? $this->compileDeleteWithJoins($query, $table, $where) - : $this->compileDeleteWithoutJoins($query, $table, $where); + return $sql; } /** - * Prepare the bindings for a delete statement. + * Prepare the bindings for an update statement. + * + * Booleans, integers, and doubles are inserted into JSON updates as raw values. * * @param array $bindings + * @param array $values * @return array */ - public function prepareBindingsForDelete(array $bindings) + #[\Override] + public function prepareBindingsForUpdate(array $bindings, array $values) { - $cleanBindings = Arr::except($bindings, ['join', 'select']); + $values = (new Collection($values)) + ->reject(fn ($value, $column) => $this->isJsonSelector($column) && is_bool($value)) + ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->all(); - return array_values( - array_merge($bindings['join'], Arr::flatten($cleanBindings)) - ); + return parent::prepareBindingsForUpdate($bindings, $values); } /** - * Compile a delete query that does not use joins. + * Compile a delete statement without joins into SQL. * * @param \Illuminate\Database\Query\Builder $query * @param string $table - * @param array $where + * @param string $where * @return string */ - protected function compileDeleteWithoutJoins($query, $table, $where) + protected function compileDeleteWithoutJoins(Builder $query, $table, $where) { - $sql = trim("delete from {$table} {$where}"); + $sql = parent::compileDeleteWithoutJoins($query, $table, $where); - // When using MySQL, delete statements may contain order by statements and limits - // so we will compile both of those here. Once we have finished compiling this - // we will return the completed SQL statement so it will be executed for us. if (! empty($query->orders)) { $sql .= ' '.$this->compileOrders($query, $query->orders); } @@ -281,21 +532,40 @@ protected function compileDeleteWithoutJoins($query, $table, $where) } /** - * Compile a delete query that uses joins. + * Compile a delete statement with joins into SQL. + * + * Adds ORDER BY and LIMIT if present, for platforms that allow them (e.g., PlanetScale). + * + * Standard MySQL does not support ORDER BY or LIMIT with joined deletes and will throw a syntax error. * * @param \Illuminate\Database\Query\Builder $query * @param string $table - * @param array $where + * @param string $where * @return string */ - protected function compileDeleteWithJoins($query, $table, $where) + protected function compileDeleteWithJoins(Builder $query, $table, $where) { - $joins = ' '.$this->compileJoins($query, $query->joins); + $sql = parent::compileDeleteWithJoins($query, $table, $where); - $alias = stripos($table, ' as ') !== false - ? explode(' as ', $table)[1] : $table; + if (! empty($query->orders)) { + $sql .= ' '.$this->compileOrders($query, $query->orders); + } + + if (isset($query->limit)) { + $sql .= ' '.$this->compileLimit($query, $query->limit); + } + + return $sql; + } - return trim("delete {$alias} from {$table}{$joins} {$where}"); + /** + * Compile a query to get the number of open connections for a database. + * + * @return string + */ + public function compileThreadCount() + { + return 'select variable_value as `Value` from performance_schema.session_status where variable_name = \'threads_connected\''; } /** @@ -321,4 +591,17 @@ protected function wrapJsonSelector($value) return 'json_unquote(json_extract('.$field.$path.'))'; } + + /** + * Wrap the given JSON selector for boolean values. + * + * @param string $value + * @return string + */ + protected function wrapJsonBooleanSelector($value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract('.$field.$path.')'; + } } diff --git a/Query/Grammars/PostgresGrammar.php b/Query/Grammars/PostgresGrammar.php index a3b3627e7e..a21443523c 100755 --- a/Query/Grammars/PostgresGrammar.php +++ b/Query/Grammars/PostgresGrammar.php @@ -2,46 +2,52 @@ namespace Illuminate\Database\Query\Grammars; +use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinLateralClause; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Illuminate\Database\Query\Builder; class PostgresGrammar extends Grammar { /** - * The components that make up a select clause. + * All of the available clause operators. * - * @var array + * @var string[] */ - protected $selectComponents = [ - 'aggregate', - 'columns', - 'from', - 'joins', - 'wheres', - 'groups', - 'havings', - 'orders', - 'limit', - 'offset', - 'lock', + protected $operators = [ + '=', '<', '>', '<=', '>=', '<>', '!=', + 'like', 'not like', 'between', 'ilike', 'not ilike', + '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', + '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '@?', '@@', '#-', + 'is distinct from', 'is not distinct from', ]; /** - * All of the available clause operators. + * The Postgres grammar specific custom operators. * * @var array */ - protected $operators = [ - '=', '<', '>', '<=', '>=', '<>', '!=', - 'like', 'not like', 'between', 'ilike', 'not ilike', + protected static $customOperators = []; + + /** + * The grammar specific bitwise operators. + * + * @var array + */ + protected $bitwiseOperators = [ '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', - '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '-', '#-', - 'is distinct from', 'is not distinct from', ]; /** - * {@inheritdoc} + * Indicates if the cascade option should be used when truncating. + * + * @var bool + */ + protected static $cascadeTruncate = true; + + /** + * Compile a basic where clause. * * @param \Illuminate\Database\Query\Builder $query * @param array $where @@ -49,7 +55,7 @@ class PostgresGrammar extends Grammar */ protected function whereBasic(Builder $query, $where) { - if (Str::contains(strtolower($where['operator']), 'like')) { + if (str_contains(strtolower($where['operator']), 'like')) { return sprintf( '%s::text %s %s', $this->wrap($where['column']), @@ -61,6 +67,38 @@ protected function whereBasic(Builder $query, $where) return parent::whereBasic($query, $where); } + /** + * Compile a bitwise operator where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBitwise(Builder $query, $where) + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return '('.$this->wrap($where['column']).' '.$operator.' '.$value.')::bool'; + } + + /** + * Compile a "where like" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereLike(Builder $query, $where) + { + $where['operator'] = $where['not'] ? 'not ' : ''; + + $where['operator'] .= $where['caseSensitive'] ? 'like' : 'ilike'; + + return $this->whereBasic($query, $where); + } + /** * Compile a "where date" clause. * @@ -70,9 +108,14 @@ protected function whereBasic(Builder $query, $where) */ protected function whereDate(Builder $query, $where) { + $column = $this->wrap($where['column']); $value = $this->parameter($where['value']); - return $this->wrap($where['column']).'::date '.$where['operator'].' '.$value; + if ($this->isJsonSelector($where['column'])) { + $column = '('.$column.')'; + } + + return $column.'::date '.$where['operator'].' '.$value; } /** @@ -84,9 +127,14 @@ protected function whereDate(Builder $query, $where) */ protected function whereTime(Builder $query, $where) { + $column = $this->wrap($where['column']); $value = $this->parameter($where['value']); - return $this->wrap($where['column']).'::time '.$where['operator'].' '.$value; + if ($this->isJsonSelector($where['column'])) { + $column = '('.$column.')'; + } + + return $column.'::time '.$where['operator'].' '.$value; } /** @@ -105,37 +153,103 @@ protected function dateBasedWhere($type, Builder $query, $where) } /** - * Compile a select query into SQL. + * Compile a "where fulltext" clause. * * @param \Illuminate\Database\Query\Builder $query + * @param array $where * @return string */ - public function compileSelect(Builder $query) + public function whereFullText(Builder $query, $where) { - if ($query->unions && $query->aggregate) { - return $this->compileUnionAggregate($query); + $language = $where['options']['language'] ?? 'english'; + + if (! in_array($language, $this->validFullTextLanguages())) { + $language = 'english'; + } + + $isVector = $where['options']['vector'] ?? false; + + $columns = (new Collection($where['columns'])) + ->map(fn ($column) => $isVector + ? $this->wrap($column) + : "to_tsvector('{$language}', {$this->wrap($column)})") + ->implode(' || '); + + $mode = 'plainto_tsquery'; + + if (($where['options']['mode'] ?? []) === 'phrase') { + $mode = 'phraseto_tsquery'; } - $sql = parent::compileSelect($query); + if (($where['options']['mode'] ?? []) === 'websearch') { + $mode = 'websearch_to_tsquery'; + } - if ($query->unions) { - $sql = '('.$sql.') '.$this->compileUnions($query); + if (($where['options']['mode'] ?? []) === 'raw') { + $mode = 'to_tsquery'; } - return $sql; + return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})"; } /** - * Compile a single union statement. + * Get an array of valid full text languages. * - * @param array $union - * @return string + * @return array + */ + protected function validFullTextLanguages() + { + return [ + 'simple', + 'arabic', + 'danish', + 'dutch', + 'english', + 'finnish', + 'french', + 'german', + 'hungarian', + 'indonesian', + 'irish', + 'italian', + 'lithuanian', + 'nepali', + 'norwegian', + 'portuguese', + 'romanian', + 'russian', + 'spanish', + 'swedish', + 'tamil', + 'turkish', + ]; + } + + /** + * Compile the "select *" portion of the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $columns + * @return string|null */ - protected function compileUnion(array $union) + protected function compileColumns(Builder $query, $columns) { - $conjunction = $union['all'] ? ' union all ' : ' union '; + // If the query is actually performing an aggregating select, we will let that + // compiler handle the building of the select clauses, as it will need some + // more syntax that is best handled by that function to keep things neat. + if (! is_null($query->aggregate)) { + return; + } - return $conjunction.'('.$union['query']->toSql().')'; + if (is_array($query->distinct)) { + $select = 'select distinct on ('.$this->columnize($query->distinct).') '; + } elseif ($query->distinct) { + $select = 'select distinct '; + } else { + $select = 'select '; + } + + return $select.$this->columnize($columns); } /** @@ -152,6 +266,40 @@ protected function compileJsonContains($column, $value) return '('.$column.')::jsonb @> '.$value; } + /** + * Compile a "JSON contains key" statement into SQL. + * + * @param string $column + * @return string + */ + protected function compileJsonContainsKey($column) + { + $segments = explode('->', $column); + + $lastSegment = array_pop($segments); + + if (filter_var($lastSegment, FILTER_VALIDATE_INT) !== false) { + $i = $lastSegment; + } elseif (preg_match('/\[(-?[0-9]+)\]$/', $lastSegment, $matches)) { + $segments[] = Str::beforeLast($lastSegment, $matches[0]); + + $i = $matches[1]; + } + + $column = str_replace('->>', '->', $this->wrap(implode('->', $segments))); + + if (isset($i)) { + return vsprintf('case when %s then %s else false end', [ + 'jsonb_typeof(('.$column.")::jsonb) = 'array'", + 'jsonb_array_length(('.$column.')::jsonb) >= '.($i < 0 ? abs($i) : $i + 1), + ]); + } + + $key = "'".str_replace("'", "''", $lastSegment)."'"; + + return 'coalesce(('.$column.')::jsonb ?? '.$key.', false)'; + } + /** * Compile a "JSON length" statement into SQL. * @@ -164,7 +312,37 @@ protected function compileJsonLength($column, $operator, $value) { $column = str_replace('->>', '->', $this->wrap($column)); - return 'json_array_length(('.$column.')::json) '.$operator.' '.$value; + return 'jsonb_array_length(('.$column.')::jsonb) '.$operator.' '.$value; + } + + /** + * Compile a single having clause. + * + * @param array $having + * @return string + */ + protected function compileHaving(array $having) + { + if ($having['type'] === 'Bitwise') { + return $this->compileHavingBitwise($having); + } + + return parent::compileHaving($having); + } + + /** + * Compile a having clause involving a bitwise operator. + * + * @param array $having + * @return string + */ + protected function compileHavingBitwise($having) + { + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); + + return '('.$column.' '.$having['operator'].' '.$parameter.')::bool'; } /** @@ -184,32 +362,57 @@ protected function compileLock(Builder $query, $value) } /** - * {@inheritdoc} + * Compile an insert ignore statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + public function compileInsertOrIgnore(Builder $query, array $values) + { + return $this->compileInsert($query, $values).' on conflict do nothing'; + } + + /** + * Compile an insert or ignore statement with a returning clause into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $returning + * @return string */ - public function compileInsert(Builder $query, array $values) + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $uniqueBy, array $returning) { - $table = $this->wrapTable($query->from); + return $this->compileInsert($query, $values) + .' on conflict ('.$this->columnize($uniqueBy).') do nothing' + .' returning '.$this->columnize($returning); + } - return empty($values) - ? "insert into {$table} DEFAULT VALUES" - : parent::compileInsert($query, $values); + /** + * Compile an insert ignore statement using a subquery into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $columns + * @param string $sql + * @return string + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql) + { + return $this->compileInsertUsing($query, $columns, $sql).' on conflict do nothing'; } /** * Compile an insert and get ID statement into SQL. * * @param \Illuminate\Database\Query\Builder $query - * @param array $values - * @param string $sequence + * @param array $values + * @param string|null $sequence * @return string */ public function compileInsertGetId(Builder $query, $values, $sequence) { - if (is_null($sequence)) { - $sequence = 'id'; - } - - return $this->compileInsert($query, $values).' returning '.$this->wrap($sequence); + return $this->compileInsert($query, $values).' returning '.$this->wrap($sequence ?: 'id'); } /** @@ -219,36 +422,26 @@ public function compileInsertGetId(Builder $query, $values, $sequence) * @param array $values * @return string */ - public function compileUpdate(Builder $query, $values) + public function compileUpdate(Builder $query, array $values) { - $table = $this->wrapTable($query->from); - - // Each one of the columns in the update statements needs to be wrapped in the - // keyword identifiers, also a place-holder needs to be created for each of - // the values in the list of bindings so we can make the sets statements. - $columns = $this->compileUpdateColumns($query, $values); - - $from = $this->compileUpdateFrom($query); - - $where = $this->compileUpdateWheres($query); + if (isset($query->joins) || isset($query->limit)) { + return $this->compileUpdateWithJoinsOrLimit($query, $values); + } - return trim("update {$table} set {$columns}{$from} {$where}"); + return parent::compileUpdate($query, $values); } /** - * Compile the columns for the update statement. + * Compile the columns for an update statement. * * @param \Illuminate\Database\Query\Builder $query - * @param array $values + * @param array $values * @return string */ - protected function compileUpdateColumns($query, $values) + protected function compileUpdateColumns(Builder $query, array $values) { - // When gathering the columns for an update statement, we'll wrap each of the - // columns and convert it to a parameter value. Then we will concatenate a - // list of the columns that can be added into this update query clauses. - return collect($values)->map(function ($value, $key) use ($query) { - $column = Str::after($key, $query->from.'.'); + return (new Collection($values))->map(function ($value, $key) { + $column = last(explode('.', $key)); if ($this->isJsonSelector($key)) { return $this->compileJsonUpdateColumn($column, $value); @@ -258,6 +451,42 @@ protected function compileUpdateColumns($query, $values) })->implode(', '); } + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; + + $columns = (new Collection($update))->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrapValue('excluded').'.'.$this->wrap($value) + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); + + return $sql.$columns; + } + + /** + * Compile a "lateral join" clause. + * + * @param \Illuminate\Database\Query\JoinLateralClause $join + * @param string $expression + * @return string + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + return trim("{$join->type} join lateral {$expression} on true"); + } + /** * Prepares a JSON column being updated using the JSONB_SET function. * @@ -267,37 +496,49 @@ protected function compileUpdateColumns($query, $values) */ protected function compileJsonUpdateColumn($key, $value) { - $parts = explode('->', $key); + $segments = explode('->', $key); - $field = $this->wrap(array_shift($parts)); + $field = $this->wrap(array_shift($segments)); - $path = '\'{"'.implode('","', $parts).'"}\''; + $path = "'{".implode(',', $this->wrapJsonPathAttributes($segments, '"'))."}'"; return "{$field} = jsonb_set({$field}::jsonb, {$path}, {$this->parameter($value)})"; } /** - * Compile the "from" clause for an update with a join. + * Compile an update from statement into SQL. * * @param \Illuminate\Database\Query\Builder $query - * @return string|null + * @param array $values + * @return string */ - protected function compileUpdateFrom(Builder $query) + public function compileUpdateFrom(Builder $query, $values) { - if (! isset($query->joins)) { - return ''; - } + $table = $this->wrapTable($query->from); - // When using Postgres, updates with joins list the joined tables in the from - // clause, which is different than other systems like MySQL. Here, we will - // compile out the tables that are joined and add them to a from clause. - $froms = collect($query->joins)->map(function ($join) { - return $this->wrapTable($join->table); - })->all(); + // Each one of the columns in the update statements needs to be wrapped in the + // keyword identifiers, also a place-holder needs to be created for each of + // the values in the list of bindings so we can make the sets statements. + $columns = $this->compileUpdateColumns($query, $values); + + $from = ''; + + if (isset($query->joins)) { + // When using Postgres, updates with joins list the joined tables in the from + // clause, which is different than other systems like MySQL. Here, we will + // compile out the tables that are joined and add them to a from clause. + $froms = (new Collection($query->joins)) + ->map(fn ($join) => $this->wrapTable($join->table)) + ->all(); - if (count($froms) > 0) { - return ' from '.implode(', ', $froms); + if (count($froms) > 0) { + $from = ' from '.implode(', ', $froms); + } } + + $where = $this->compileUpdateWheres($query); + + return trim("update {$table} set {$columns}{$from} {$where}"); } /** @@ -357,21 +598,65 @@ protected function compileUpdateJoinWheres(Builder $query) * @param array $values * @return array */ + public function prepareBindingsForUpdateFrom(array $bindings, array $values) + { + $values = (new Collection($values)) + ->map(function ($value, $column) { + return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) + ? json_encode($value) + : $value; + }) + ->all(); + + $bindingsWithoutWhere = Arr::except($bindings, ['select', 'where']); + + return array_values( + array_merge($values, $bindings['where'], Arr::flatten($bindingsWithoutWhere)) + ); + } + + /** + * Compile an update statement with joins or limit into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values) + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias.'.ctid')); + + return "update {$table} set {$columns} where {$this->wrap('ctid')} in ({$selectSql})"; + } + + /** + * Prepare the bindings for an update statement. + * + * @param array $bindings + * @param array $values + * @return array + */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { - $values = collect($values)->map(function ($value, $column) { - return $this->isJsonSelector($column) && ! $this->isExpression($value) + $values = (new Collection($values))->map(function ($value, $column) { + return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) ? json_encode($value) : $value; })->all(); - // Update statements with "joins" in Postgres use an interesting syntax. We need to - // take all of the bindings and put them on the end of this array since they are - // added to the end of the "where" clause statements as typical where clauses. - $bindingsWithoutJoin = Arr::except($bindings, 'join'); + $cleanBindings = Arr::except($bindings, 'select'); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); return array_values( - array_merge($values, $bindings['join'], Arr::flatten($bindingsWithoutJoin)) + array_merge($values, Arr::flatten($cleanBindings)) ); } @@ -383,29 +668,28 @@ public function prepareBindingsForUpdate(array $bindings, array $values) */ public function compileDelete(Builder $query) { - $table = $this->wrapTable($query->from); + if (isset($query->joins) || isset($query->limit)) { + return $this->compileDeleteWithJoinsOrLimit($query); + } - return isset($query->joins) - ? $this->compileDeleteWithJoins($query, $table) - : parent::compileDelete($query); + return parent::compileDelete($query); } /** - * Compile a delete query that uses joins. + * Compile a delete statement with joins or limit into SQL. * * @param \Illuminate\Database\Query\Builder $query - * @param string $table * @return string */ - protected function compileDeleteWithJoins($query, $table) + protected function compileDeleteWithJoinsOrLimit(Builder $query) { - $using = ' USING '.collect($query->joins)->map(function ($join) { - return $this->wrapTable($join->table); - })->implode(', '); + $table = $this->wrapTable($query->from); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); - $where = count($query->wheres) > 0 ? ' '.$this->compileUpdateWheres($query) : ''; + $selectSql = $this->compileSelect($query->select($alias.'.ctid')); - return trim("delete from {$table}{$using}{$where}"); + return "delete from {$table} where {$this->wrap('ctid')} in ({$selectSql})"; } /** @@ -416,7 +700,17 @@ protected function compileDeleteWithJoins($query, $table) */ public function compileTruncate(Builder $query) { - return ['truncate '.$this->wrapTable($query->from).' restart identity cascade' => []]; + return ['truncate '.$this->wrapTable($query->from).' restart identity'.(static::$cascadeTruncate ? ' cascade' : '') => []]; + } + + /** + * Compile a query to get the number of open connections for a database. + * + * @return string + */ + public function compileThreadCount() + { + return 'select count(*) as "Value" from pg_stat_activity'; } /** @@ -443,15 +737,137 @@ protected function wrapJsonSelector($value) } /** - * Wrap the attributes of the give JSON path. + * Wrap the given JSON selector for boolean values. + * + * @param string $value + * @return string + */ + protected function wrapJsonBooleanSelector($value) + { + $selector = str_replace( + '->>', '->', + $this->wrapJsonSelector($value) + ); + + return '('.$selector.')::jsonb'; + } + + /** + * Wrap the given JSON boolean value. + * + * @param string $value + * @return string + */ + protected function wrapJsonBooleanValue($value) + { + return "'".$value."'::jsonb"; + } + + /** + * Wrap the attributes of the given JSON path. * * @param array $path * @return array */ protected function wrapJsonPathAttributes($path) { - return array_map(function ($attribute) { - return "'$attribute'"; - }, $path); + $quote = func_num_args() === 2 ? func_get_arg(1) : "'"; + + return (new Collection($path)) + ->map(fn ($attribute) => $this->parseJsonPathArrayKeys($attribute)) + ->collapse() + ->map(function ($attribute) use ($quote) { + return filter_var($attribute, FILTER_VALIDATE_INT) !== false + ? $attribute + : $quote.$attribute.$quote; + }) + ->all(); + } + + /** + * Parse the given JSON path attribute for array keys. + * + * @param string $attribute + * @return array + */ + protected function parseJsonPathArrayKeys($attribute) + { + if (preg_match('/(\[[^\]]+\])+$/', $attribute, $parts)) { + $key = Str::beforeLast($attribute, $parts[0]); + + preg_match_all('/\[([^\]]+)\]/', $parts[0], $keys); + + return (new Collection([$key])) + ->merge($keys[1]) + ->diff('') + ->values() + ->all(); + } + + return [$attribute]; + } + + /** + * Substitute the given bindings into the given raw SQL query. + * + * @param string $sql + * @param array $bindings + * @return string + */ + public function substituteBindingsIntoRawSql($sql, $bindings) + { + $query = parent::substituteBindingsIntoRawSql($sql, $bindings); + + foreach ($this->operators as $operator) { + if (! str_contains($operator, '?')) { + continue; + } + + $query = str_replace(str_replace('?', '??', $operator), $operator, $query); + } + + return $query; + } + + /** + * Get the Postgres grammar specific operators. + * + * @return array + */ + public function getOperators() + { + return array_values(array_unique(array_merge(parent::getOperators(), static::$customOperators))); + } + + /** + * Set any Postgres grammar specific custom operators. + * + * @param array $operators + * @return void + */ + public static function customOperators(array $operators) + { + static::$customOperators = array_values( + array_merge(static::$customOperators, array_filter(array_filter($operators, 'is_string'))) + ); + } + + /** + * Enable or disable the "cascade" option when compiling the truncate statement. + * + * @param bool $value + * @return void + */ + public static function cascadeOnTruncate(bool $value = true) + { + static::$cascadeTruncate = $value; + } + + /** + * @deprecated use cascadeOnTruncate + */ + public static function cascadeOnTrucate(bool $value = true) + { + self::cascadeOnTruncate($value); } } diff --git a/Query/Grammars/SQLiteGrammar.php b/Query/Grammars/SQLiteGrammar.php index 2d1b9e1286..28401b9e97 100755 --- a/Query/Grammars/SQLiteGrammar.php +++ b/Query/Grammars/SQLiteGrammar.php @@ -2,35 +2,18 @@ namespace Illuminate\Database\Query\Grammars; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; -use Illuminate\Database\Query\Builder; +use InvalidArgumentException; class SQLiteGrammar extends Grammar { - /** - * The components that make up a select clause. - * - * @var array - */ - protected $selectComponents = [ - 'aggregate', - 'columns', - 'from', - 'joins', - 'wheres', - 'groups', - 'havings', - 'orders', - 'limit', - 'offset', - 'lock', - ]; - /** * All of the available clause operators. * - * @var array + * @var string[] */ protected $operators = [ '=', '<', '>', '<=', '>=', '<>', '!=', @@ -39,37 +22,78 @@ class SQLiteGrammar extends Grammar ]; /** - * Compile a select query into SQL. + * Compile the lock into SQL. * * @param \Illuminate\Database\Query\Builder $query + * @param bool|string $value * @return string */ - public function compileSelect(Builder $query) + protected function compileLock(Builder $query, $value) { - if ($query->unions && $query->aggregate) { - return $this->compileUnionAggregate($query); - } + return ''; + } - $sql = parent::compileSelect($query); + /** + * Wrap a union subquery in parentheses. + * + * @param string $sql + * @return string + */ + protected function wrapUnion($sql) + { + return 'select * from ('.$sql.')'; + } - if ($query->unions) { - $sql = 'select * from ('.$sql.') '.$this->compileUnions($query); + /** + * Compile a basic where clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBasic(Builder $query, $where) + { + if ($where['operator'] === '<=>') { + $column = $this->wrap($where['column']); + $value = $this->parameter($where['value']); + + return "{$column} IS {$value}"; } - return $sql; + return parent::whereBasic($query, $where); } /** - * Compile a single union statement. + * Compile a "where like" clause. * - * @param array $union + * @param \Illuminate\Database\Query\Builder $query + * @param array $where * @return string */ - protected function compileUnion(array $union) + protected function whereLike(Builder $query, $where) { - $conjunction = $union['all'] ? ' union all ' : ' union '; + if ($where['caseSensitive'] == false) { + return parent::whereLike($query, $where); + } + $where['operator'] = $where['not'] ? 'not glob' : 'glob'; - return $conjunction.'select * from ('.$union['query']->toSql().')'; + return $this->whereBasic($query, $where); + } + + /** + * Convert a LIKE pattern to a GLOB pattern using simple string replacement. + * + * @param string $value + * @param bool $caseSensitive + * @return string + */ + public function prepareWhereLikeBinding($value, $caseSensitive) + { + return $caseSensitive === false ? $value : str_replace( + ['*', '?', '%', '_'], + ['[*]', '[?]', '*', '?'], + $value + ); } /** @@ -147,6 +171,30 @@ protected function dateBasedWhere($type, Builder $query, $where) return "strftime('{$type}', {$this->wrap($where['column'])}) {$where['operator']} cast({$value} as text)"; } + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + * + * @throws \InvalidArgumentException + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + if ($indexHint->type !== 'force') { + return ''; + } + + $index = $indexHint->index; + + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + + return "indexed by {$index}"; + } + /** * Compile a "JSON length" statement into SQL. * @@ -163,19 +211,60 @@ protected function compileJsonLength($column, $operator, $value) } /** - * Compile an insert statement into SQL. + * Compile a "JSON contains" statement into SQL. + * + * @param string $column + * @param mixed $value + * @return string + */ + protected function compileJsonContains($column, $value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'exists (select 1 from json_each('.$field.$path.') where '.$this->wrap('json_each.value').' is '.$value.')'; + } + + /** + * Prepare the binding for a "JSON contains" statement. + * + * @param mixed $binding + * @return mixed + */ + public function prepareBindingForJsonContains($binding) + { + return $binding; + } + + /** + * Compile a "JSON contains key" statement into SQL. + * + * @param string $column + * @return string + */ + protected function compileJsonContainsKey($column) + { + [$field, $path] = $this->wrapJsonFieldAndPath($column); + + return 'json_type('.$field.$path.') is not null'; + } + + /** + * Compile a group limit clause. * * @param \Illuminate\Database\Query\Builder $query - * @param array $values * @return string */ - public function compileInsert(Builder $query, array $values) + protected function compileGroupLimit(Builder $query) { - $table = $this->wrapTable($query->from); + $version = $query->getConnection()->getServerVersion(); + + if (version_compare($version, '3.25.0', '>=')) { + return parent::compileGroupLimit($query); + } + + $query->groupLimit = null; - return empty($values) - ? "insert into {$table} DEFAULT VALUES" - : parent::compileInsert($query, $values); + return $this->compileSelect($query); } /** @@ -185,21 +274,153 @@ public function compileInsert(Builder $query, array $values) * @param array $values * @return string */ - public function compileUpdate(Builder $query, $values) + public function compileUpdate(Builder $query, array $values) { - $table = $this->wrapTable($query->from); + if (isset($query->joins) || isset($query->limit)) { + return $this->compileUpdateWithJoinsOrLimit($query, $values); + } - $columns = collect($values)->map(function ($value, $key) use ($query) { - return $this->wrap(Str::after($key, $query->from.'.')).' = '.$this->parameter($value); + return parent::compileUpdate($query, $values); + } + + /** + * Compile an insert ignore statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + public function compileInsertOrIgnore(Builder $query, array $values) + { + return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsert($query, $values)); + } + + /** + * Compile an insert or ignore statement with a returning clause into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $returning + * @return string + */ + public function compileInsertOrIgnoreReturning(Builder $query, array $values, array $uniqueBy, array $returning) + { + return $this->compileInsert($query, $values) + .' on conflict ('.$this->columnize($uniqueBy).') do nothing' + .' returning '.$this->columnize($returning); + } + + /** + * Compile an insert ignore statement using a subquery into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $columns + * @param string $sql + * @return string + */ + public function compileInsertOrIgnoreUsing(Builder $query, array $columns, string $sql) + { + return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsertUsing($query, $columns, $sql)); + } + + /** + * Compile the columns for an update statement. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + protected function compileUpdateColumns(Builder $query, array $values) + { + $jsonGroups = $this->groupJsonColumnsForUpdate($values); + + return (new Collection($values)) + ->reject(fn ($value, $key) => $this->isJsonSelector($key)) + ->merge($jsonGroups) + ->map(function ($value, $key) use ($jsonGroups) { + $column = last(explode('.', $key)); + + $value = isset($jsonGroups[$key]) ? $this->compileJsonPatch($column, $value) : $this->parameter($value); + + return $this->wrap($column).' = '.$value; + }) + ->implode(', '); + } + + /** + * Compile an "upsert" statement into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @param array $uniqueBy + * @param array $update + * @return string + */ + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) + { + $sql = $this->compileInsert($query, $values); + + $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; + + $columns = (new Collection($update))->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrapValue('excluded').'.'.$this->wrap($value) + : $this->wrap($key).' = '.$this->parameter($value); })->implode(', '); - if (isset($query->joins) || isset($query->limit)) { - $selectSql = parent::compileSelect($query->select("{$query->from}.rowid")); + return $sql.$columns; + } + + /** + * Group the nested JSON columns. + * + * @param array $values + * @return array + */ + protected function groupJsonColumnsForUpdate(array $values) + { + $groups = []; - return "update {$table} set $columns where {$this->wrap('rowid')} in ({$selectSql})"; + foreach ($values as $key => $value) { + if ($this->isJsonSelector($key)) { + Arr::set($groups, str_replace('->', '.', Str::after($key, '.')), $value); + } } - return trim("update {$table} set {$columns} {$this->compileWheres($query)}"); + return $groups; + } + + /** + * Compile a "JSON" patch statement into SQL. + * + * @param string $column + * @param mixed $value + * @return string + */ + protected function compileJsonPatch($column, $value) + { + return "json_patch(ifnull({$this->wrap($column)}, json('{}')), json({$this->parameter($value)}))"; + } + + /** + * Compile an update statement with joins or limit into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $values + * @return string + */ + protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values) + { + $table = $this->wrapTable($query->from); + + $columns = $this->compileUpdateColumns($query, $values); + + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias.'.rowid')); + + return "update {$table} set {$columns} where {$this->wrap('rowid')} in ({$selectSql})"; } /** @@ -209,12 +430,23 @@ public function compileUpdate(Builder $query, $values) * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { - $cleanBindings = Arr::except($bindings, ['select', 'join']); + $groups = $this->groupJsonColumnsForUpdate($values); + + $values = (new Collection($values)) + ->reject(fn ($value, $key) => $this->isJsonSelector($key)) + ->merge($groups) + ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->all(); + + $cleanBindings = Arr::except($bindings, 'select'); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); return array_values( - array_merge($values, $bindings['join'], Arr::flatten($cleanBindings)) + array_merge($values, Arr::flatten($cleanBindings)) ); } @@ -227,29 +459,27 @@ public function prepareBindingsForUpdate(array $bindings, array $values) public function compileDelete(Builder $query) { if (isset($query->joins) || isset($query->limit)) { - $selectSql = parent::compileSelect($query->select("{$query->from}.rowid")); - - return "delete from {$this->wrapTable($query->from)} where {$this->wrap('rowid')} in ({$selectSql})"; + return $this->compileDeleteWithJoinsOrLimit($query); } - $wheres = is_array($query->wheres) ? $this->compileWheres($query) : ''; - - return trim("delete from {$this->wrapTable($query->from)} $wheres"); + return parent::compileDelete($query); } /** - * Prepare the bindings for a delete statement. + * Compile a delete statement with joins or limit into SQL. * - * @param array $bindings - * @return array + * @param \Illuminate\Database\Query\Builder $query + * @return string */ - public function prepareBindingsForDelete(array $bindings) + protected function compileDeleteWithJoinsOrLimit(Builder $query) { - $cleanBindings = Arr::except($bindings, ['select', 'join']); + $table = $this->wrapTable($query->from); - return array_values( - array_merge($bindings['join'], Arr::flatten($cleanBindings)) - ); + $alias = last(preg_split('/\s+as\s+/i', $query->from)); + + $selectSql = $this->compileSelect($query->select($alias.'.rowid')); + + return "delete from {$table} where {$this->wrap('rowid')} in ({$selectSql})"; } /** @@ -260,8 +490,12 @@ public function prepareBindingsForDelete(array $bindings) */ public function compileTruncate(Builder $query) { + [$schema, $table] = $query->getConnection()->getSchemaBuilder()->parseSchemaAndTable($query->from); + + $schema = $schema ? $this->wrapValue($schema).'.' : ''; + return [ - 'delete from sqlite_sequence where name = ?' => [$query->from], + 'delete from '.$schema.'sqlite_sequence where name = ?' => [$query->getConnection()->getTablePrefix().$table], 'delete from '.$this->wrapTable($query->from) => [], ]; } diff --git a/Query/Grammars/SqlServerGrammar.php b/Query/Grammars/SqlServerGrammar.php index c7ab71b63e..f22f528e35 100755 --- a/Query/Grammars/SqlServerGrammar.php +++ b/Query/Grammars/SqlServerGrammar.php @@ -2,15 +2,19 @@ namespace Illuminate\Database\Query\Grammars; -use Illuminate\Support\Arr; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinLateralClause; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use InvalidArgumentException; class SqlServerGrammar extends Grammar { /** * All of the available clause operators. * - * @var array + * @var string[] */ protected $operators = [ '=', '<', '>', '<=', '>=', '!<', '!>', '<>', '!=', @@ -18,6 +22,26 @@ class SqlServerGrammar extends Grammar '&', '&=', '|', '|=', '^', '^=', ]; + /** + * The components that make up a select clause. + * + * @var string[] + */ + protected $selectComponents = [ + 'aggregate', + 'columns', + 'from', + 'indexHint', + 'joins', + 'wheres', + 'groups', + 'havings', + 'orders', + 'offset', + 'limit', + 'lock', + ]; + /** * Compile a select query into SQL. * @@ -26,20 +50,12 @@ class SqlServerGrammar extends Grammar */ public function compileSelect(Builder $query) { - if (! $query->offset) { - return parent::compileSelect($query); - } - - // If an offset is present on the query, we will need to wrap the query in - // a big "ANSI" offset syntax block. This is very nasty compared to the - // other database systems but is necessary for implementing features. - if (is_null($query->columns)) { - $query->columns = ['*']; + // An order by clause is required for SQL Server offset to function... + if ($query->offset && empty($query->orders)) { + $query->orders[] = ['sql' => '(SELECT 0)']; } - return $this->compileAnsiOffset( - $query, $this->compileComponents($query) - ); + return parent::compileSelect($query); } /** @@ -60,8 +76,8 @@ protected function compileColumns(Builder $query, $columns) // If there is a limit on the query, but not an offset, we will add the top // clause to the query, which serves as a "limit" type clause within the // SQL Server system similar to the limit keywords available in MySQL. - if ($query->limit > 0 && $query->offset <= 0) { - $select .= 'top '.$query->limit.' '; + if (is_numeric($query->limit) && $query->limit > 0 && $query->offset <= 0) { + $select .= 'top '.((int) $query->limit).' '; } return $select.$this->columnize($columns); @@ -89,6 +105,46 @@ protected function compileFrom(Builder $query, $table) return $from; } + /** + * Compile the index hints for the query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param \Illuminate\Database\Query\IndexHint $indexHint + * @return string + * + * @throws \InvalidArgumentException + */ + protected function compileIndexHint(Builder $query, $indexHint) + { + if ($indexHint->type !== 'force') { + return ''; + } + + $index = $indexHint->index; + + if (! preg_match('/^[a-zA-Z0-9_$]+$/', $index)) { + throw new InvalidArgumentException('Index name contains invalid characters.'); + } + + return "with (index([{$index}]))"; + } + + /** + * {@inheritdoc} + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereBitwise(Builder $query, $where) + { + $value = $this->parameter($where['value']); + + $operator = str_replace('?', '??', $where['operator']); + + return '('.$this->wrap($where['column']).' '.$operator.' '.$value.') != 0'; + } + /** * Compile a "where date" clause. * @@ -142,6 +198,31 @@ public function prepareBindingForJsonContains($binding) return is_bool($binding) ? json_encode($binding) : $binding; } + /** + * Compile a "JSON contains key" statement into SQL. + * + * @param string $column + * @return string + */ + protected function compileJsonContainsKey($column) + { + $segments = explode('->', $column); + + $lastSegment = array_pop($segments); + + if (preg_match('/\[([0-9]+)\]$/', $lastSegment, $matches)) { + $segments[] = Str::beforeLast($lastSegment, $matches[0]); + + $key = $matches[1]; + } else { + $key = "'".str_replace("'", "''", $lastSegment)."'"; + } + + [$field, $path] = $this->wrapJsonFieldAndPath(implode('->', $segments)); + + return $key.' in (select [key] from openjson('.$field.$path.'))'; + } + /** * Compile a "JSON length" statement into SQL. * @@ -158,84 +239,67 @@ protected function compileJsonLength($column, $operator, $value) } /** - * Create a full ANSI offset clause for the query. + * Compile a "JSON value cast" statement into SQL. * - * @param \Illuminate\Database\Query\Builder $query - * @param array $components + * @param string $value * @return string */ - protected function compileAnsiOffset(Builder $query, $components) + public function compileJsonValueCast($value) { - // An ORDER BY clause is required to make this offset query work, so if one does - // not exist we'll just create a dummy clause to trick the database and so it - // does not complain about the queries for not having an "order by" clause. - if (empty($components['orders'])) { - $components['orders'] = 'order by (select 0)'; - } - - // We need to add the row number to the query so we can compare it to the offset - // and limit values given for the statements. So we will add an expression to - // the "select" that will give back the row numbers on each of the records. - $components['columns'] .= $this->compileOver($components['orders']); - - unset($components['orders']); - - // Next we need to calculate the constraints that should be placed on the query - // to get the right offset and limit from our query but if there is no limit - // set we will just handle the offset only since that is all that matters. - $sql = $this->concatenate($components); - - return $this->compileTableExpression($sql, $query); + return 'json_query('.$value.')'; } /** - * Compile the over statement for a table expression. + * Compile a single having clause. * - * @param string $orderings + * @param array $having * @return string */ - protected function compileOver($orderings) + protected function compileHaving(array $having) { - return ", row_number() over ({$orderings}) as row_num"; + if ($having['type'] === 'Bitwise') { + return $this->compileHavingBitwise($having); + } + + return parent::compileHaving($having); } /** - * Compile a common table expression for a query. + * Compile a having clause involving a bitwise operator. * - * @param string $sql - * @param \Illuminate\Database\Query\Builder $query + * @param array $having * @return string */ - protected function compileTableExpression($sql, $query) + protected function compileHavingBitwise($having) { - $constraint = $this->compileRowConstraint($query); + $column = $this->wrap($having['column']); + + $parameter = $this->parameter($having['value']); - return "select * from ({$sql}) as temp_table where row_num {$constraint} order by row_num"; + return '('.$column.' '.$having['operator'].' '.$parameter.') != 0'; } /** - * Compile the limit / offset row constraint for a query. + * Compile a delete statement without joins into SQL. * * @param \Illuminate\Database\Query\Builder $query + * @param string $table + * @param string $where * @return string */ - protected function compileRowConstraint($query) + protected function compileDeleteWithoutJoins(Builder $query, $table, $where) { - $start = $query->offset + 1; - - if ($query->limit > 0) { - $finish = $query->offset + $query->limit; - - return "between {$start} and {$finish}"; - } + $sql = parent::compileDeleteWithoutJoins($query, $table, $where); - return ">= {$start}"; + return ! is_null($query->limit) && $query->limit > 0 && $query->offset <= 0 + ? Str::replaceFirst('delete', 'delete top ('.$query->limit.')', $sql) + : $sql; } /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) @@ -252,9 +316,31 @@ public function compileRandom($seed) */ protected function compileLimit(Builder $query, $limit) { + $limit = (int) $limit; + + if ($limit && $query->offset > 0) { + return "fetch next {$limit} rows only"; + } + return ''; } + /** + * Compile a row number clause. + * + * @param string $partition + * @param string $orders + * @return string + */ + protected function compileRowNumber($partition, $orders) + { + if (empty($orders)) { + $orders = 'order by (select 0)'; + } + + return parent::compileRowNumber($partition, $orders); + } + /** * Compile the "offset" portions of the query. * @@ -264,6 +350,12 @@ protected function compileLimit(Builder $query, $limit) */ protected function compileOffset(Builder $query, $offset) { + $offset = (int) $offset; + + if ($offset) { + return "offset {$offset} rows"; + } + return ''; } @@ -280,120 +372,89 @@ protected function compileLock(Builder $query, $value) } /** - * Compile an exists statement into SQL. + * Wrap a union subquery in parentheses. * - * @param \Illuminate\Database\Query\Builder $query + * @param string $sql * @return string */ - public function compileExists(Builder $query) + protected function wrapUnion($sql) { - $existsQuery = clone $query; - - $existsQuery->columns = []; - - return $this->compileSelect($existsQuery->selectRaw('1 [exists]')->limit(1)); + return 'select * from ('.$sql.') as '.$this->wrapTable('temp_table'); } /** - * Compile a delete statement into SQL. + * Compile an exists statement into SQL. * * @param \Illuminate\Database\Query\Builder $query * @return string */ - public function compileDelete(Builder $query) + public function compileExists(Builder $query) { - $table = $this->wrapTable($query->from); + $existsQuery = clone $query; - $where = is_array($query->wheres) ? $this->compileWheres($query) : ''; + $existsQuery->columns = []; - return isset($query->joins) - ? $this->compileDeleteWithJoins($query, $table, $where) - : trim("delete from {$table} {$where}"); + return $this->compileSelect($existsQuery->selectRaw('1 [exists]')->limit(1)); } /** - * Compile a delete statement with joins into SQL. + * Compile an update statement with joins into SQL. * * @param \Illuminate\Database\Query\Builder $query * @param string $table + * @param string $columns * @param string $where * @return string */ - protected function compileDeleteWithJoins(Builder $query, $table, $where) + protected function compileUpdateWithJoins(Builder $query, $table, $columns, $where) { - $joins = ' '.$this->compileJoins($query, $query->joins); + $alias = last(explode(' as ', $table)); - $alias = stripos($table, ' as ') !== false - ? explode(' as ', $table)[1] : $table; + $joins = $this->compileJoins($query, $query->joins); - return trim("delete {$alias} from {$table}{$joins} {$where}"); + return "update {$alias} set {$columns} from {$table} {$joins} {$where}"; } /** - * Compile a truncate table statement into SQL. - * - * @param \Illuminate\Database\Query\Builder $query - * @return array - */ - public function compileTruncate(Builder $query) - { - return ['truncate table '.$this->wrapTable($query->from) => []]; - } - - /** - * Compile an update statement into SQL. + * Compile an "upsert" statement into SQL. * * @param \Illuminate\Database\Query\Builder $query * @param array $values + * @param array $uniqueBy + * @param array $update * @return string */ - public function compileUpdate(Builder $query, $values) + public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) { - [$table, $alias] = $this->parseUpdateTable($query->from); + $columns = $this->columnize(array_keys(array_first($values))); - // Each one of the columns in the update statements needs to be wrapped in the - // keyword identifiers, also a place-holder needs to be created for each of - // the values in the list of bindings so we can make the sets statements. - $columns = collect($values)->map(function ($value, $key) { - return $this->wrap($key).' = '.$this->parameter($value); - })->implode(', '); + $sql = 'merge '.$this->wrapTable($query->from).' '; - // If the query has any "join" clauses, we will setup the joins on the builder - // and compile them so we can attach them to this update, as update queries - // can get join statements to attach to other tables when they're needed. - $joins = ''; + $parameters = (new Collection($values)) + ->map(fn ($record) => '('.$this->parameterize($record).')') + ->implode(', '); - if (isset($query->joins)) { - $joins = ' '.$this->compileJoins($query, $query->joins); - } + $sql .= 'using (values '.$parameters.') '.$this->wrapTable('laravel_source').' ('.$columns.') '; - // Of course, update queries may also be constrained by where clauses so we'll - // need to compile the where clauses and attach it to the query so only the - // intended records are updated by the SQL statements we generate to run. - $where = $this->compileWheres($query); + $on = (new Collection($uniqueBy)) + ->map(fn ($column) => $this->wrap('laravel_source.'.$column).' = '.$this->wrap($query->from.'.'.$column)) + ->implode(' and '); - if (! empty($joins)) { - return trim("update {$alias} set {$columns} from {$table}{$joins} {$where}"); - } - - return trim("update {$table}{$joins} set $columns $where"); - } + $sql .= 'on '.$on.' '; - /** - * Get the table and alias for the given table. - * - * @param string $table - * @return array - */ - protected function parseUpdateTable($table) - { - $table = $alias = $this->wrapTable($table); + if ($update) { + $update = (new Collection($update))->map(function ($value, $key) { + return is_numeric($key) + ? $this->wrap($value).' = '.$this->wrap('laravel_source.'.$value) + : $this->wrap($key).' = '.$this->parameter($value); + })->implode(', '); - if (stripos($table, '] as [') !== false) { - $alias = '['.explode('] as [', $table)[1]; + $sql .= 'when matched then update set '.$update.' '; } - return [$table, $alias]; + $sql .= 'when not matched then insert ('.$columns.') values ('.$columns.');'; + + return $sql; } /** @@ -403,18 +464,32 @@ protected function parseUpdateTable($table) * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { - // Update statements with joins in SQL Servers utilize an unique syntax. We need to - // take all of the bindings and put them on the end of this array since they are - // added to the end of the "where" clause statements as typical where clauses. - $bindingsWithoutJoin = Arr::except($bindings, 'join'); + $cleanBindings = Arr::except($bindings, 'select'); + + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); return array_values( - array_merge($values, $bindings['join'], Arr::flatten($bindingsWithoutJoin)) + array_merge($values, Arr::flatten($cleanBindings)) ); } + /** + * Compile a "lateral join" clause. + * + * @param \Illuminate\Database\Query\JoinLateralClause $join + * @param string $expression + * @return string + */ + public function compileJoinLateral(JoinLateralClause $join, string $expression): string + { + $type = $join->type == 'left' ? 'outer' : 'cross'; + + return trim("{$type} apply {$expression}"); + } + /** * Compile the SQL statement to define a savepoint. * @@ -437,6 +512,16 @@ public function compileSavepointRollBack($name) return 'ROLLBACK TRANSACTION '.$name; } + /** + * Compile a query to get the number of open connections for a database. + * + * @return string + */ + public function compileThreadCount() + { + return 'select count(*) Value from sys.dm_exec_sessions where status = N\'running\''; + } + /** * Get the format for database stored dates. * @@ -471,16 +556,28 @@ protected function wrapJsonSelector($value) return 'json_value('.$field.$path.')'; } + /** + * Wrap the given JSON boolean value. + * + * @param string $value + * @return string + */ + protected function wrapJsonBooleanValue($value) + { + return "'".$value."'"; + } + /** * Wrap a table in keyword identifiers. * - * @param \Illuminate\Database\Query\Expression|string $table + * @param \Illuminate\Contracts\Database\Query\Expression|string $table + * @param string|null $prefix * @return string */ - public function wrapTable($table) + public function wrapTable($table, $prefix = null) { if (! $this->isExpression($table)) { - return $this->wrapTableValuedFunction(parent::wrapTable($table)); + return $this->wrapTableValuedFunction(parent::wrapTable($table, $prefix)); } return $this->getValue($table); diff --git a/Query/IndexHint.php b/Query/IndexHint.php new file mode 100755 index 0000000000..5659daa548 --- /dev/null +++ b/Query/IndexHint.php @@ -0,0 +1,32 @@ +type = $type; + $this->index = $index; + } +} diff --git a/Query/JoinClause.php b/Query/JoinClause.php index 267854e74d..d5733f3550 100755 --- a/Query/JoinClause.php +++ b/Query/JoinClause.php @@ -16,33 +16,56 @@ class JoinClause extends Builder /** * The table the join clause is joining to. * - * @var string + * @var \Illuminate\Contracts\Database\Query\Expression|string */ public $table; /** - * The parent query builder instance. + * The connection of the parent query builder. + * + * @var \Illuminate\Database\ConnectionInterface + */ + protected $parentConnection; + + /** + * The grammar of the parent query builder. + * + * @var \Illuminate\Database\Query\Grammars\Grammar + */ + protected $parentGrammar; + + /** + * The processor of the parent query builder. + * + * @var \Illuminate\Database\Query\Processors\Processor + */ + protected $parentProcessor; + + /** + * The class name of the parent query builder. * - * @var \Illuminate\Database\Query\Builder + * @var string */ - private $parentQuery; + protected $parentClass; /** * Create a new join clause instance. * - * @param \Illuminate\Database\Query\Builder $parentQuery + * @param \Illuminate\Database\Query\Builder $parentQuery * @param string $type * @param string $table - * @return void */ public function __construct(Builder $parentQuery, $type, $table) { $this->type = $type; $this->table = $table; - $this->parentQuery = $parentQuery; + $this->parentClass = get_class($parentQuery); + $this->parentGrammar = $parentQuery->getGrammar(); + $this->parentProcessor = $parentQuery->getProcessor(); + $this->parentConnection = $parentQuery->getConnection(); parent::__construct( - $parentQuery->getConnection(), $parentQuery->getGrammar(), $parentQuery->getProcessor() + $this->parentConnection, $this->parentGrammar, $this->parentProcessor ); } @@ -58,9 +81,9 @@ public function __construct(Builder $parentQuery, $type, $table) * * on `contacts`.`user_id` = `users`.`id` and `contacts`.`info_id` = `info`.`id` * - * @param \Closure|string $first + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @param string $boolean * @return $this * @@ -78,9 +101,9 @@ public function on($first, $operator = null, $second = null, $boolean = 'and') /** * Add an "or on" clause to the join. * - * @param \Closure|string $first + * @param \Closure|\Illuminate\Contracts\Database\Query\Expression|string $first * @param string|null $operator - * @param string|null $second + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return \Illuminate\Database\Query\JoinClause */ public function orOn($first, $operator = null, $second = null) @@ -95,7 +118,7 @@ public function orOn($first, $operator = null, $second = null) */ public function newQuery() { - return new static($this->parentQuery, $this->type, $this->table); + return new static($this->newParentQuery(), $this->type, $this->table); } /** @@ -105,6 +128,18 @@ public function newQuery() */ protected function forSubQuery() { - return $this->parentQuery->newQuery(); + return $this->newParentQuery()->newQuery(); + } + + /** + * Create a new parent query instance. + * + * @return \Illuminate\Database\Query\Builder + */ + protected function newParentQuery() + { + $class = $this->parentClass; + + return new $class($this->parentConnection, $this->parentGrammar, $this->parentProcessor); } } diff --git a/Query/JoinLateralClause.php b/Query/JoinLateralClause.php new file mode 100644 index 0000000000..1be31d2962 --- /dev/null +++ b/Query/JoinLateralClause.php @@ -0,0 +1,8 @@ +getJsonBindingParameter($value) - ); - } - - /** - * Translate the given value into the appropriate JSON binding parameter. - * - * @param mixed $value - * @return string - * - * @throws \InvalidArgumentException - */ - protected function getJsonBindingParameter($value) - { - if ($value instanceof Expression) { - return $value->getValue(); - } - - switch ($type = gettype($value)) { - case 'boolean': - return $value ? 'true' : 'false'; - case 'NULL': - case 'integer': - case 'double': - case 'string': - return '?'; - case 'object': - case 'array': - return '?'; - } - - throw new InvalidArgumentException("JSON value is of illegal type: {$type}"); - } -} diff --git a/Query/Processors/MariaDbProcessor.php b/Query/Processors/MariaDbProcessor.php new file mode 100644 index 0000000000..7a549d57fc --- /dev/null +++ b/Query/Processors/MariaDbProcessor.php @@ -0,0 +1,8 @@ +column_name; }, $results); } + + /** + * Process an "insert get ID" query. + * + * @param \Illuminate\Database\Query\Builder $query + * @param string $sql + * @param array $values + * @param string|null $sequence + * @return int + */ + public function processInsertGetId(Builder $query, $sql, $values, $sequence = null) + { + $query->getConnection()->insert($sql, $values, $sequence); + + $id = $query->getConnection()->getLastInsertId(); + + return is_numeric($id) ? (int) $id : $id; + } + + /** @inheritDoc */ + public function processColumns($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'type_name' => $result->type_name, + 'type' => $result->type, + 'collation' => $result->collation, + 'nullable' => $result->nullable === 'YES', + 'default' => $result->default, + 'auto_increment' => $result->extra === 'auto_increment', + 'comment' => $result->comment ?: null, + 'generation' => $result->expression ? [ + 'type' => match ($result->extra) { + 'STORED GENERATED' => 'stored', + 'VIRTUAL GENERATED' => 'virtual', + default => null, + }, + 'expression' => $result->expression, + ] : null, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processIndexes($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $name = strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => strtolower($result->type), + 'unique' => (bool) $result->unique, + 'primary' => $name === 'primary', + ]; + }, $results); + } + + /** @inheritDoc */ + public function processForeignKeys($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower($result->on_update), + 'on_delete' => strtolower($result->on_delete), + ]; + }, $results); + } } diff --git a/Query/Processors/PostgresProcessor.php b/Query/Processors/PostgresProcessor.php index 90abf24682..2818f91d8c 100755 --- a/Query/Processors/PostgresProcessor.php +++ b/Query/Processors/PostgresProcessor.php @@ -11,13 +11,17 @@ class PostgresProcessor extends Processor * * @param \Illuminate\Database\Query\Builder $query * @param string $sql - * @param array $values - * @param string $sequence + * @param array $values + * @param string|null $sequence * @return int */ public function processInsertGetId(Builder $query, $sql, $values, $sequence = null) { - $result = $query->getConnection()->selectFromWriteConnection($sql, $values)[0]; + $connection = $query->getConnection(); + + $connection->recordsHaveBeenModified(); + + $result = $connection->selectFromWriteConnection($sql, $values)[0]; $sequence = $sequence ?: 'id'; @@ -26,16 +30,124 @@ public function processInsertGetId(Builder $query, $sql, $values, $sequence = nu return is_numeric($id) ? (int) $id : $id; } - /** - * Process the results of a column listing query. - * - * @param array $results - * @return array - */ - public function processColumnListing($results) + /** @inheritDoc */ + public function processTypes($results) { return array_map(function ($result) { - return ((object) $result)->column_name; + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema, + 'schema_qualified_name' => $result->schema.'.'.$result->name, + 'implicit' => (bool) $result->implicit, + 'type' => match (strtolower($result->type)) { + 'b' => 'base', + 'c' => 'composite', + 'd' => 'domain', + 'e' => 'enum', + 'p' => 'pseudo', + 'r' => 'range', + 'm' => 'multirange', + default => null, + }, + 'category' => match (strtolower($result->category)) { + 'a' => 'array', + 'b' => 'boolean', + 'c' => 'composite', + 'd' => 'date_time', + 'e' => 'enum', + 'g' => 'geometric', + 'i' => 'network_address', + 'n' => 'numeric', + 'p' => 'pseudo', + 'r' => 'range', + 's' => 'string', + 't' => 'timespan', + 'u' => 'user_defined', + 'v' => 'bit_string', + 'x' => 'unknown', + 'z' => 'internal_use', + default => null, + }, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processColumns($results) + { + return array_map(function ($result) { + $result = (object) $result; + + $autoincrement = $result->default !== null && str_starts_with($result->default, 'nextval('); + + return [ + 'name' => $result->name, + 'type_name' => $result->type_name, + 'type' => $result->type, + 'collation' => $result->collation, + 'nullable' => (bool) $result->nullable, + 'default' => $result->generated ? null : $result->default, + 'auto_increment' => $autoincrement, + 'comment' => $result->comment, + 'generation' => $result->generated ? [ + 'type' => match ($result->generated) { + 's' => 'stored', + 'v' => 'virtual', + default => null, + }, + 'expression' => $result->default, + ] : null, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processIndexes($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => strtolower($result->type), + 'unique' => (bool) $result->unique, + 'primary' => (bool) $result->primary, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processForeignKeys($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => match (strtolower($result->on_update)) { + 'a' => 'no action', + 'r' => 'restrict', + 'c' => 'cascade', + 'n' => 'set null', + 'd' => 'set default', + default => null, + }, + 'on_delete' => match (strtolower($result->on_delete)) { + 'a' => 'no action', + 'r' => 'restrict', + 'c' => 'cascade', + 'n' => 'set null', + 'd' => 'set default', + default => null, + }, + ]; }, $results); } } diff --git a/Query/Processors/Processor.php b/Query/Processors/Processor.php index f78429fbaf..46f692e49a 100755 --- a/Query/Processors/Processor.php +++ b/Query/Processors/Processor.php @@ -23,8 +23,8 @@ public function processSelect(Builder $query, $results) * * @param \Illuminate\Database\Query\Builder $query * @param string $sql - * @param array $values - * @param string $sequence + * @param array $values + * @param string|null $sequence * @return int */ public function processInsertGetId(Builder $query, $sql, $values, $sequence = null) @@ -37,12 +37,107 @@ public function processInsertGetId(Builder $query, $sql, $values, $sequence = nu } /** - * Process the results of a column listing query. + * Process the results of a schemas query. * - * @param array $results - * @return array + * @param list> $results + * @return list + */ + public function processSchemas($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'path' => $result->path ?? null, // SQLite Only... + 'default' => (bool) $result->default, + ]; + }, $results); + } + + /** + * Process the results of a tables query. + * + * @param list> $results + * @return list + */ + public function processTables($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema ?? null, + 'schema_qualified_name' => isset($result->schema) ? $result->schema.'.'.$result->name : $result->name, + 'size' => isset($result->size) ? (int) $result->size : null, + 'comment' => $result->comment ?? null, // MySQL and PostgreSQL + 'collation' => $result->collation ?? null, // MySQL only + 'engine' => $result->engine ?? null, // MySQL only + ]; + }, $results); + } + + /** + * Process the results of a views query. + * + * @param list> $results + * @return list + */ + public function processViews($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'schema' => $result->schema ?? null, + 'schema_qualified_name' => isset($result->schema) ? $result->schema.'.'.$result->name : $result->name, + 'definition' => $result->definition, + ]; + }, $results); + } + + /** + * Process the results of a types query. + * + * @param list> $results + * @return list + */ + public function processTypes($results) + { + return $results; + } + + /** + * Process the results of a columns query. + * + * @param list> $results + * @return list + */ + public function processColumns($results) + { + return $results; + } + + /** + * Process the results of an indexes query. + * + * @param list> $results + * @return list, type: string, unique: bool, primary: bool}> + */ + public function processIndexes($results) + { + return $results; + } + + /** + * Process the results of a foreign keys query. + * + * @param list> $results + * @return list, foreign_schema: string, foreign_table: string, foreign_columns: list, on_update: string, on_delete: string}> */ - public function processColumnListing($results) + public function processForeignKeys($results) { return $results; } diff --git a/Query/Processors/SQLiteProcessor.php b/Query/Processors/SQLiteProcessor.php index 65da1dff7c..ed4916a7a5 100644 --- a/Query/Processors/SQLiteProcessor.php +++ b/Query/Processors/SQLiteProcessor.php @@ -4,16 +4,96 @@ class SQLiteProcessor extends Processor { - /** - * Process the results of a column listing query. - * - * @param array $results - * @return array - */ - public function processColumnListing($results) + /** @inheritDoc */ + public function processColumns($results, $sql = '') + { + $hasPrimaryKey = array_sum(array_column($results, 'primary')) === 1; + + return array_map(function ($result) use ($hasPrimaryKey, $sql) { + $result = (object) $result; + + $type = strtolower($result->type); + + $safeName = preg_quote($result->name, '/'); + + $collation = preg_match( + '/\b'.$safeName.'\b[^,(]+(?:\([^()]+\)[^,]*)?(?:(?:default|check|as)\s*(?:\(.*?\))?[^,]*)*collate\s+["\'`]?(\w+)/i', + $sql, + $matches + ) === 1 ? strtolower($matches[1]) : null; + + $isGenerated = in_array($result->extra, [2, 3]); + + $expression = $isGenerated && preg_match( + '/\b'.$safeName.'\b[^,]+\s+as\s+\(((?:[^()]+|\((?:[^()]+|\([^()]*\))*\))*)\)/i', + $sql, + $matches + ) === 1 ? $matches[1] : null; + + return [ + 'name' => $result->name, + 'type_name' => strtok($type, '(') ?: '', + 'type' => $type, + 'collation' => $collation, + 'nullable' => (bool) $result->nullable, + 'default' => $result->default, + 'auto_increment' => $hasPrimaryKey && $result->primary && $type === 'integer', + 'comment' => null, + 'generation' => $isGenerated ? [ + 'type' => match ((int) $result->extra) { + 3 => 'stored', + 2 => 'virtual', + default => null, + }, + 'expression' => $expression, + ] : null, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processIndexes($results) + { + $primaryCount = 0; + + $indexes = array_map(function ($result) use (&$primaryCount) { + $result = (object) $result; + + if ($isPrimary = (bool) $result->primary) { + $primaryCount += 1; + } + + return [ + 'name' => strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => null, + 'unique' => (bool) $result->unique, + 'primary' => $isPrimary, + ]; + }, $results); + + if ($primaryCount > 1) { + $indexes = array_filter($indexes, fn ($index) => $index['name'] !== 'primary'); + } + + return $indexes; + } + + /** @inheritDoc */ + public function processForeignKeys($results) { return array_map(function ($result) { - return ((object) $result)->name; + $result = (object) $result; + + return [ + 'name' => null, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower($result->on_update), + 'on_delete' => strtolower($result->on_delete), + ]; }, $results); } } diff --git a/Query/Processors/SqlServerProcessor.php b/Query/Processors/SqlServerProcessor.php index 4912d9d968..8d000c4579 100755 --- a/Query/Processors/SqlServerProcessor.php +++ b/Query/Processors/SqlServerProcessor.php @@ -13,8 +13,8 @@ class SqlServerProcessor extends Processor * * @param \Illuminate\Database\Query\Builder $query * @param string $sql - * @param array $values - * @param string $sequence + * @param array $values + * @param string|null $sequence * @return int */ public function processInsertGetId(Builder $query, $sql, $values, $sequence = null) @@ -55,16 +55,67 @@ protected function processInsertGetIdForOdbc(Connection $connection) return is_object($row) ? $row->insertid : $row['insertid']; } - /** - * Process the results of a column listing query. - * - * @param array $results - * @return array - */ - public function processColumnListing($results) + /** @inheritDoc */ + public function processColumns($results) { return array_map(function ($result) { - return ((object) $result)->name; + $result = (object) $result; + + $type = match ($typeName = $result->type_name) { + 'binary', 'varbinary', 'char', 'varchar', 'nchar', 'nvarchar' => $result->length == -1 ? $typeName.'(max)' : $typeName."($result->length)", + 'decimal', 'numeric' => $typeName."($result->precision,$result->places)", + 'float', 'datetime2', 'datetimeoffset', 'time' => $typeName."($result->precision)", + default => $typeName, + }; + + return [ + 'name' => $result->name, + 'type_name' => $result->type_name, + 'type' => $type, + 'collation' => $result->collation, + 'nullable' => (bool) $result->nullable, + 'default' => $result->default, + 'auto_increment' => (bool) $result->autoincrement, + 'comment' => $result->comment, + 'generation' => $result->expression ? [ + 'type' => $result->persisted ? 'stored' : 'virtual', + 'expression' => $result->expression, + ] : null, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processIndexes($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => strtolower($result->name), + 'columns' => $result->columns ? explode(',', $result->columns) : [], + 'type' => strtolower($result->type), + 'unique' => (bool) $result->unique, + 'primary' => (bool) $result->primary, + ]; + }, $results); + } + + /** @inheritDoc */ + public function processForeignKeys($results) + { + return array_map(function ($result) { + $result = (object) $result; + + return [ + 'name' => $result->name, + 'columns' => explode(',', $result->columns), + 'foreign_schema' => $result->foreign_schema, + 'foreign_table' => $result->foreign_table, + 'foreign_columns' => explode(',', $result->foreign_columns), + 'on_update' => strtolower(str_replace('_', ' ', $result->on_update)), + 'on_delete' => strtolower(str_replace('_', ' ', $result->on_delete)), + ]; }, $results); } } diff --git a/QueryException.php b/QueryException.php index 9a3687d65b..b9251d2fe0 100644 --- a/QueryException.php +++ b/QueryException.php @@ -2,11 +2,20 @@ namespace Illuminate\Database; -use PDOException; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; +use PDOException; +use Throwable; class QueryException extends PDOException { + /** + * The database connection name. + * + * @var string + */ + public $connectionName; + /** * The SQL for the query. * @@ -21,22 +30,41 @@ class QueryException extends PDOException */ protected $bindings; + /** + * The PDO read / write type for the executed query. + * + * @var null|'read'|'write' + */ + public $readWriteType; + + /** + * The connection details for the query (host, port, database, etc.). + * + * @var array + */ + protected $connectionDetails = []; + /** * Create a new query exception instance. * + * @param string $connectionName * @param string $sql * @param array $bindings - * @param \Exception $previous - * @return void + * @param \Throwable $previous + * @param null|'read'|'write' $readWriteType + * @param array $connectionDetails */ - public function __construct($sql, array $bindings, $previous) + public function __construct($connectionName, $sql, array $bindings, Throwable $previous, array $connectionDetails = [], $readWriteType = null) { parent::__construct('', 0, $previous); + $this->connectionName = $connectionName; $this->sql = $sql; $this->bindings = $bindings; + $this->connectionDetails = $connectionDetails; + $this->readWriteType = $readWriteType; $this->code = $previous->getCode(); - $this->message = $this->formatMessage($sql, $bindings, $previous); + $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous); if ($previous instanceof PDOException) { $this->errorInfo = $previous->errorInfo; @@ -46,14 +74,58 @@ public function __construct($sql, array $bindings, $previous) /** * Format the SQL error message. * + * @param string $connectionName * @param string $sql * @param array $bindings - * @param \Exception $previous + * @param \Throwable $previous * @return string */ - protected function formatMessage($sql, $bindings, $previous) + protected function formatMessage($connectionName, $sql, $bindings, Throwable $previous) { - return $previous->getMessage().' (SQL: '.Str::replaceArray('?', $bindings, $sql).')'; + $details = $this->formatConnectionDetails(); + + return $previous->getMessage().' (Connection: '.$connectionName.$details.', SQL: '.Str::replaceArray('?', $bindings, $sql).')'; + } + + /** + * Format the connection details for the error message. + * + * @return string + */ + protected function formatConnectionDetails() + { + if (empty($this->connectionDetails)) { + return ''; + } + + $driver = $this->connectionDetails['driver'] ?? ''; + + $segments = []; + + if ($driver !== 'sqlite') { + if (! empty($this->connectionDetails['unix_socket'])) { + $segments[] = 'Socket: '.$this->connectionDetails['unix_socket']; + } else { + $host = $this->connectionDetails['host'] ?? ''; + + $segments[] = 'Host: '.(is_array($host) ? implode(', ', $host) : $host); + $segments[] = 'Port: '.($this->connectionDetails['port'] ?? ''); + } + } + + $segments[] = 'Database: '.($this->connectionDetails['database'] ?? ''); + + return ', '.implode(', ', $segments); + } + + /** + * Get the connection name for the query. + * + * @return string + */ + public function getConnectionName() + { + return $this->connectionName; } /** @@ -66,6 +138,16 @@ public function getSql() return $this->sql; } + /** + * Get the raw SQL representation of the query with embedded bindings. + */ + public function getRawSql(): string + { + return DB::connection($this->getConnectionName()) + ->getQueryGrammar() + ->substituteBindingsIntoRawSql($this->getSql(), $this->getBindings()); + } + /** * Get the bindings for the query. * @@ -75,4 +157,14 @@ public function getBindings() { return $this->bindings; } + + /** + * Get information about the connection such as host, port, database, etc. + * + * @return array + */ + public function getConnectionDetails() + { + return $this->connectionDetails; + } } diff --git a/README.md b/README.md index 7d59ab7d8c..9019936025 100755 --- a/README.md +++ b/README.md @@ -12,14 +12,14 @@ use Illuminate\Database\Capsule\Manager as Capsule; $capsule = new Capsule; $capsule->addConnection([ - 'driver' => 'mysql', - 'host' => 'localhost', - 'database' => 'database', - 'username' => 'root', - 'password' => 'password', - 'charset' => 'utf8', + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'database', + 'username' => 'root', + 'password' => 'password', + 'charset' => 'utf8', 'collation' => 'utf8_unicode_ci', - 'prefix' => '', + 'prefix' => '', ]); // Set the event dispatcher used by Eloquent models... (optional) diff --git a/RecordNotFoundException.php b/RecordNotFoundException.php new file mode 100644 index 0000000000..3a717feeed --- /dev/null +++ b/RecordNotFoundException.php @@ -0,0 +1,10 @@ +=')) { + $mode = $this->getConfig('transaction_mode') ?? 'DEFERRED'; - $enableForeignKeyConstraints = $this->getForeignKeyConstraintsConfigurationValue(); + $this->getPdo()->exec("BEGIN {$mode} TRANSACTION"); - if ($enableForeignKeyConstraints === null) { return; } - $enableForeignKeyConstraints - ? $this->getSchemaBuilder()->enableForeignKeyConstraints() - : $this->getSchemaBuilder()->disableForeignKeyConstraints(); + $this->getPdo()->beginTransaction(); + } + + /** + * Escape a binary value for safe SQL embedding. + * + * @param string $value + * @return string + */ + protected function escapeBinary($value) + { + $hex = bin2hex($value); + + return "x'{$hex}'"; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return (bool) preg_match('#(column(s)? .* (is|are) not unique|UNIQUE constraint failed: .*)#i', $exception->getMessage()); } /** @@ -41,7 +69,7 @@ public function __construct($pdo, $database = '', $tablePrefix = '', array $conf */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -65,36 +93,29 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** - * Get the default post processor instance. + * Get the schema state for the connection. * - * @return \Illuminate\Database\Query\Processors\SQLiteProcessor - */ - protected function getDefaultPostProcessor() - { - return new SQLiteProcessor; - } - - /** - * Get the Doctrine DBAL driver. + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory * - * @return \Doctrine\DBAL\Driver\PDOSqlite\Driver + * @throws \RuntimeException */ - protected function getDoctrineDriver() + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { - return new DoctrineDriver; + return new SqliteSchemaState($this, $files, $processFactory); } /** - * Get the database connection foreign key constraints configuration option. + * Get the default post processor instance. * - * @return bool|null + * @return \Illuminate\Database\Query\Processors\SQLiteProcessor */ - protected function getForeignKeyConstraintsConfigurationValue() + protected function getDefaultPostProcessor() { - return $this->getConfig('foreign_key_constraints'); + return new SQLiteProcessor; } } diff --git a/SQLiteDatabaseDoesNotExistException.php b/SQLiteDatabaseDoesNotExistException.php new file mode 100644 index 0000000000..8ea87cf39e --- /dev/null +++ b/SQLiteDatabaseDoesNotExistException.php @@ -0,0 +1,27 @@ +path = $path; + } +} diff --git a/Schema/Blueprint.php b/Schema/Blueprint.php index d2442fbbd3..8c5c1cd7dc 100755 --- a/Schema/Blueprint.php +++ b/Schema/Blueprint.php @@ -3,30 +3,38 @@ namespace Illuminate\Database\Schema; use Closure; -use BadMethodCallException; -use Illuminate\Support\Fluent; use Illuminate\Database\Connection; -use Illuminate\Support\Traits\Macroable; -use Illuminate\Database\SQLiteConnection; +use Illuminate\Database\Eloquent\Concerns\HasUlids; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Grammars\Grammar; +use Illuminate\Database\Schema\Grammars\MySqlGrammar; +use Illuminate\Database\Schema\Grammars\SQLiteGrammar; +use Illuminate\Support\Collection; +use Illuminate\Support\Fluent; +use Illuminate\Support\Traits\Macroable; + +use function Illuminate\Support\enum_value; class Blueprint { use Macroable; /** - * The table the blueprint describes. - * - * @var string + * The database connection instance. */ - protected $table; + protected Connection $connection; /** - * The prefix of the table. + * The schema grammar instance. + */ + protected Grammar $grammar; + + /** + * The table the blueprint describes. * * @var string */ - protected $prefix; + protected $table; /** * The columns that should be added to the table. @@ -51,11 +59,15 @@ class Blueprint /** * The default character set that should be used for the table. + * + * @var string */ public $charset; /** * The collation that should be used for the table. + * + * @var string */ public $collation; @@ -66,18 +78,32 @@ class Blueprint */ public $temporary = false; + /** + * The column to add new columns after. + * + * @var string + */ + public $after; + + /** + * The blueprint state instance. + * + * @var \Illuminate\Database\Schema\BlueprintState|null + */ + protected $state; + /** * Create a new schema blueprint. * + * @param \Illuminate\Database\Connection $connection * @param string $table - * @param \Closure|null $callback - * @param string $prefix - * @return void + * @param (\Closure(self): void)|null $callback */ - public function __construct($table, Closure $callback = null, $prefix = '') + public function __construct(Connection $connection, $table, ?Closure $callback = null) { + $this->connection = $connection; + $this->grammar = $connection->getSchemaGrammar(); $this->table = $table; - $this->prefix = $prefix; if (! is_null($callback)) { $callback($this); @@ -87,40 +113,44 @@ public function __construct($table, Closure $callback = null, $prefix = '') /** * Execute the blueprint against the database. * - * @param \Illuminate\Database\Connection $connection - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar * @return void */ - public function build(Connection $connection, Grammar $grammar) + public function build() { - foreach ($this->toSql($connection, $grammar) as $statement) { - $connection->statement($statement); + foreach ($this->toSql() as $statement) { + $this->connection->statement($statement); } } /** * Get the raw SQL statements for the blueprint. * - * @param \Illuminate\Database\Connection $connection - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar * @return array */ - public function toSql(Connection $connection, Grammar $grammar) + public function toSql() { - $this->addImpliedCommands($grammar); + $this->addImpliedCommands(); $statements = []; // Each type of command has a corresponding compiler function on the schema // grammar which is used to build the necessary SQL statements to build // the blueprint element, so we'll just call that compilers function. - $this->ensureCommandsAreValid($connection); + $this->ensureCommandsAreValid(); foreach ($this->commands as $command) { + if ($command->shouldBeSkipped) { + continue; + } + $method = 'compile'.ucfirst($command->name); - if (method_exists($grammar, $method)) { - if (! is_null($sql = $grammar->$method($this, $command, $connection))) { + if (method_exists($this->grammar, $method) || $this->grammar::hasMacro($method)) { + if ($this->hasState()) { + $this->state->update($command); + } + + if (! is_null($sql = $this->grammar->$method($this, $command))) { $statements = array_merge($statements, (array) $sql); } } @@ -132,60 +162,49 @@ public function toSql(Connection $connection, Grammar $grammar) /** * Ensure the commands on the blueprint are valid for the connection type. * - * @param \Illuminate\Database\Connection $connection * @return void * * @throws \BadMethodCallException */ - protected function ensureCommandsAreValid(Connection $connection) + protected function ensureCommandsAreValid() { - if ($connection instanceof SQLiteConnection) { - if ($this->commandsNamed(['dropColumn', 'renameColumn'])->count() > 1) { - throw new BadMethodCallException( - "SQLite doesn't support multiple calls to dropColumn / renameColumn in a single modification." - ); - } - - if ($this->commandsNamed(['dropForeign'])->count() > 0) { - throw new BadMethodCallException( - "SQLite doesn't support dropping foreign keys (you would need to re-create the table)." - ); - } - } + // } /** * Get all of the commands matching the given names. * + * @deprecated Will be removed in a future Laravel version. + * * @param array $names * @return \Illuminate\Support\Collection */ protected function commandsNamed(array $names) { - return collect($this->commands)->filter(function ($command) use ($names) { - return in_array($command->name, $names); - }); + return (new Collection($this->commands)) + ->filter(fn ($command) => in_array($command->name, $names)); } /** * Add the commands that are implied by the blueprint's state. * - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar * @return void */ - protected function addImpliedCommands(Grammar $grammar) + protected function addImpliedCommands() { - if (count($this->getAddedColumns()) > 0 && ! $this->creating()) { - array_unshift($this->commands, $this->createCommand('add')); - } - - if (count($this->getChangedColumns()) > 0 && ! $this->creating()) { - array_unshift($this->commands, $this->createCommand('change')); - } - $this->addFluentIndexes(); + $this->addFluentCommands(); + + if (! $this->creating()) { + $this->commands = array_map( + fn ($command) => $command instanceof ColumnDefinition + ? $this->createCommand($command->change ? 'change' : 'add', ['column' => $command]) + : $command, + $this->commands + ); - $this->addFluentCommands($grammar); + $this->addAlterCommands(); + } } /** @@ -196,12 +215,34 @@ protected function addImpliedCommands(Grammar $grammar) protected function addFluentIndexes() { foreach ($this->columns as $column) { - foreach (['primary', 'unique', 'index', 'spatialIndex'] as $index) { + foreach (['primary', 'unique', 'index', 'fulltext', 'fullText', 'spatialIndex', 'vectorIndex'] as $index) { + // If the column is supposed to be changed to an auto increment column and + // the specified index is primary, there is no need to add a command on + // MySQL, as it will be handled during the column definition instead. + if ($index === 'primary' && $column->autoIncrement && $column->change && $this->grammar instanceof MySqlGrammar) { + continue 2; + } + // If the index has been specified on the given column, but is simply equal // to "true" (boolean), no name has been specified for this index so the // index method can be called without a name and it will generate one. if ($column->{$index} === true) { - $this->{$index}($column->name); + $indexMethod = $index === 'index' && $column->type === 'vector' + ? 'vectorIndex' + : $index; + + $this->{$indexMethod}($column->name); + $column->{$index} = null; + + continue 2; + } + + // If the index has been specified on the given column, but it equals false + // and the column is supposed to be changed, we will call the drop index + // method with an array of column to drop it by its conventional name. + elseif ($column->{$index} === false && $column->change) { + $this->{'drop'.ucfirst($index)}([$column->name]); + $column->{$index} = null; continue 2; } @@ -210,7 +251,12 @@ protected function addFluentIndexes() // value, we'll go ahead and call the index method and pass the name for // the index since the developer specified the explicit name for this. elseif (isset($column->{$index})) { - $this->{$index}($column->name, $column->{$index}); + $indexMethod = $index === 'index' && $column->type === 'vector' + ? 'vectorIndex' + : $index; + + $this->{$indexMethod}($column->name, $column->{$index}); + $column->{$index} = null; continue 2; } @@ -221,26 +267,55 @@ protected function addFluentIndexes() /** * Add the fluent commands specified on any columns. * - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar * @return void */ - public function addFluentCommands(Grammar $grammar) + public function addFluentCommands() { foreach ($this->columns as $column) { - foreach ($grammar->getFluentCommands() as $commandName) { - $attributeName = lcfirst($commandName); + foreach ($this->grammar->getFluentCommands() as $commandName) { + $this->addCommand($commandName, compact('column')); + } + } + } - if (! isset($column->{$attributeName})) { - continue; - } + /** + * Add the alter commands if whenever needed. + * + * @return void + */ + public function addAlterCommands() + { + if (! $this->grammar instanceof SQLiteGrammar) { + return; + } + + $alterCommands = $this->grammar->getAlterCommands(); - $value = $column->{$attributeName}; + [$commands, $lastCommandWasAlter, $hasAlterCommand] = [ + [], false, false, + ]; - $this->addCommand( - $commandName, compact('value', 'column') - ); + foreach ($this->commands as $command) { + if (in_array($command->name, $alterCommands)) { + $hasAlterCommand = true; + $lastCommandWasAlter = true; + } elseif ($lastCommandWasAlter) { + $commands[] = $this->createCommand('alter'); + $lastCommandWasAlter = false; } + + $commands[] = $command; + } + + if ($lastCommandWasAlter) { + $commands[] = $this->createCommand('alter'); } + + if ($hasAlterCommand) { + $this->state = new BlueprintState($this, $this->connection); + } + + $this->commands = $commands; } /** @@ -248,11 +323,10 @@ public function addFluentCommands(Grammar $grammar) * * @return bool */ - protected function creating() + public function creating() { - return collect($this->commands)->contains(function ($command) { - return $command->name === 'create'; - }); + return (new Collection($this->commands)) + ->contains(fn ($command) => ! $command instanceof ColumnDefinition && $command->name === 'create'); } /** @@ -265,6 +339,49 @@ public function create() return $this->addCommand('create'); } + /** + * Specify the storage engine that should be used for the table. + * + * @param string $engine + * @return void + */ + public function engine($engine) + { + $this->engine = $engine; + } + + /** + * Specify that the InnoDB storage engine should be used for the table (MySQL only). + * + * @return void + */ + public function innoDb() + { + $this->engine('InnoDB'); + } + + /** + * Specify the character set that should be used for the table. + * + * @param string $charset + * @return void + */ + public function charset($charset) + { + $this->charset = $charset; + } + + /** + * Specify the collation that should be used for the table. + * + * @param string $collation + * @return void + */ + public function collation($collation) + { + $this->collation = $collation; + } + /** * Indicate that the table needs to be temporary. * @@ -298,7 +415,7 @@ public function dropIfExists() /** * Indicate that the given columns should be dropped. * - * @param array|mixed $columns + * @param mixed $columns * @return \Illuminate\Support\Fluent */ public function dropColumn($columns) @@ -323,7 +440,7 @@ public function renameColumn($from, $to) /** * Indicate that the given primary key should be dropped. * - * @param string|array $index + * @param string|array|null $index * @return \Illuminate\Support\Fluent */ public function dropPrimary($index = null) @@ -353,6 +470,17 @@ public function dropIndex($index) return $this->dropIndexCommand('dropIndex', 'index', $index); } + /** + * Indicate that the given fulltext index should be dropped. + * + * @param string|array $index + * @return \Illuminate\Support\Fluent + */ + public function dropFullText($index) + { + return $this->dropIndexCommand('dropFullText', 'fulltext', $index); + } + /** * Indicate that the given spatial index should be dropped. * @@ -375,6 +503,51 @@ public function dropForeign($index) return $this->dropIndexCommand('dropForeign', 'foreign', $index); } + /** + * Indicate that the given column and foreign key should be dropped. + * + * @param string $column + * @return \Illuminate\Support\Fluent + */ + public function dropConstrainedForeignId($column) + { + $this->dropForeign([$column]); + + return $this->dropColumn($column); + } + + /** + * Indicate that the given foreign key should be dropped. + * + * @param \Illuminate\Database\Eloquent\Model|string $model + * @param string|null $column + * @return \Illuminate\Support\Fluent + */ + public function dropForeignIdFor($model, $column = null) + { + if (is_string($model)) { + $model = new $model; + } + + return $this->dropColumn($column ?: $model->getForeignKey()); + } + + /** + * Indicate that the given foreign key should be dropped. + * + * @param \Illuminate\Database\Eloquent\Model|string $model + * @param string|null $column + * @return \Illuminate\Support\Fluent + */ + public function dropConstrainedForeignIdFor($model, $column = null) + { + if (is_string($model)) { + $model = new $model; + } + + return $this->dropConstrainedForeignId($column ?: $model->getForeignKey()); + } + /** * Indicate that the given indexes should be renamed. * @@ -468,9 +641,9 @@ public function rename($to) * Specify the primary key(s) for the table. * * @param string|array $columns - * @param string $name + * @param string|null $name * @param string|null $algorithm - * @return \Illuminate\Support\Fluent + * @return \Illuminate\Database\Schema\IndexDefinition */ public function primary($columns, $name = null, $algorithm = null) { @@ -481,9 +654,9 @@ public function primary($columns, $name = null, $algorithm = null) * Specify a unique index for the table. * * @param string|array $columns - * @param string $name + * @param string|null $name * @param string|null $algorithm - * @return \Illuminate\Support\Fluent + * @return \Illuminate\Database\Schema\IndexDefinition */ public function unique($columns, $name = null, $algorithm = null) { @@ -494,41 +667,96 @@ public function unique($columns, $name = null, $algorithm = null) * Specify an index for the table. * * @param string|array $columns - * @param string $name + * @param string|null $name * @param string|null $algorithm - * @return \Illuminate\Support\Fluent + * @return \Illuminate\Database\Schema\IndexDefinition */ public function index($columns, $name = null, $algorithm = null) { return $this->indexCommand('index', $columns, $name, $algorithm); } + /** + * Specify a fulltext index for the table. + * + * @param string|array $columns + * @param string|null $name + * @param string|null $algorithm + * @return \Illuminate\Database\Schema\IndexDefinition + */ + public function fullText($columns, $name = null, $algorithm = null) + { + return $this->indexCommand('fulltext', $columns, $name, $algorithm); + } + /** * Specify a spatial index for the table. * * @param string|array $columns + * @param string|null $name + * @param string|null $operatorClass + * @return \Illuminate\Database\Schema\IndexDefinition + */ + public function spatialIndex($columns, $name = null, $operatorClass = null) + { + return $this->indexCommand('spatialIndex', $columns, $name, null, $operatorClass); + } + + /** + * Specify a vector index for the table. + * + * @param string $column + * @param string|null $name + * @return \Illuminate\Database\Schema\IndexDefinition + */ + public function vectorIndex($column, $name = null) + { + return $this->indexCommand('vectorIndex', $column, $name, 'hnsw', 'vector_cosine_ops'); + } + + /** + * Specify a raw index for the table. + * + * @param string $expression * @param string $name - * @return \Illuminate\Support\Fluent + * @return \Illuminate\Database\Schema\IndexDefinition */ - public function spatialIndex($columns, $name = null) + public function rawIndex($expression, $name) { - return $this->indexCommand('spatialIndex', $columns, $name); + return $this->index([new Expression($expression)], $name); } /** * Specify a foreign key for the table. * * @param string|array $columns - * @param string $name - * @return \Illuminate\Support\Fluent + * @param string|null $name + * @return \Illuminate\Database\Schema\ForeignKeyDefinition */ public function foreign($columns, $name = null) { - return $this->indexCommand('foreign', $columns, $name); + $command = new ForeignKeyDefinition( + $this->indexCommand('foreign', $columns, $name)->getAttributes() + ); + + $this->commands[count($this->commands) - 1] = $command; + + return $command; } /** - * Create a new auto-incrementing integer (4-byte) column on the table. + * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + * + * @param string $column + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function id($column = 'id') + { + return $this->bigIncrements($column); + } + + /** + * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -539,7 +767,7 @@ public function increments($column) } /** - * Create a new auto-incrementing integer (4-byte) column on the table. + * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -550,7 +778,7 @@ public function integerIncrements($column) } /** - * Create a new auto-incrementing tiny integer (1-byte) column on the table. + * Create a new auto-incrementing tiny integer column on the table (1-byte, 0 to 255). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -561,7 +789,7 @@ public function tinyIncrements($column) } /** - * Create a new auto-incrementing small integer (2-byte) column on the table. + * Create a new auto-incrementing small integer column on the table (2-byte, 0 to 65,535). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -572,7 +800,7 @@ public function smallIncrements($column) } /** - * Create a new auto-incrementing medium integer (3-byte) column on the table. + * Create a new auto-incrementing medium integer column on the table (3-byte, 0 to 16,777,215). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -583,7 +811,7 @@ public function mediumIncrements($column) } /** - * Create a new auto-incrementing big integer (8-byte) column on the table. + * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -597,12 +825,12 @@ public function bigIncrements($column) * Create a new char column on the table. * * @param string $column - * @param int $length + * @param int|null $length * @return \Illuminate\Database\Schema\ColumnDefinition */ public function char($column, $length = null) { - $length = $length ?: Builder::$defaultStringLength; + $length = ! is_null($length) ? $length : Builder::$defaultStringLength; return $this->addColumn('char', $column, compact('length')); } @@ -611,7 +839,7 @@ public function char($column, $length = null) * Create a new string column on the table. * * @param string $column - * @param int $length + * @param int|null $length * @return \Illuminate\Database\Schema\ColumnDefinition */ public function string($column, $length = null) @@ -622,7 +850,18 @@ public function string($column, $length = null) } /** - * Create a new text column on the table. + * Create a new tiny text column on the table (up to 255 characters). + * + * @param string $column + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function tinyText($column) + { + return $this->addColumn('tinyText', $column); + } + + /** + * Create a new text column on the table (up to 65,535 characters / ~64 KB). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -633,7 +872,7 @@ public function text($column) } /** - * Create a new medium text column on the table. + * Create a new medium text column on the table (up to 16,777,215 characters / ~16 MB). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -644,7 +883,7 @@ public function mediumText($column) } /** - * Create a new long text column on the table. + * Create a new long text column on the table (up to 4,294,967,295 characters / ~4 GB). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -656,6 +895,7 @@ public function longText($column) /** * Create a new integer (4-byte) column on the table. + * Range: -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned). * * @param string $column * @param bool $autoIncrement @@ -669,6 +909,7 @@ public function integer($column, $autoIncrement = false, $unsigned = false) /** * Create a new tiny integer (1-byte) column on the table. + * Range: -128 to 127 (signed) or 0 to 255 (unsigned). * * @param string $column * @param bool $autoIncrement @@ -682,6 +923,7 @@ public function tinyInteger($column, $autoIncrement = false, $unsigned = false) /** * Create a new small integer (2-byte) column on the table. + * Range: -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned). * * @param string $column * @param bool $autoIncrement @@ -695,6 +937,7 @@ public function smallInteger($column, $autoIncrement = false, $unsigned = false) /** * Create a new medium integer (3-byte) column on the table. + * Range: -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned). * * @param string $column * @param bool $autoIncrement @@ -708,6 +951,7 @@ public function mediumInteger($column, $autoIncrement = false, $unsigned = false /** * Create a new big integer (8-byte) column on the table. + * Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned). * * @param string $column * @param bool $autoIncrement @@ -720,7 +964,7 @@ public function bigInteger($column, $autoIncrement = false, $unsigned = false) } /** - * Create a new unsigned integer (4-byte) column on the table. + * Create a new unsigned integer column on the table (4-byte, 0 to 4,294,967,295). * * @param string $column * @param bool $autoIncrement @@ -732,7 +976,7 @@ public function unsignedInteger($column, $autoIncrement = false) } /** - * Create a new unsigned tiny integer (1-byte) column on the table. + * Create a new unsigned tiny integer column on the table (1-byte, 0 to 255). * * @param string $column * @param bool $autoIncrement @@ -744,7 +988,7 @@ public function unsignedTinyInteger($column, $autoIncrement = false) } /** - * Create a new unsigned small integer (2-byte) column on the table. + * Create a new unsigned small integer column on the table (2-byte, 0 to 65,535). * * @param string $column * @param bool $autoIncrement @@ -756,7 +1000,7 @@ public function unsignedSmallInteger($column, $autoIncrement = false) } /** - * Create a new unsigned medium integer (3-byte) column on the table. + * Create a new unsigned medium integer column on the table (3-byte, 0 to 16,777,215). * * @param string $column * @param bool $autoIncrement @@ -768,7 +1012,7 @@ public function unsignedMediumInteger($column, $autoIncrement = false) } /** - * Create a new unsigned big integer (8-byte) column on the table. + * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). * * @param string $column * @param bool $autoIncrement @@ -780,57 +1024,89 @@ public function unsignedBigInteger($column, $autoIncrement = false) } /** - * Create a new float column on the table. + * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). * * @param string $column - * @param int $total - * @param int $places - * @return \Illuminate\Database\Schema\ColumnDefinition + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition */ - public function float($column, $total = 8, $places = 2) + public function foreignId($column) { - return $this->addColumn('float', $column, compact('total', 'places')); + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'bigInteger', + 'name' => $column, + 'autoIncrement' => false, + 'unsigned' => true, + ])); } /** - * Create a new double column on the table. + * Create a foreign ID column for the given model. + * + * @param \Illuminate\Database\Eloquent\Model|string $model + * @param string|null $column + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignIdFor($model, $column = null) + { + if (is_string($model)) { + $model = new $model; + } + + $column = $column ?: $model->getForeignKey(); + + if ($model->getKeyType() === 'int') { + return $this->foreignId($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + $modelTraits = class_uses_recursive($model); + + if (in_array(HasUlids::class, $modelTraits, true)) { + return $this->foreignUlid($column, 26) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + return $this->foreignUuid($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } + + /** + * Create a new float column on the table. * * @param string $column - * @param int|null $total - * @param int|null $places + * @param int $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function double($column, $total = null, $places = null) + public function float($column, $precision = 53) { - return $this->addColumn('double', $column, compact('total', 'places')); + return $this->addColumn('float', $column, compact('precision')); } /** - * Create a new decimal column on the table. + * Create a new double column on the table. * * @param string $column - * @param int $total - * @param int $places * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function decimal($column, $total = 8, $places = 2) + public function double($column) { - return $this->addColumn('decimal', $column, compact('total', 'places')); + return $this->addColumn('double', $column); } /** - * Create a new unsigned decimal column on the table. + * Create a new decimal column on the table. * * @param string $column * @param int $total * @param int $places * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function unsignedDecimal($column, $total = 8, $places = 2) + public function decimal($column, $total = 8, $places = 2) { - return $this->addColumn('decimal', $column, [ - 'total' => $total, 'places' => $places, 'unsigned' => true, - ]); + return $this->addColumn('decimal', $column, compact('total', 'places')); } /** @@ -853,9 +1129,23 @@ public function boolean($column) */ public function enum($column, array $allowed) { + $allowed = array_map(fn ($value) => enum_value($value), $allowed); + return $this->addColumn('enum', $column, compact('allowed')); } + /** + * Create a new set column on the table. + * + * @param string $column + * @param array $allowed + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function set($column, array $allowed) + { + return $this->addColumn('set', $column, compact('allowed')); + } + /** * Create a new json column on the table. * @@ -893,11 +1183,13 @@ public function date($column) * Create a new date-time column on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function dateTime($column, $precision = 0) + public function dateTime($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('dateTime', $column, compact('precision')); } @@ -905,11 +1197,13 @@ public function dateTime($column, $precision = 0) * Create a new date-time column (with time zone) on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function dateTimeTz($column, $precision = 0) + public function dateTimeTz($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('dateTimeTz', $column, compact('precision')); } @@ -917,11 +1211,13 @@ public function dateTimeTz($column, $precision = 0) * Create a new time column on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function time($column, $precision = 0) + public function time($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('time', $column, compact('precision')); } @@ -929,11 +1225,13 @@ public function time($column, $precision = 0) * Create a new time column (with time zone) on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function timeTz($column, $precision = 0) + public function timeTz($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('timeTz', $column, compact('precision')); } @@ -941,11 +1239,13 @@ public function timeTz($column, $precision = 0) * Create a new timestamp column on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function timestamp($column, $precision = 0) + public function timestamp($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('timestamp', $column, compact('precision')); } @@ -953,25 +1253,28 @@ public function timestamp($column, $precision = 0) * Create a new timestamp (with time zone) column on the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function timestampTz($column, $precision = 0) + public function timestampTz($column, $precision = null) { + $precision ??= $this->defaultTimePrecision(); + return $this->addColumn('timestampTz', $column, compact('precision')); } /** * Add nullable creation and update timestamps to the table. * - * @param int $precision - * @return void + * @param int|null $precision + * @return \Illuminate\Support\Collection */ - public function timestamps($precision = 0) + public function timestamps($precision = null) { - $this->timestamp('created_at', $precision)->nullable(); - - $this->timestamp('updated_at', $precision)->nullable(); + return new Collection([ + $this->timestamp('created_at', $precision)->nullable(), + $this->timestamp('updated_at', $precision)->nullable(), + ]); } /** @@ -979,35 +1282,63 @@ public function timestamps($precision = 0) * * Alias for self::timestamps(). * - * @param int $precision - * @return void + * @param int|null $precision + * @return \Illuminate\Support\Collection */ - public function nullableTimestamps($precision = 0) + public function nullableTimestamps($precision = null) { - $this->timestamps($precision); + return $this->timestamps($precision); } /** - * Add creation and update timestampTz columns to the table. + * Add nullable creation and update timestampTz columns to the table. * - * @param int $precision - * @return void + * @param int|null $precision + * @return \Illuminate\Support\Collection */ - public function timestampsTz($precision = 0) + public function timestampsTz($precision = null) { - $this->timestampTz('created_at', $precision)->nullable(); + return new Collection([ + $this->timestampTz('created_at', $precision)->nullable(), + $this->timestampTz('updated_at', $precision)->nullable(), + ]); + } - $this->timestampTz('updated_at', $precision)->nullable(); + /** + * Add nullable creation and update timestampTz columns to the table. + * + * Alias for self::timestampsTz(). + * + * @param int|null $precision + * @return \Illuminate\Support\Collection + */ + public function nullableTimestampsTz($precision = null) + { + return $this->timestampsTz($precision); + } + + /** + * Add creation and update datetime columns to the table. + * + * @param int|null $precision + * @return \Illuminate\Support\Collection + */ + public function datetimes($precision = null) + { + return new Collection([ + $this->datetime('created_at', $precision)->nullable(), + $this->datetime('updated_at', $precision)->nullable(), + ]); } /** * Add a "deleted at" timestamp for the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function softDeletes($column = 'deleted_at', $precision = 0) + public function softDeletes($column = 'deleted_at', $precision = null) { return $this->timestamp($column, $precision)->nullable(); } @@ -1016,14 +1347,26 @@ public function softDeletes($column = 'deleted_at', $precision = 0) * Add a "deleted at" timestampTz for the table. * * @param string $column - * @param int $precision + * @param int|null $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function softDeletesTz($column = 'deleted_at', $precision = 0) + public function softDeletesTz($column = 'deleted_at', $precision = null) { return $this->timestampTz($column, $precision)->nullable(); } + /** + * Add a "deleted at" datetime column to the table. + * + * @param string $column + * @param int|null $precision + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function softDeletesDatetime($column = 'deleted_at', $precision = null) + { + return $this->dateTime($column, $precision)->nullable(); + } + /** * Create a new year column on the table. * @@ -1039,31 +1382,75 @@ public function year($column) * Create a new binary column on the table. * * @param string $column + * @param int|null $length + * @param bool $fixed * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function binary($column) + public function binary($column, $length = null, $fixed = false) { - return $this->addColumn('binary', $column); + return $this->addColumn('binary', $column, compact('length', 'fixed')); } /** - * Create a new uuid column on the table. + * Create a new UUID column on the table. * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function uuid($column) + public function uuid($column = 'uuid') { return $this->addColumn('uuid', $column); } + /** + * Create a new UUID column on the table with a foreign key constraint. + * + * @param string $column + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignUuid($column) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'uuid', + 'name' => $column, + ])); + } + + /** + * Create a new ULID column on the table. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function ulid($column = 'ulid', $length = 26) + { + return $this->char($column, $length); + } + + /** + * Create a new ULID column on the table with a foreign key constraint. + * + * @param string $column + * @param int|null $length + * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition + */ + public function foreignUlid($column, $length = 26) + { + return $this->addColumnDefinition(new ForeignIdColumnDefinition($this, [ + 'type' => 'char', + 'name' => $column, + 'length' => $length, + ])); + } + /** * Create a new IP address column on the table. * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function ipAddress($column) + public function ipAddress($column = 'ip_address') { return $this->addColumn('ipAddress', $column); } @@ -1074,7 +1461,7 @@ public function ipAddress($column) * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function macAddress($column) + public function macAddress($column = 'mac_address') { return $this->addColumn('macAddress', $column); } @@ -1083,137 +1470,214 @@ public function macAddress($column) * Create a new geometry column on the table. * * @param string $column + * @param string|null $subtype + * @param int $srid * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function geometry($column) + public function geometry($column, $subtype = null, $srid = 0) { - return $this->addColumn('geometry', $column); + return $this->addColumn('geometry', $column, compact('subtype', 'srid')); } /** - * Create a new point column on the table. + * Create a new geography column on the table. * * @param string $column - * @param int|null $srid + * @param string|null $subtype + * @param int $srid * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function point($column, $srid = null) + public function geography($column, $subtype = null, $srid = 4326) { - return $this->addColumn('point', $column, compact('srid')); + return $this->addColumn('geography', $column, compact('subtype', 'srid')); } /** - * Create a new linestring column on the table. + * Create a new generated, computed column on the table. * * @param string $column + * @param string $expression * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function lineString($column) + public function computed($column, $expression) { - return $this->addColumn('linestring', $column); + return $this->addColumn('computed', $column, compact('expression')); } /** - * Create a new polygon column on the table. + * Create a new vector column on the table. * * @param string $column + * @param int|null $dimensions * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function polygon($column) + public function vector($column, $dimensions = null) { - return $this->addColumn('polygon', $column); + $options = $dimensions ? compact('dimensions') : []; + + return $this->addColumn('vector', $column, $options); } /** - * Create a new geometrycollection column on the table. + * Add the proper columns for a polymorphic table. * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void */ - public function geometryCollection($column) + public function morphs($name, $indexName = null, $after = null) { - return $this->addColumn('geometrycollection', $column); + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->uuidMorphs($name, $indexName, $after); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->ulidMorphs($name, $indexName, $after); + } else { + $this->numericMorphs($name, $indexName, $after); + } } /** - * Create a new multipoint column on the table. + * Add nullable columns for a polymorphic table. * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void */ - public function multiPoint($column) + public function nullableMorphs($name, $indexName = null, $after = null) { - return $this->addColumn('multipoint', $column); + if (Builder::$defaultMorphKeyType === 'uuid') { + $this->nullableUuidMorphs($name, $indexName, $after); + } elseif (Builder::$defaultMorphKeyType === 'ulid') { + $this->nullableUlidMorphs($name, $indexName, $after); + } else { + $this->nullableNumericMorphs($name, $indexName, $after); + } } /** - * Create a new multilinestring column on the table. + * Add the proper columns for a polymorphic table using numeric IDs (incremental). * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void */ - public function multiLineString($column) + public function numericMorphs($name, $indexName = null, $after = null) { - return $this->addColumn('multilinestring', $column); + $this->string("{$name}_type") + ->after($after); + + $this->unsignedBigInteger("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); } /** - * Create a new multipolygon column on the table. + * Add nullable columns for a polymorphic table using numeric IDs (incremental). * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void */ - public function multiPolygon($column) + public function nullableNumericMorphs($name, $indexName = null, $after = null) { - return $this->addColumn('multipolygon', $column); + $this->string("{$name}_type") + ->nullable() + ->after($after); + + $this->unsignedBigInteger("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); } /** - * Create a new generated, computed column on the table. + * Add the proper columns for a polymorphic table using UUIDs. * - * @param string $column - * @param string $expression - * @return \Illuminate\Database\Schema\ColumnDefinition + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void */ - public function computed($column, $expression) + public function uuidMorphs($name, $indexName = null, $after = null) { - return $this->addColumn('computed', $column, compact('expression')); + $this->string("{$name}_type") + ->after($after); + + $this->uuid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); } /** - * Add the proper columns for a polymorphic table. + * Add nullable columns for a polymorphic table using UUIDs. * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function morphs($name, $indexName = null) + public function nullableUuidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type"); + $this->string("{$name}_type") + ->nullable() + ->after($after); - $this->unsignedBigInteger("{$name}_id"); + $this->uuid("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } /** - * Add nullable columns for a polymorphic table. + * Add the proper columns for a polymorphic table using ULIDs. + * + * @param string $name + * @param string|null $indexName + * @param string|null $after + * @return void + */ + public function ulidMorphs($name, $indexName = null, $after = null) + { + $this->string("{$name}_type") + ->after($after); + + $this->ulid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); + + $this->index(["{$name}_type", "{$name}_id"], $indexName); + } + + /** + * Add nullable columns for a polymorphic table using ULIDs. * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function nullableMorphs($name, $indexName = null) + public function nullableUlidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type")->nullable(); + $this->string("{$name}_type") + ->nullable() + ->after($after); - $this->unsignedBigInteger("{$name}_id")->nullable(); + $this->ulid("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } /** - * Adds the `remember_token` column to the table. + * Add the `remember_token` column to the table. * * @return \Illuminate\Database\Schema\ColumnDefinition */ @@ -1223,15 +1687,39 @@ public function rememberToken() } /** - * Add a new index command to the blueprint. + * Create a new custom column on the table. + * + * @param string $column + * @param string $definition + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + public function rawColumn($column, $definition) + { + return $this->addColumn('raw', $column, compact('definition')); + } + + /** + * Add a comment to the table. + * + * @param string $comment + * @return \Illuminate\Support\Fluent + */ + public function comment($comment) + { + return $this->addCommand('tableComment', compact('comment')); + } + + /** + * Create a new index command on the blueprint. * * @param string $type * @param string|array $columns * @param string $index * @param string|null $algorithm + * @param string|null $operatorClass * @return \Illuminate\Support\Fluent */ - protected function indexCommand($type, $columns, $index, $algorithm = null) + protected function indexCommand($type, $columns, $index, $algorithm = null, $operatorClass = null) { $columns = (array) $columns; @@ -1241,7 +1729,7 @@ protected function indexCommand($type, $columns, $index, $algorithm = null) $index = $index ?: $this->createIndexName($type, $columns); return $this->addCommand( - $type, compact('index', 'columns', 'algorithm') + $type, compact('index', 'columns', 'algorithm', 'operatorClass') ); } @@ -1276,7 +1764,15 @@ protected function dropIndexCommand($command, $type, $index) */ protected function createIndexName($type, array $columns) { - $index = strtolower($this->prefix.$this->table.'_'.implode('_', $columns).'_'.$type); + $table = $this->table; + + if ($this->connection->getConfig('prefix_indexes')) { + $table = str_contains($this->table, '.') + ? substr_replace($this->table, '.'.$this->connection->getTablePrefix(), strrpos($this->table, '.'), 1) + : $this->connection->getTablePrefix().$this->table; + } + + $index = strtolower($table.'_'.implode('_', $columns).'_'.$type); return str_replace(['-', '.'], '_', $index); } @@ -1291,11 +1787,48 @@ protected function createIndexName($type, array $columns) */ public function addColumn($type, $name, array $parameters = []) { - $this->columns[] = $column = new ColumnDefinition( + return $this->addColumnDefinition(new ColumnDefinition( array_merge(compact('type', 'name'), $parameters) - ); + )); + } + + /** + * Add a new column definition to the blueprint. + * + * @param \Illuminate\Database\Schema\ColumnDefinition $definition + * @return \Illuminate\Database\Schema\ColumnDefinition + */ + protected function addColumnDefinition($definition) + { + $this->columns[] = $definition; - return $column; + if (! $this->creating()) { + $this->commands[] = $definition; + } + + if ($this->after) { + $definition->after($this->after); + + $this->after = $definition->name; + } + + return $definition; + } + + /** + * Add the columns from the callback after the given column. + * + * @param string $column + * @param (\Closure(self): void) $callback + * @return void + */ + public function after($column, Closure $callback) + { + $this->after = $column; + + $callback($this); + + $this->after = null; } /** @@ -1310,6 +1843,10 @@ public function removeColumn($name) return $c['name'] != $name; })); + $this->commands = array_values(array_filter($this->commands, function ($c) use ($name) { + return ! $c instanceof ColumnDefinition || $c['name'] != $name; + })); + return $this; } @@ -1349,6 +1886,18 @@ public function getTable() return $this->table; } + /** + * Get the table prefix. + * + * @deprecated Use DB::getTablePrefix() + * + * @return string + */ + public function getPrefix() + { + return $this->connection->getTablePrefix(); + } + /** * Get the columns on the blueprint. * @@ -1369,6 +1918,26 @@ public function getCommands() return $this->commands; } + /** + * Determine if the blueprint has state. + * + * @return bool + */ + private function hasState(): bool + { + return ! is_null($this->state); + } + + /** + * Get the state of the blueprint. + * + * @return \Illuminate\Database\Schema\BlueprintState + */ + public function getState() + { + return $this->state; + } + /** * Get the columns on the blueprint that should be added. * @@ -1384,6 +1953,8 @@ public function getAddedColumns() /** * Get the columns on the blueprint that should be changed. * + * @deprecated Will be removed in a future Laravel version. + * * @return \Illuminate\Database\Schema\ColumnDefinition[] */ public function getChangedColumns() @@ -1392,4 +1963,12 @@ public function getChangedColumns() return (bool) $column->change; }); } + + /** + * Get the default time precision. + */ + protected function defaultTimePrecision(): ?int + { + return $this->connection->getSchemaBuilder()::$defaultTimePrecision; + } } diff --git a/Schema/BlueprintState.php b/Schema/BlueprintState.php new file mode 100644 index 0000000000..a4ad1149d4 --- /dev/null +++ b/Schema/BlueprintState.php @@ -0,0 +1,247 @@ +blueprint = $blueprint; + $this->connection = $connection; + + $schema = $connection->getSchemaBuilder(); + $table = $blueprint->getTable(); + + $this->columns = (new Collection($schema->getColumns($table)))->map(fn ($column) => new ColumnDefinition([ + 'name' => $column['name'], + 'type' => $column['type_name'], + 'full_type_definition' => $column['type'], + 'nullable' => $column['nullable'], + 'default' => is_null($column['default']) ? null : new Expression(Str::wrap($column['default'], '(', ')')), + 'autoIncrement' => $column['auto_increment'], + 'collation' => $column['collation'], + 'comment' => $column['comment'], + 'virtualAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'virtual' + ? $column['generation']['expression'] + : null, + 'storedAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'stored' + ? $column['generation']['expression'] + : null, + ]))->all(); + + [$primary, $indexes] = (new Collection($schema->getIndexes($table)))->map(fn ($index) => new IndexDefinition([ + 'name' => match (true) { + $index['primary'] => 'primary', + $index['unique'] => 'unique', + default => 'index', + }, + 'index' => $index['name'], + 'columns' => $index['columns'], + ]))->partition(fn ($index) => $index->name === 'primary'); + + $this->indexes = $indexes->all(); + $this->primaryKey = $primary->first(); + + $this->foreignKeys = (new Collection($schema->getForeignKeys($table)))->map(fn ($foreignKey) => new ForeignKeyDefinition([ + 'columns' => $foreignKey['columns'], + 'on' => new Expression($foreignKey['foreign_table']), + 'references' => $foreignKey['foreign_columns'], + 'onUpdate' => $foreignKey['on_update'], + 'onDelete' => $foreignKey['on_delete'], + ]))->all(); + } + + /** + * Get the primary key. + * + * @return \Illuminate\Database\Schema\IndexDefinition|null + */ + public function getPrimaryKey() + { + return $this->primaryKey; + } + + /** + * Get the columns. + * + * @return \Illuminate\Database\Schema\ColumnDefinition[] + */ + public function getColumns() + { + return $this->columns; + } + + /** + * Get the indexes. + * + * @return \Illuminate\Database\Schema\IndexDefinition[] + */ + public function getIndexes() + { + return $this->indexes; + } + + /** + * Get the foreign keys. + * + * @return \Illuminate\Database\Schema\ForeignKeyDefinition[] + */ + public function getForeignKeys() + { + return $this->foreignKeys; + } + + /* + * Update the blueprint's state. + * + * @param \Illuminate\Support\Fluent $command + * @return void + */ + public function update(Fluent $command) + { + switch ($command->name) { + case 'alter': + // Already handled... + break; + + case 'add': + $this->columns[] = $command->column; + break; + + case 'change': + foreach ($this->columns as &$column) { + if ($column->name === $command->column->name) { + $column = $command->column; + break; + } + } + + break; + + case 'renameColumn': + foreach ($this->columns as $column) { + if ($column->name === $command->from) { + $column->name = $command->to; + break; + } + } + + if ($this->primaryKey) { + $this->primaryKey->columns = str_replace($command->from, $command->to, $this->primaryKey->columns); + } + + foreach ($this->indexes as $index) { + $index->columns = str_replace($command->from, $command->to, $index->columns); + } + + foreach ($this->foreignKeys as $foreignKey) { + $foreignKey->columns = str_replace($command->from, $command->to, $foreignKey->columns); + } + + break; + + case 'dropColumn': + $this->columns = array_values( + array_filter($this->columns, fn ($column) => ! in_array($column->name, $command->columns)) + ); + + break; + + case 'primary': + $this->primaryKey = $command; + break; + + case 'unique': + case 'index': + $this->indexes[] = $command; + break; + + case 'renameIndex': + foreach ($this->indexes as $index) { + if ($index->index === $command->from) { + $index->index = $command->to; + break; + } + } + + break; + + case 'foreign': + $this->foreignKeys[] = $command; + break; + + case 'dropPrimary': + $this->primaryKey = null; + break; + + case 'dropIndex': + case 'dropUnique': + $this->indexes = array_values( + array_filter($this->indexes, fn ($index) => $index->index !== $command->index) + ); + + break; + + case 'dropForeign': + $this->foreignKeys = array_values( + array_filter($this->foreignKeys, fn ($fk) => $fk->columns !== $command->columns) + ); + + break; + } + } +} diff --git a/Schema/Builder.php b/Schema/Builder.php index 2e25cf0cd9..180e647743 100755 --- a/Schema/Builder.php +++ b/Schema/Builder.php @@ -3,11 +3,18 @@ namespace Illuminate\Database\Schema; use Closure; -use LogicException; +use Illuminate\Container\Container; use Illuminate\Database\Connection; +use Illuminate\Database\PostgresConnection; +use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; +use LogicException; +use RuntimeException; class Builder { + use Macroable; + /** * The database connection instance. * @@ -25,22 +32,33 @@ class Builder /** * The Blueprint resolver callback. * - * @var \Closure + * @var \Closure(\Illuminate\Database\Connection, string, \Closure|null): \Illuminate\Database\Schema\Blueprint */ protected $resolver; /** * The default string length for migrations. * - * @var int + * @var non-negative-int|null */ public static $defaultStringLength = 255; + /** + * The default time precision for migrations. + */ + public static ?int $defaultTimePrecision = 0; + + /** + * The default relationship morph key type. + * + * @var 'int'|'uuid'|'ulid' + */ + public static $defaultMorphKeyType = 'int'; + /** * Create a new database Schema manager. * * @param \Illuminate\Database\Connection $connection - * @return void */ public function __construct(Connection $connection) { @@ -51,7 +69,7 @@ public function __construct(Connection $connection) /** * Set the default string length for migrations. * - * @param int $length + * @param non-negative-int $length * @return void */ public static function defaultStringLength($length) @@ -59,6 +77,89 @@ public static function defaultStringLength($length) static::$defaultStringLength = $length; } + /** + * Set the default time precision for migrations. + */ + public static function defaultTimePrecision(?int $precision): void + { + static::$defaultTimePrecision = $precision; + } + + /** + * Set the default morph key type for migrations. + * + * @param string $type + * @return void + * + * @throws \InvalidArgumentException + */ + public static function defaultMorphKeyType(string $type) + { + if (! in_array($type, ['int', 'uuid', 'ulid'])) { + throw new InvalidArgumentException("Morph key type must be 'int', 'uuid', or 'ulid'."); + } + + static::$defaultMorphKeyType = $type; + } + + /** + * Set the default morph key type for migrations to UUIDs. + * + * @return void + */ + public static function morphUsingUuids() + { + static::defaultMorphKeyType('uuid'); + } + + /** + * Set the default morph key type for migrations to ULIDs. + * + * @return void + */ + public static function morphUsingUlids() + { + static::defaultMorphKeyType('ulid'); + } + + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name) + ); + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return $this->connection->statement( + $this->grammar->compileDropDatabaseIfExists($name) + ); + } + + /** + * Get the schemas that belong to the connection. + * + * @return list + */ + public function getSchemas() + { + return $this->connection->getPostProcessor()->processSchemas( + $this->connection->selectFromWriteConnection($this->grammar->compileSchemas()) + ); + } + /** * Determine if the given table exists. * @@ -67,11 +168,96 @@ public static function defaultStringLength($length) */ public function hasTable($table) { + [$schema, $table] = $this->parseSchemaAndTable($table); + $table = $this->connection->getTablePrefix().$table; - return count($this->connection->selectFromWriteConnection( - $this->grammar->compileTableExists(), [$table] - )) > 0; + if ($sql = $this->grammar->compileTableExists($schema, $table)) { + return (bool) $this->connection->scalar($sql); + } + + foreach ($this->getTables($schema ?? $this->getCurrentSchemaName()) as $value) { + if (strtolower($table) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + + /** + * Determine if the given view exists. + * + * @param string $view + * @return bool + */ + public function hasView($view) + { + [$schema, $view] = $this->parseSchemaAndTable($view); + + $view = $this->connection->getTablePrefix().$view; + + foreach ($this->getViews($schema ?? $this->getCurrentSchemaName()) as $value) { + if (strtolower($view) === strtolower($value['name'])) { + return true; + } + } + + return false; + } + + /** + * Get the tables that belong to the connection. + * + * @param string|string[]|null $schema + * @return list + */ + public function getTables($schema = null) + { + return $this->connection->getPostProcessor()->processTables( + $this->connection->selectFromWriteConnection($this->grammar->compileTables($schema)) + ); + } + + /** + * Get the names of the tables that belong to the connection. + * + * @param string|string[]|null $schema + * @param bool $schemaQualified + * @return list + */ + public function getTableListing($schema = null, $schemaQualified = true) + { + return array_column( + $this->getTables($schema), + $schemaQualified ? 'schema_qualified_name' : 'name' + ); + } + + /** + * Get the views that belong to the connection. + * + * @param string|string[]|null $schema + * @return list + */ + public function getViews($schema = null) + { + return $this->connection->getPostProcessor()->processViews( + $this->connection->selectFromWriteConnection($this->grammar->compileViews($schema)) + ); + } + + /** + * Get the user-defined types that belong to the connection. + * + * @param string|string[]|null $schema + * @return list + */ + public function getTypes($schema = null) + { + return $this->connection->getPostProcessor()->processTypes( + $this->connection->selectFromWriteConnection($this->grammar->compileTypes($schema)) + ); } /** @@ -84,7 +270,7 @@ public function hasTable($table) public function hasColumn($table, $column) { return in_array( - strtolower($column), array_map('strtolower', $this->getColumnListing($table)) + strtolower($column), array_map(strtolower(...), $this->getColumnListing($table)) ); } @@ -92,12 +278,12 @@ public function hasColumn($table, $column) * Determine if the given table has given columns. * * @param string $table - * @param array $columns + * @param array $columns * @return bool */ public function hasColumns($table, array $columns) { - $tableColumns = array_map('strtolower', $this->getColumnListing($table)); + $tableColumns = array_map(strtolower(...), $this->getColumnListing($table)); foreach ($columns as $column) { if (! in_array(strtolower($column), $tableColumns)) { @@ -108,39 +294,200 @@ public function hasColumns($table, array $columns) return true; } + /** + * Execute a table builder callback if the given table has a given column. + * + * @param string $table + * @param string $column + * @param \Closure $callback + * @return void + */ + public function whenTableHasColumn(string $table, string $column, Closure $callback) + { + if ($this->hasColumn($table, $column)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table doesn't have a given column. + * + * @param string $table + * @param string $column + * @param \Closure $callback + * @return void + */ + public function whenTableDoesntHaveColumn(string $table, string $column, Closure $callback) + { + if (! $this->hasColumn($table, $column)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table has a given index. + * + * @param string $table + * @param string|array $index + * @param \Closure $callback + * @param string|null $type + * @return void + */ + public function whenTableHasIndex(string $table, string|array $index, Closure $callback, ?string $type = null) + { + if ($this->hasIndex($table, $index, $type)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + + /** + * Execute a table builder callback if the given table doesn't have a given index. + * + * @param string $table + * @param string|array $index + * @param \Closure $callback + * @param string|null $type + * @return void + */ + public function whenTableDoesntHaveIndex(string $table, string|array $index, Closure $callback, ?string $type = null) + { + if (! $this->hasIndex($table, $index, $type)) { + $this->table($table, fn (Blueprint $table) => $callback($table)); + } + } + /** * Get the data type for the given column name. * * @param string $table * @param string $column + * @param bool $fullDefinition * @return string + * + * @throws \InvalidArgumentException */ - public function getColumnType($table, $column) + public function getColumnType($table, $column, $fullDefinition = false) { - $table = $this->connection->getTablePrefix().$table; + $columns = $this->getColumns($table); - return $this->connection->getDoctrineColumn($table, $column)->getType()->getName(); + foreach ($columns as $value) { + if (strtolower($value['name']) === strtolower($column)) { + return $fullDefinition ? $value['type'] : $value['type_name']; + } + } + + throw new InvalidArgumentException("There is no column with name '$column' on table '$table'."); } /** * Get the column listing for a given table. * * @param string $table - * @return array + * @return list */ public function getColumnListing($table) { - $results = $this->connection->selectFromWriteConnection($this->grammar->compileColumnListing( - $this->connection->getTablePrefix().$table - )); + return array_column($this->getColumns($table), 'name'); + } + + /** + * Get the columns for a given table. + * + * @param string $table + * @return list + */ + public function getColumns($table) + { + [$schema, $table] = $this->parseSchemaAndTable($table); - return $this->connection->getPostProcessor()->processColumnListing($results); + $table = $this->connection->getTablePrefix().$table; + + return $this->connection->getPostProcessor()->processColumns( + $this->connection->selectFromWriteConnection( + $this->grammar->compileColumns($schema, $table) + ) + ); + } + + /** + * Get the indexes for a given table. + * + * @param string $table + * @return list, type: string, unique: bool, primary: bool}> + */ + public function getIndexes($table) + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix().$table; + + return $this->connection->getPostProcessor()->processIndexes( + $this->connection->selectFromWriteConnection( + $this->grammar->compileIndexes($schema, $table) + ) + ); + } + + /** + * Get the names of the indexes for a given table. + * + * @param string $table + * @return list + */ + public function getIndexListing($table) + { + return array_column($this->getIndexes($table), 'name'); + } + + /** + * Determine if the given table has a given index. + * + * @param string $table + * @param string|array $index + * @param string|null $type + * @return bool + */ + public function hasIndex($table, $index, $type = null) + { + $type = is_null($type) ? $type : strtolower($type); + + foreach ($this->getIndexes($table) as $value) { + $typeMatches = is_null($type) + || ($type === 'primary' && $value['primary']) + || ($type === 'unique' && $value['unique']) + || $type === $value['type']; + + if (($value['name'] === $index || $value['columns'] === $index) && $typeMatches) { + return true; + } + } + + return false; + } + + /** + * Get the foreign keys for a given table. + * + * @param string $table + * @return array + */ + public function getForeignKeys($table) + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix().$table; + + return $this->connection->getPostProcessor()->processForeignKeys( + $this->connection->selectFromWriteConnection( + $this->grammar->compileForeignKeys($schema, $table) + ) + ); } /** * Modify a table on the schema. * - * @param string $table + * @param string $table * @param \Closure $callback * @return void */ @@ -152,7 +499,7 @@ public function table($table, Closure $callback) /** * Create a new table on the schema. * - * @param string $table + * @param string $table * @param \Closure $callback * @return void */ @@ -191,6 +538,20 @@ public function dropIfExists($table) })); } + /** + * Drop columns from a table schema. + * + * @param string $table + * @param string|array $columns + * @return void + */ + public function dropColumns($table, $columns) + { + $this->table($table, function (Blueprint $blueprint) use ($columns) { + $blueprint->dropColumn($columns); + }); + } + /** * Drop all tables from the database. * @@ -215,6 +576,18 @@ public function dropAllViews() throw new LogicException('This database driver does not support dropping all views.'); } + /** + * Drop all types from the database. + * + * @return void + * + * @throws \LogicException + */ + public function dropAllTypes() + { + throw new LogicException('This database driver does not support dropping all types.'); + } + /** * Rename a table on the schema. * @@ -253,6 +626,59 @@ public function disableForeignKeyConstraints() ); } + /** + * Disable foreign key constraints during the execution of a callback. + * + * @template TReturn + * + * @param (\Closure(): TReturn) $callback + * @return TReturn + */ + public function withoutForeignKeyConstraints(Closure $callback) + { + $this->disableForeignKeyConstraints(); + + try { + return $callback(); + } finally { + $this->enableForeignKeyConstraints(); + } + } + + /** + * Create the vector extension on the schema if it does not exist. + * + * @param string|null $schema + * @return void + */ + public function ensureVectorExtensionExists($schema = null) + { + $this->ensureExtensionExists('vector', $schema); + } + + /** + * Create a new extension on the schema if it does not exist. + * + * @param string $name + * @param string|null $schema + * @return void + * + * @throws \RuntimeException + */ + public function ensureExtensionExists($name, $schema = null) + { + if (! $this->getConnection() instanceof PostgresConnection) { + throw new RuntimeException('Extensions are only supported by Postgres.'); + } + + $name = $this->getConnection()->getSchemaGrammar()->wrap($name); + + $this->getConnection()->statement(match (filled($schema)) { + true => "create extension if not exists {$name} schema {$this->getConnection()->getSchemaGrammar()->wrap($schema)}", + false => "create extension if not exists {$name}", + }); + } + /** * Execute the blueprint to build / modify the table. * @@ -261,7 +687,7 @@ public function disableForeignKeyConstraints() */ protected function build(Blueprint $blueprint) { - $blueprint->build($this->connection, $this->grammar); + $blueprint->build(); } /** @@ -271,46 +697,82 @@ protected function build(Blueprint $blueprint) * @param \Closure|null $callback * @return \Illuminate\Database\Schema\Blueprint */ - protected function createBlueprint($table, Closure $callback = null) + protected function createBlueprint($table, ?Closure $callback = null) { - $prefix = $this->connection->getConfig('prefix_indexes') - ? $this->connection->getConfig('prefix') - : ''; + $connection = $this->connection; if (isset($this->resolver)) { - return call_user_func($this->resolver, $table, $callback, $prefix); + return call_user_func($this->resolver, $connection, $table, $callback); } - return new Blueprint($table, $callback, $prefix); + return Container::getInstance()->make(Blueprint::class, compact('connection', 'table', 'callback')); } /** - * Get the database connection instance. + * Get the names of the current schemas for the connection. * - * @return \Illuminate\Database\Connection + * @return string[]|null */ - public function getConnection() + public function getCurrentSchemaListing() { - return $this->connection; + return null; } /** - * Set the database connection instance. + * Get the default schema name for the connection. * - * @param \Illuminate\Database\Connection $connection - * @return $this + * @return string|null */ - public function setConnection(Connection $connection) + public function getCurrentSchemaName() { - $this->connection = $connection; + return $this->getCurrentSchemaListing()[0] ?? null; + } - return $this; + /** + * Parse the given database object reference and extract the schema and table. + * + * @param string $reference + * @param string|bool|null $withDefaultSchema + * @return array{string|null, string} + * + * @throws \InvalidArgumentException + */ + public function parseSchemaAndTable($reference, $withDefaultSchema = null) + { + $segments = explode('.', $reference); + + if (count($segments) > 2) { + throw new InvalidArgumentException( + "Using three-part references is not supported, you may use `Schema::connection('{$segments[0]}')` instead." + ); + } + + $table = $segments[1] ?? $segments[0]; + + $schema = match (true) { + isset($segments[1]) => $segments[0], + is_string($withDefaultSchema) => $withDefaultSchema, + $withDefaultSchema => $this->getCurrentSchemaName(), + default => null, + }; + + return [$schema, $table]; + } + + /** + * Get the database connection instance. + * + * @return \Illuminate\Database\Connection + */ + public function getConnection() + { + return $this->connection; } /** * Set the Schema Blueprint resolver callback. * - * @param \Closure $resolver + * @param \Closure(\Illuminate\Database\Connection, string, \Closure|null): \Illuminate\Database\Schema\Blueprint $resolver * @return void */ public function blueprintResolver(Closure $resolver) diff --git a/Schema/ColumnDefinition.php b/Schema/ColumnDefinition.php index 25166a1e3e..05618db57c 100644 --- a/Schema/ColumnDefinition.php +++ b/Schema/ColumnDefinition.php @@ -5,26 +5,35 @@ use Illuminate\Support\Fluent; /** - * @method ColumnDefinition after(string $column) Place the column "after" another column (MySQL) - * @method ColumnDefinition always() Used as a modifier for generatedAs() (PostgreSQL) - * @method ColumnDefinition autoIncrement() Set INTEGER columns as auto-increment (primary key) - * @method ColumnDefinition change() Change the column - * @method ColumnDefinition charset(string $charset) Specify a character set for the column (MySQL) - * @method ColumnDefinition collation(string $collation) Specify a collation for the column (MySQL/SQL Server) - * @method ColumnDefinition comment(string $comment) Add a comment to the column (MySQL) - * @method ColumnDefinition default(mixed $value) Specify a "default" value for the column - * @method ColumnDefinition first() Place the column "first" in the table (MySQL) - * @method ColumnDefinition generatedAs(string $expression) Create a SQL compliant identity column (PostgreSQL) - * @method ColumnDefinition index(string $indexName = null) Add an index - * @method ColumnDefinition nullable(bool $value = true) Allow NULL values to be inserted into the column - * @method ColumnDefinition primary() Add a primary index - * @method ColumnDefinition spatialIndex() Add a spatial index - * @method ColumnDefinition storedAs(string $expression) Create a stored generated column (MySQL) - * @method ColumnDefinition unique() Add a unique index - * @method ColumnDefinition unsigned() Set the INTEGER column as UNSIGNED (MySQL) - * @method ColumnDefinition useCurrent() Set the TIMESTAMP column to use CURRENT_TIMESTAMP as default value - * @method ColumnDefinition virtualAs(string $expression) Create a virtual generated column (MySQL) - * @method ColumnDefinition persisted() Mark the computed generated column as persistent (SQL Server) + * @method $this after(string $column) Place the column "after" another column (MySQL) + * @method $this always(bool $value = true) Used as a modifier for generatedAs() (PostgreSQL) + * @method $this autoIncrement() Set INTEGER columns as auto-increment (primary key) + * @method $this change() Change the column + * @method $this charset(string $charset) Specify a character set for the column (MySQL) + * @method $this collation(string $collation) Specify a collation for the column + * @method $this comment(string $comment) Add a comment to the column (MySQL/PostgreSQL) + * @method $this default(mixed $value) Specify a "default" value for the column + * @method $this first() Place the column "first" in the table (MySQL) + * @method $this from(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) + * @method $this fulltext(bool|string $indexName = null) Add a fulltext index + * @method $this generatedAs(string|\Illuminate\Contracts\Database\Query\Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) + * @method $this instant() Specify that algorithm=instant should be used for the column operation (MySQL) + * @method $this index(bool|string $indexName = null) Add an index + * @method $this invisible() Specify that the column should be invisible to "SELECT *" (MySQL) + * @method $this lock(('none'|'shared'|'default'|'exclusive') $value) Specify the DDL lock mode for the column operation (MySQL) + * @method $this nullable(bool $value = true) Allow NULL values to be inserted into the column + * @method $this persisted() Mark the computed generated column as persistent (SQL Server) + * @method $this primary(bool $value = true) Add a primary index + * @method $this spatialIndex(bool|string $indexName = null) Add a spatial index + * @method $this vectorIndex(bool|string $indexName = null) Add a vector index + * @method $this startingValue(int $startingValue) Set the starting value of an auto-incrementing field (MySQL/PostgreSQL) + * @method $this storedAs(string|\Illuminate\Contracts\Database\Query\Expression $expression) Create a stored generated column (MySQL/PostgreSQL/SQLite) + * @method $this type(string $type) Specify a type for the column + * @method $this unique(bool|string $indexName = null) Add a unique index + * @method $this unsigned() Set the INTEGER column as UNSIGNED (MySQL) + * @method $this useCurrent() Set the TIMESTAMP column to use CURRENT_TIMESTAMP as default value + * @method $this useCurrentOnUpdate() Set the TIMESTAMP column to use CURRENT_TIMESTAMP when updating (MySQL) + * @method $this virtualAs(string|\Illuminate\Contracts\Database\Query\Expression $expression) Create a virtual generated column (MySQL/PostgreSQL/SQLite) */ class ColumnDefinition extends Fluent { diff --git a/Schema/ForeignIdColumnDefinition.php b/Schema/ForeignIdColumnDefinition.php new file mode 100644 index 0000000000..2501bebe68 --- /dev/null +++ b/Schema/ForeignIdColumnDefinition.php @@ -0,0 +1,56 @@ +blueprint = $blueprint; + } + + /** + * Create a foreign key constraint on this column referencing the "id" column of the conventionally related table. + * + * @param string|null $table + * @param string|null $column + * @param string|null $indexName + * @return \Illuminate\Database\Schema\ForeignKeyDefinition + */ + public function constrained($table = null, $column = null, $indexName = null) + { + $table ??= $this->table; + $column ??= $this->referencesModelColumn ?? 'id'; + + return $this->references($column, $indexName)->on($table ?? (new Stringable($this->name))->beforeLast('_'.$column)->plural()); + } + + /** + * Specify which column this foreign ID references on another table. + * + * @param string $column + * @param string|null $indexName + * @return \Illuminate\Database\Schema\ForeignKeyDefinition + */ + public function references($column, $indexName = null) + { + return $this->blueprint->foreign($this->name, $indexName)->references($column); + } +} diff --git a/Schema/ForeignKeyDefinition.php b/Schema/ForeignKeyDefinition.php new file mode 100644 index 0000000000..cd17896560 --- /dev/null +++ b/Schema/ForeignKeyDefinition.php @@ -0,0 +1,97 @@ +onUpdate('cascade'); + } + + /** + * Indicate that updates should be restricted. + * + * @return $this + */ + public function restrictOnUpdate() + { + return $this->onUpdate('restrict'); + } + + /** + * Indicate that updates should set the foreign key value to null. + * + * @return $this + */ + public function nullOnUpdate() + { + return $this->onUpdate('set null'); + } + + /** + * Indicate that updates should have "no action". + * + * @return $this + */ + public function noActionOnUpdate() + { + return $this->onUpdate('no action'); + } + + /** + * Indicate that deletes should cascade. + * + * @return $this + */ + public function cascadeOnDelete() + { + return $this->onDelete('cascade'); + } + + /** + * Indicate that deletes should be restricted. + * + * @return $this + */ + public function restrictOnDelete() + { + return $this->onDelete('restrict'); + } + + /** + * Indicate that deletes should set the foreign key value to null. + * + * @return $this + */ + public function nullOnDelete() + { + return $this->onDelete('set null'); + } + + /** + * Indicate that deletes should have "no action". + * + * @return $this + */ + public function noActionOnDelete() + { + return $this->onDelete('no action'); + } +} diff --git a/Schema/Grammars/ChangeColumn.php b/Schema/Grammars/ChangeColumn.php deleted file mode 100644 index 3941cd3182..0000000000 --- a/Schema/Grammars/ChangeColumn.php +++ /dev/null @@ -1,211 +0,0 @@ -isDoctrineAvailable()) { - throw new RuntimeException(sprintf( - 'Changing columns for table "%s" requires Doctrine DBAL; install "doctrine/dbal".', - $blueprint->getTable() - )); - } - - $tableDiff = static::getChangedDiff( - $grammar, $blueprint, $schema = $connection->getDoctrineSchemaManager() - ); - - if ($tableDiff !== false) { - return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); - } - - return []; - } - - /** - * Get the Doctrine table difference for the given changes. - * - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema - * @return \Doctrine\DBAL\Schema\TableDiff|bool - */ - protected static function getChangedDiff($grammar, Blueprint $blueprint, SchemaManager $schema) - { - $current = $schema->listTableDetails($grammar->getTablePrefix().$blueprint->getTable()); - - return (new Comparator)->diffTable( - $current, static::getTableWithColumnChanges($blueprint, $current) - ); - } - - /** - * Get a copy of the given Doctrine table after making the column changes. - * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Doctrine\DBAL\Schema\Table $table - * @return \Doctrine\DBAL\Schema\Table - */ - protected static function getTableWithColumnChanges(Blueprint $blueprint, Table $table) - { - $table = clone $table; - - foreach ($blueprint->getChangedColumns() as $fluent) { - $column = static::getDoctrineColumn($table, $fluent); - - // Here we will spin through each fluent column definition and map it to the proper - // Doctrine column definitions - which is necessary because Laravel and Doctrine - // use some different terminology for various column attributes on the tables. - foreach ($fluent->getAttributes() as $key => $value) { - if (! is_null($option = static::mapFluentOptionToDoctrine($key))) { - if (method_exists($column, $method = 'set'.ucfirst($option))) { - $column->{$method}(static::mapFluentValueToDoctrine($option, $value)); - } - } - } - } - - return $table; - } - - /** - * Get the Doctrine column instance for a column change. - * - * @param \Doctrine\DBAL\Schema\Table $table - * @param \Illuminate\Support\Fluent $fluent - * @return \Doctrine\DBAL\Schema\Column - */ - protected static function getDoctrineColumn(Table $table, Fluent $fluent) - { - return $table->changeColumn( - $fluent['name'], static::getDoctrineColumnChangeOptions($fluent) - )->getColumn($fluent['name']); - } - - /** - * Get the Doctrine column change options. - * - * @param \Illuminate\Support\Fluent $fluent - * @return array - */ - protected static function getDoctrineColumnChangeOptions(Fluent $fluent) - { - $options = ['type' => static::getDoctrineColumnType($fluent['type'])]; - - if (in_array($fluent['type'], ['text', 'mediumText', 'longText'])) { - $options['length'] = static::calculateDoctrineTextLength($fluent['type']); - } - - if ($fluent['type'] === 'json') { - $options['customSchemaOptions'] = [ - 'collation' => '', - ]; - } - - return $options; - } - - /** - * Get the doctrine column type. - * - * @param string $type - * @return \Doctrine\DBAL\Types\Type - */ - protected static function getDoctrineColumnType($type) - { - $type = strtolower($type); - - switch ($type) { - case 'biginteger': - $type = 'bigint'; - break; - case 'smallinteger': - $type = 'smallint'; - break; - case 'mediumtext': - case 'longtext': - $type = 'text'; - break; - case 'binary': - $type = 'blob'; - break; - } - - return Type::getType($type); - } - - /** - * Calculate the proper column length to force the Doctrine text type. - * - * @param string $type - * @return int - */ - protected static function calculateDoctrineTextLength($type) - { - switch ($type) { - case 'mediumText': - return 65535 + 1; - case 'longText': - return 16777215 + 1; - default: - return 255 + 1; - } - } - - /** - * Get the matching Doctrine option for a given Fluent attribute name. - * - * @param string $attribute - * @return string|null - */ - protected static function mapFluentOptionToDoctrine($attribute) - { - switch ($attribute) { - case 'type': - case 'name': - return; - case 'nullable': - return 'notnull'; - case 'total': - return 'precision'; - case 'places': - return 'scale'; - default: - return $attribute; - } - } - - /** - * Get the matching Doctrine value for a given Fluent attribute. - * - * @param string $option - * @param mixed $value - * @return mixed - */ - protected static function mapFluentValueToDoctrine($option, $value) - { - return $option === 'notnull' ? ! $value : $value; - } -} diff --git a/Schema/Grammars/Grammar.php b/Schema/Grammars/Grammar.php index 0f81731b36..5a1fd468e1 100755 --- a/Schema/Grammars/Grammar.php +++ b/Schema/Grammars/Grammar.php @@ -2,17 +2,27 @@ namespace Illuminate\Database\Schema\Grammars; -use RuntimeException; -use Illuminate\Support\Fluent; -use Doctrine\DBAL\Schema\TableDiff; -use Illuminate\Database\Connection; -use Illuminate\Database\Query\Expression; -use Illuminate\Database\Schema\Blueprint; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Database\Concerns\CompilesJsonPaths; use Illuminate\Database\Grammar as BaseGrammar; -use Doctrine\DBAL\Schema\AbstractSchemaManager as SchemaManager; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Fluent; +use RuntimeException; +use UnitEnum; + +use function Illuminate\Support\enum_value; abstract class Grammar extends BaseGrammar { + use CompilesJsonPaths; + + /** + * The possible column modifiers. + * + * @var string[] + */ + protected $modifiers = []; + /** * If this Grammar supports schema changes wrapped in a transaction. * @@ -27,17 +37,165 @@ abstract class Grammar extends BaseGrammar */ protected $fluentCommands = []; + /** + * Compile a create database command. + * + * @param string $name + * @return string + */ + public function compileCreateDatabase($name) + { + return sprintf('create database %s', + $this->wrapValue($name), + ); + } + + /** + * Compile a drop database if exists command. + * + * @param string $name + * @return string + */ + public function compileDropDatabaseIfExists($name) + { + return sprintf('drop database if exists %s', + $this->wrapValue($name) + ); + } + + /** + * Compile the query to determine the schemas. + * + * @return string + * + * @throws \RuntimeException + */ + public function compileSchemas() + { + throw new RuntimeException('This database driver does not support retrieving schemas.'); + } + + /** + * Compile the query to determine if the given table exists. + * + * @param string|null $schema + * @param string $table + * @return string|null + */ + public function compileTableExists($schema, $table) + { + // + } + + /** + * Compile the query to determine the tables. + * + * @param string|string[]|null $schema + * @return string + * + * @throws \RuntimeException + */ + public function compileTables($schema) + { + throw new RuntimeException('This database driver does not support retrieving tables.'); + } + + /** + * Compile the query to determine the views. + * + * @param string|string[]|null $schema + * @return string + * + * @throws \RuntimeException + */ + public function compileViews($schema) + { + throw new RuntimeException('This database driver does not support retrieving views.'); + } + + /** + * Compile the query to determine the user-defined types. + * + * @param string|string[]|null $schema + * @return string + * + * @throws \RuntimeException + */ + public function compileTypes($schema) + { + throw new RuntimeException('This database driver does not support retrieving user-defined types.'); + } + + /** + * Compile the query to determine the columns. + * + * @param string|null $schema + * @param string $table + * @return string + * + * @throws \RuntimeException + */ + public function compileColumns($schema, $table) + { + throw new RuntimeException('This database driver does not support retrieving columns.'); + } + + /** + * Compile the query to determine the indexes. + * + * @param string|null $schema + * @param string $table + * @return string + * + * @throws \RuntimeException + */ + public function compileIndexes($schema, $table) + { + throw new RuntimeException('This database driver does not support retrieving indexes.'); + } + + /** + * Compile a vector index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return void + * + * @throws \RuntimeException + */ + public function compileVectorIndex(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('The database driver in use does not support vector indexes.'); + } + + /** + * Compile the query to determine the foreign keys. + * + * @param string|null $schema + * @param string $table + * @return string + * + * @throws \RuntimeException + */ + public function compileForeignKeys($schema, $table) + { + throw new RuntimeException('This database driver does not support retrieving foreign keys.'); + } + /** * Compile a rename column command. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection - * @return array + * @return list|string */ - public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileRenameColumn(Blueprint $blueprint, Fluent $command) { - return RenameColumn::compile($this, $blueprint, $command, $connection); + return sprintf('alter table %s rename column %s to %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to) + ); } /** @@ -45,14 +203,41 @@ public function compileRenameColumn(Blueprint $blueprint, Fluent $command, Conne * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection - * @return array + * @return list|string + * + * @throws \RuntimeException + */ + public function compileChange(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('This database driver does not support modifying columns.'); + } + + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string * * @throws \RuntimeException */ - public function compileChange(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileFulltext(Blueprint $blueprint, Fluent $command) { - return ChangeColumn::compile($this, $blueprint, $command, $connection); + throw new RuntimeException('This database driver does not support fulltext index creation.'); + } + + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + * + * @throws \RuntimeException + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('This database driver does not support fulltext index removal.'); } /** @@ -96,9 +281,23 @@ public function compileForeign(Blueprint $blueprint, Fluent $command) } /** - * Compile the blueprint's column definitions. + * Compile a drop foreign key command. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + * + * @throws \RuntimeException + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command) + { + throw new RuntimeException('This database driver does not support dropping foreign keys.'); + } + + /** + * Compile the blueprint's added column definitions. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @return array */ protected function getColumns(Blueprint $blueprint) @@ -106,17 +305,29 @@ protected function getColumns(Blueprint $blueprint) $columns = []; foreach ($blueprint->getAddedColumns() as $column) { - // Each of the column types have their own compiler functions which are tasked - // with turning the column definition into its SQL format for this platform - // used by the connection. The column's modifiers are compiled and added. - $sql = $this->wrap($column).' '.$this->getType($column); - - $columns[] = $this->addModifiers($sql, $blueprint, $column); + $columns[] = $this->getColumn($blueprint, $column); } return $columns; } + /** + * Compile the column definition. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Database\Schema\ColumnDefinition $column + * @return string + */ + protected function getColumn(Blueprint $blueprint, $column) + { + // Each of the column types has their own compiler functions, which are tasked + // with turning the column definition into its SQL format for this platform + // used by the connection. The column's modifiers are compiled and added. + $sql = $this->wrap($column).' '.$this->getType($column); + + return $this->addModifiers($sql, $blueprint, $column); + } + /** * Get the SQL for the column data type. * @@ -141,6 +352,30 @@ protected function typeComputed(Fluent $column) throw new RuntimeException('This database driver does not support the computed type.'); } + /** + * Create the column definition for a vector type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + * + * @throws \RuntimeException + */ + protected function typeVector(Fluent $column) + { + throw new RuntimeException('This database driver does not support the vector type.'); + } + + /** + * Create the column definition for a raw column type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeRaw(Fluent $column) + { + return $column->offsetGet('definition'); + } + /** * Add the column modifiers to the definition. * @@ -161,7 +396,7 @@ protected function addModifiers($sql, Blueprint $blueprint, Fluent $column) } /** - * Get the primary key command if it exists on the blueprint. + * Get the command with a given name if it exists on the blueprint. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param string $name @@ -172,7 +407,7 @@ protected function getCommandByName(Blueprint $blueprint, $name) $commands = $this->getCommandsByName($blueprint, $name); if (count($commands) > 0) { - return reset($commands); + return array_first($commands); } } @@ -190,12 +425,30 @@ protected function getCommandsByName(Blueprint $blueprint, $name) }); } + /* + * Determine if a command with a given name exists on the blueprint. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param string $name + * @return bool + */ + protected function hasCommand(Blueprint $blueprint, $name) + { + foreach ($blueprint->getCommands() as $command) { + if ($command->name === $name) { + return true; + } + } + + return false; + } + /** * Add a prefix to an array of values. * * @param string $prefix - * @param array $values - * @return array + * @param array $values + * @return array */ public function prefixArray($prefix, array $values) { @@ -207,61 +460,50 @@ public function prefixArray($prefix, array $values) /** * Wrap a table in keyword identifiers. * - * @param mixed $table + * @param mixed $table + * @param string|null $prefix * @return string */ - public function wrapTable($table) + public function wrapTable($table, $prefix = null) { return parent::wrapTable( - $table instanceof Blueprint ? $table->getTable() : $table + $table instanceof Blueprint ? $table->getTable() : $table, + $prefix ); } /** * Wrap a value in keyword identifiers. * - * @param \Illuminate\Database\Query\Expression|string $value - * @param bool $prefixAlias + * @param \Illuminate\Support\Fluent|\Illuminate\Contracts\Database\Query\Expression|string $value * @return string */ - public function wrap($value, $prefixAlias = false) + public function wrap($value) { return parent::wrap( - $value instanceof Fluent ? $value->name : $value, $prefixAlias + $value instanceof Fluent ? $value->name : $value, ); } /** * Format a value so that it can be used in "default" clauses. * - * @param mixed $value + * @param mixed $value * @return string */ protected function getDefaultValue($value) { if ($value instanceof Expression) { - return $value; + return $this->getValue($value); } - return is_bool($value) - ? "'".(int) $value."'" - : "'".(string) $value."'"; - } - - /** - * Create an empty Doctrine DBAL TableDiff from the Blueprint. - * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema - * @return \Doctrine\DBAL\Schema\TableDiff - */ - public function getDoctrineTableDiff(Blueprint $blueprint, SchemaManager $schema) - { - $table = $this->getTablePrefix().$blueprint->getTable(); + if ($value instanceof UnitEnum) { + return "'".str_replace("'", "''", enum_value($value))."'"; + } - return tap(new TableDiff($table), function ($tableDiff) use ($schema, $table) { - $tableDiff->fromTable = $schema->listTableDetails($table); - }); + return is_bool($value) + ? "'".(int) $value."'" + : "'".str_replace("'", "''", $value)."'"; } /** diff --git a/Schema/Grammars/MariaDbGrammar.php b/Schema/Grammars/MariaDbGrammar.php new file mode 100755 index 0000000000..ec15f50c78 --- /dev/null +++ b/Schema/Grammars/MariaDbGrammar.php @@ -0,0 +1,67 @@ +connection->getServerVersion(), '10.5.2', '<')) { + return $this->compileLegacyRenameColumn($blueprint, $command); + } + + return parent::compileRenameColumn($blueprint, $command); + } + + /** + * Create the column definition for a uuid type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeUuid(Fluent $column) + { + if (version_compare($this->connection->getServerVersion(), '10.7.0', '<')) { + return 'char(36)'; + } + + return 'uuid'; + } + + /** + * Create the column definition for a spatial Geometry type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeGeometry(Fluent $column) + { + $subtype = $column->subtype ? strtolower($column->subtype) : null; + + if (! in_array($subtype, ['point', 'linestring', 'polygon', 'geometrycollection', 'multipoint', 'multilinestring', 'multipolygon'])) { + $subtype = null; + } + + return sprintf('%s%s', + $subtype ?? 'geometry', + $column->srid ? ' ref_system_id='.$column->srid : '' + ); + } + + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + */ + protected function wrapJsonSelector($value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_value('.$field.$path.')'; + } +} diff --git a/Schema/Grammars/MySqlGrammar.php b/Schema/Grammars/MySqlGrammar.php index 7d476c171c..30b559eb6e 100755 --- a/Schema/Grammars/MySqlGrammar.php +++ b/Schema/Grammars/MySqlGrammar.php @@ -2,48 +2,201 @@ namespace Illuminate\Database\Schema\Grammars; -use RuntimeException; -use Illuminate\Support\Fluent; -use Illuminate\Database\Connection; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\ColumnDefinition; +use Illuminate\Support\Collection; +use Illuminate\Support\Fluent; +use RuntimeException; class MySqlGrammar extends Grammar { /** * The possible column modifiers. * - * @var array + * @var string[] */ protected $modifiers = [ - 'Unsigned', 'VirtualAs', 'StoredAs', 'Charset', 'Collate', 'Nullable', - 'Default', 'Increment', 'Comment', 'After', 'First', 'Srid', + 'Unsigned', 'Charset', 'Collate', 'VirtualAs', 'StoredAs', 'Nullable', + 'Default', 'OnUpdate', 'Invisible', 'Increment', 'Comment', 'After', 'First', ]; /** * The possible column serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; /** - * Compile the query to determine the list of tables. + * The commands to be executed outside of create or alter commands. + * + * @var string[] + */ + protected $fluentCommands = ['AutoIncrementStartingValues']; + + /** + * Compile a create database command. * + * @param string $name * @return string */ - public function compileTableExists() + public function compileCreateDatabase($name) { - return 'select * from information_schema.tables where table_schema = ? and table_name = ?'; + $sql = parent::compileCreateDatabase($name); + + if ($charset = $this->connection->getConfig('charset')) { + $sql .= sprintf(' default character set %s', $this->wrapValue($charset)); + } + + if ($collation = $this->connection->getConfig('collation')) { + $sql .= sprintf(' default collate %s', $this->wrapValue($collation)); + } + + return $sql; } /** - * Compile the query to determine the list of columns. + * Compile the query to determine the schemas. * * @return string */ - public function compileColumnListing() + public function compileSchemas() { - return 'select column_name as `column_name` from information_schema.columns where table_schema = ? and table_name = ?'; + return 'select schema_name as name, schema_name = schema() as `default` from information_schema.schemata where ' + .$this->compileSchemaWhereClause(null, 'schema_name') + .' order by schema_name'; + } + + /** + * Compile the query to determine if the given table exists. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileTableExists($schema, $table) + { + return sprintf( + 'select exists (select 1 from information_schema.tables where ' + ."table_schema = %s and table_name = %s and table_type in ('BASE TABLE', 'SYSTEM VERSIONED')) as `exists`", + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the tables. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileTables($schema) + { + return sprintf( + 'select table_name as `name`, table_schema as `schema`, (data_length + index_length) as `size`, ' + .'table_comment as `comment`, engine as `engine`, table_collation as `collation` ' + ."from information_schema.tables where table_type in ('BASE TABLE', 'SYSTEM VERSIONED') and " + .$this->compileSchemaWhereClause($schema, 'table_schema') + .' order by table_schema, table_name', + $this->quoteString($schema) + ); + } + + /** + * Compile the query to determine the views. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileViews($schema) + { + return 'select table_name as `name`, table_schema as `schema`, view_definition as `definition` ' + .'from information_schema.views where ' + .$this->compileSchemaWhereClause($schema, 'table_schema') + .' order by table_schema, table_name'; + } + + /** + * Compile the query to compare the schema. + * + * @param string|string[]|null $schema + * @param string $column + * @return string + */ + protected function compileSchemaWhereClause($schema, $column) + { + return $column.(match (true) { + ! empty($schema) && is_array($schema) => ' in ('.$this->quoteString($schema).')', + ! empty($schema) => ' = '.$this->quoteString($schema), + default => " not in ('information_schema', 'mysql', 'ndbinfo', 'performance_schema', 'sys')", + }); + } + + /** + * Compile the query to determine the columns. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileColumns($schema, $table) + { + return sprintf( + 'select column_name as `name`, data_type as `type_name`, column_type as `type`, ' + .'collation_name as `collation`, is_nullable as `nullable`, ' + .'column_default as `default`, column_comment as `comment`, ' + .'generation_expression as `expression`, extra as `extra` ' + .'from information_schema.columns where table_schema = %s and table_name = %s ' + .'order by ordinal_position asc', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the indexes. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileIndexes($schema, $table) + { + return sprintf( + 'select index_name as `name`, group_concat(column_name order by seq_in_index) as `columns`, ' + .'index_type as `type`, not non_unique as `unique` ' + .'from information_schema.statistics where table_schema = %s and table_name = %s ' + .'group by index_name, index_type, non_unique', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the foreign keys. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileForeignKeys($schema, $table) + { + return sprintf( + 'select kc.constraint_name as `name`, ' + .'group_concat(kc.column_name order by kc.ordinal_position) as `columns`, ' + .'kc.referenced_table_schema as `foreign_schema`, ' + .'kc.referenced_table_name as `foreign_table`, ' + .'group_concat(kc.referenced_column_name order by kc.ordinal_position) as `foreign_columns`, ' + .'rc.update_rule as `on_update`, ' + .'rc.delete_rule as `on_delete` ' + .'from information_schema.key_column_usage kc join information_schema.referential_constraints rc ' + .'on kc.constraint_schema = rc.constraint_schema and kc.constraint_name = rc.constraint_name ' + .'where kc.table_schema = %s and kc.table_name = %s and kc.referenced_table_name is not null ' + .'group by kc.constraint_name, kc.referenced_table_schema, kc.referenced_table_name, rc.update_rule, rc.delete_rule', + $schema ? $this->quoteString($schema) : 'schema()', + $this->quoteString($table) + ); } /** @@ -51,28 +204,25 @@ public function compileColumnListing() * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection * @return string */ - public function compileCreate(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileCreate(Blueprint $blueprint, Fluent $command) { $sql = $this->compileCreateTable( - $blueprint, $command, $connection + $blueprint, $command ); // Once we have the primary SQL, we can add the encoding option to the SQL for // the table. Then, we can check if a storage engine has been supplied for // the table. If so, we will add the engine declaration to the SQL query. $sql = $this->compileCreateEncoding( - $sql, $connection, $blueprint + $sql, $blueprint ); // Finally, we will append the engine configuration onto this SQL statement as // the final thing we do before returning this finished SQL. Once this gets // added the query will be ready to execute against the real connections. - return $this->compileCreateEngine( - $sql, $connection, $blueprint - ); + return $this->compileCreateEngine($sql, $blueprint); } /** @@ -80,15 +230,26 @@ public function compileCreate(Blueprint $blueprint, Fluent $command, Connection * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection * @return string */ - protected function compileCreateTable($blueprint, $command, $connection) + protected function compileCreateTable($blueprint, $command) { + $tableStructure = $this->getColumns($blueprint); + + if ($primaryKey = $this->getCommandByName($blueprint, 'primary')) { + $tableStructure[] = sprintf( + 'primary key %s(%s)', + $primaryKey->algorithm ? 'using '.$primaryKey->algorithm : '', + $this->columnize($primaryKey->columns) + ); + + $primaryKey->shouldBeSkipped = true; + } + return sprintf('%s table %s (%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), - implode(', ', $this->getColumns($blueprint)) + implode(', ', $tableStructure) ); } @@ -96,18 +257,17 @@ protected function compileCreateTable($blueprint, $command, $connection) * Append the character set specifications to a command. * * @param string $sql - * @param \Illuminate\Database\Connection $connection * @param \Illuminate\Database\Schema\Blueprint $blueprint * @return string */ - protected function compileCreateEncoding($sql, Connection $connection, Blueprint $blueprint) + protected function compileCreateEncoding($sql, Blueprint $blueprint) { // First we will set the character set if one has been set on either the create // blueprint itself or on the root configuration for the connection that the // table is being created on. We will add these to the create table query. if (isset($blueprint->charset)) { $sql .= ' default character set '.$blueprint->charset; - } elseif (! is_null($charset = $connection->getConfig('charset'))) { + } elseif (! is_null($charset = $this->connection->getConfig('charset'))) { $sql .= ' default character set '.$charset; } @@ -116,7 +276,7 @@ protected function compileCreateEncoding($sql, Connection $connection, Blueprint // connection that the query is targeting. We'll add it to this SQL query. if (isset($blueprint->collation)) { $sql .= " collate '{$blueprint->collation}'"; - } elseif (! is_null($collation = $connection->getConfig('collation'))) { + } elseif (! is_null($collation = $this->connection->getConfig('collation'))) { $sql .= " collate '{$collation}'"; } @@ -127,15 +287,14 @@ protected function compileCreateEncoding($sql, Connection $connection, Blueprint * Append the engine specifications to a command. * * @param string $sql - * @param \Illuminate\Database\Connection $connection * @param \Illuminate\Database\Schema\Blueprint $blueprint * @return string */ - protected function compileCreateEngine($sql, Connection $connection, Blueprint $blueprint) + protected function compileCreateEngine($sql, Blueprint $blueprint) { if (isset($blueprint->engine)) { return $sql.' engine = '.$blueprint->engine; - } elseif (! is_null($engine = $connection->getConfig('engine'))) { + } elseif (! is_null($engine = $this->connection->getConfig('engine'))) { return $sql.' engine = '.$engine; } @@ -151,9 +310,112 @@ protected function compileCreateEngine($sql, Connection $connection, Blueprint $ */ public function compileAdd(Blueprint $blueprint, Fluent $command) { - $columns = $this->prefixArray('add', $this->getColumns($blueprint)); + return sprintf('alter table %s add %s%s%s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column), + $command->column->instant ? ', algorithm=instant' : '', + $command->column->lock ? ', lock='.$command->column->lock : '' + ); + } - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + /** + * Compile the auto-incrementing column starting values. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command) + { + if ($command->column->autoIncrement + && $value = $command->column->get('startingValue', $command->column->get('from'))) { + return 'alter table '.$this->wrapTable($blueprint).' auto_increment = '.$value; + } + } + + /** @inheritDoc */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command) + { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if (($isMaria && version_compare($version, '10.5.2', '<')) || + (! $isMaria && version_compare($version, '8.0.3', '<'))) { + return $this->compileLegacyRenameColumn($blueprint, $command); + } + + return parent::compileRenameColumn($blueprint, $command); + } + + /** + * Compile a rename column command for legacy versions of MySQL. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + protected function compileLegacyRenameColumn(Blueprint $blueprint, Fluent $command) + { + $column = (new Collection($this->connection->getSchemaBuilder()->getColumns($blueprint->getTable()))) + ->firstWhere('name', $command->from); + + $modifiers = $this->addModifiers($column['type'], $blueprint, new ColumnDefinition([ + 'change' => true, + 'type' => match ($column['type_name']) { + 'bigint' => 'bigInteger', + 'int' => 'integer', + 'mediumint' => 'mediumInteger', + 'smallint' => 'smallInteger', + 'tinyint' => 'tinyInteger', + default => $column['type_name'], + }, + 'nullable' => $column['nullable'], + 'default' => $column['default'] && (str_starts_with(strtolower($column['default']), 'current_timestamp') || $column['default'] === 'NULL') + ? new Expression($column['default']) + : $column['default'], + 'autoIncrement' => $column['auto_increment'], + 'collation' => $column['collation'], + 'comment' => $column['comment'], + 'virtualAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'virtual' + ? $column['generation']['expression'] + : null, + 'storedAs' => ! is_null($column['generation']) && $column['generation']['type'] === 'stored' + ? $column['generation']['expression'] + : null, + ])); + + return sprintf('alter table %s change %s %s %s', + $this->wrapTable($blueprint), + $this->wrap($command->from), + $this->wrap($command->to), + $modifiers + ); + } + + /** @inheritDoc */ + public function compileChange(Blueprint $blueprint, Fluent $command) + { + $column = $command->column; + + $sql = sprintf('alter table %s %s %s%s %s', + $this->wrapTable($blueprint), + is_null($column->renameTo) ? 'modify' : 'change', + $this->wrap($column), + is_null($column->renameTo) ? '' : ' '.$this->wrap($column->renameTo), + $this->getType($column) + ); + + $sql = $this->addModifiers($sql, $blueprint, $column); + + if ($column->instant) { + $sql .= ', algorithm=instant'; + } + + if ($column->lock) { + $sql .= ', lock='.$column->lock; + } + + return $sql; } /** @@ -165,9 +427,12 @@ public function compileAdd(Blueprint $blueprint, Fluent $command) */ public function compilePrimary(Blueprint $blueprint, Fluent $command) { - $command->name(null); - - return $this->compileKey($blueprint, $command, 'primary key'); + return sprintf('alter table %s add primary key %s(%s)%s', + $this->wrapTable($blueprint), + $command->algorithm ? 'using '.$command->algorithm : '', + $this->columnize($command->columns), + $command->lock ? ', lock='.$command->lock : '' + ); } /** @@ -194,6 +459,18 @@ public function compileIndex(Blueprint $blueprint, Fluent $command) return $this->compileKey($blueprint, $command, 'index'); } + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileKey($blueprint, $command, 'fulltext'); + } + /** * Compile a spatial index key command. * @@ -216,12 +493,13 @@ public function compileSpatialIndex(Blueprint $blueprint, Fluent $command) */ protected function compileKey(Blueprint $blueprint, Fluent $command, $type) { - return sprintf('alter table %s add %s %s%s(%s)', + return sprintf('alter table %s add %s %s%s(%s)%s', $this->wrapTable($blueprint), $type, $this->wrap($command->index), $command->algorithm ? ' using '.$command->algorithm : '', - $this->columnize($command->columns) + $this->columnize($command->columns), + $command->lock ? ', lock='.$command->lock : '' ); } @@ -260,7 +538,17 @@ public function compileDropColumn(Blueprint $blueprint, Fluent $command) { $columns = $this->prefixArray('drop', $this->wrapArray($command->columns)); - return 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + $sql = 'alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns); + + if ($command->instant) { + $sql .= ', algorithm=instant'; + } + + if ($command->lock) { + $sql .= ', lock='.$command->lock; + } + + return $sql; } /** @@ -303,6 +591,18 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command) return "alter table {$this->wrapTable($blueprint)} drop index {$index}"; } + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileDropIndex($blueprint, $command); + } + /** * Compile a drop spatial index command. * @@ -315,6 +615,24 @@ public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command) return $this->compileDropIndex($blueprint, $command); } + /** + * Compile a foreign key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileForeign(Blueprint $blueprint, Fluent $command) + { + $sql = parent::compileForeign($blueprint, $command); + + if ($command->lock) { + $sql .= ', lock='.$command->lock; + } + + return $sql; + } + /** * Compile a drop foreign key command. * @@ -346,8 +664,8 @@ public function compileRename(Blueprint $blueprint, Fluent $command) /** * Compile a rename index command. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command * @return string */ public function compileRenameIndex(Blueprint $blueprint, Fluent $command) @@ -362,63 +680,72 @@ public function compileRenameIndex(Blueprint $blueprint, Fluent $command) /** * Compile the SQL needed to drop all tables. * - * @param array $tables + * @param array $tables * @return string */ public function compileDropAllTables($tables) { - return 'drop table '.implode(',', $this->wrapArray($tables)); + return 'drop table '.implode(', ', $this->escapeNames($tables)); } /** * Compile the SQL needed to drop all views. * - * @param array $views + * @param array $views * @return string */ public function compileDropAllViews($views) { - return 'drop view '.implode(',', $this->wrapArray($views)); + return 'drop view '.implode(', ', $this->escapeNames($views)); } /** - * Compile the SQL needed to retrieve all table names. + * Compile the command to enable foreign key constraints. * * @return string */ - public function compileGetAllTables() + public function compileEnableForeignKeyConstraints() { - return 'SHOW FULL TABLES WHERE table_type = \'BASE TABLE\''; + return 'SET FOREIGN_KEY_CHECKS=1;'; } /** - * Compile the SQL needed to retrieve all view names. + * Compile the command to disable foreign key constraints. * * @return string */ - public function compileGetAllViews() + public function compileDisableForeignKeyConstraints() { - return 'SHOW FULL TABLES WHERE table_type = \'VIEW\''; + return 'SET FOREIGN_KEY_CHECKS=0;'; } /** - * Compile the command to enable foreign key constraints. + * Compile a table comment command. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command * @return string */ - public function compileEnableForeignKeyConstraints() + public function compileTableComment(Blueprint $blueprint, Fluent $command) { - return 'SET FOREIGN_KEY_CHECKS=1;'; + return sprintf('alter table %s comment = %s', + $this->wrapTable($blueprint), + "'".str_replace("'", "''", $command->comment)."'" + ); } /** - * Compile the command to disable foreign key constraints. + * Quote-escape the given tables, views, or types. * - * @return string + * @param array $names + * @return array */ - public function compileDisableForeignKeyConstraints() + public function escapeNames($names) { - return 'SET FOREIGN_KEY_CHECKS=0;'; + return array_map( + fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + $names + ); } /** @@ -443,6 +770,17 @@ protected function typeString(Fluent $column) return "varchar({$column->length})"; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'tinytext'; + } + /** * Create the column definition for a text type. * @@ -539,7 +877,11 @@ protected function typeSmallInteger(Fluent $column) */ protected function typeFloat(Fluent $column) { - return $this->typeDouble($column); + if ($column->precision) { + return "float({$column->precision})"; + } + + return 'float'; } /** @@ -550,10 +892,6 @@ protected function typeFloat(Fluent $column) */ protected function typeDouble(Fluent $column) { - if ($column->total && $column->places) { - return "double({$column->total}, {$column->places})"; - } - return 'double'; } @@ -590,6 +928,17 @@ protected function typeEnum(Fluent $column) return sprintf('enum(%s)', $this->quoteString($column->allowed)); } + /** + * Create the column definition for a set enumeration type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeSet(Fluent $column) + { + return sprintf('set(%s)', $this->quoteString($column->allowed)); + } + /** * Create the column definition for a json type. * @@ -620,6 +969,16 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if ($isMaria || + (! $isMaria && version_compare($version, '8.0.13', '>='))) { + if ($column->useCurrent) { + $column->default(new Expression('(CURDATE())')); + } + } + return 'date'; } @@ -631,9 +990,17 @@ protected function typeDate(Fluent $column) */ protected function typeDateTime(Fluent $column) { - $columnType = $column->precision ? "datetime($column->precision)" : 'datetime'; + $current = $column->precision ? "CURRENT_TIMESTAMP($column->precision)" : 'CURRENT_TIMESTAMP'; + + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + return $column->precision ? "datetime($column->precision)" : 'datetime'; } /** @@ -677,9 +1044,17 @@ protected function typeTimeTz(Fluent $column) */ protected function typeTimestamp(Fluent $column) { - $columnType = $column->precision ? "timestamp($column->precision)" : 'timestamp'; + $current = $column->precision ? "CURRENT_TIMESTAMP($column->precision)" : 'CURRENT_TIMESTAMP'; - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } + + return $column->precision ? "timestamp($column->precision)" : 'timestamp'; } /** @@ -701,6 +1076,16 @@ protected function typeTimestampTz(Fluent $column) */ protected function typeYear(Fluent $column) { + $isMaria = $this->connection->isMaria(); + $version = $this->connection->getServerVersion(); + + if ($isMaria || + (! $isMaria && version_compare($version, '8.0.13', '>='))) { + if ($column->useCurrent) { + $column->default(new Expression('(YEAR(CURDATE()))')); + } + } + return 'year'; } @@ -712,6 +1097,10 @@ protected function typeYear(Fluent $column) */ protected function typeBinary(Fluent $column) { + if ($column->length) { + return $column->fixed ? "binary({$column->length})" : "varbinary({$column->length})"; + } + return 'blob'; } @@ -754,99 +1143,59 @@ protected function typeMacAddress(Fluent $column) * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeGeometry(Fluent $column) + protected function typeGeometry(Fluent $column) { - return 'geometry'; - } - - /** - * Create the column definition for a spatial Point type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePoint(Fluent $column) - { - return 'point'; - } + $subtype = $column->subtype ? strtolower($column->subtype) : null; - /** - * Create the column definition for a spatial LineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeLineString(Fluent $column) - { - return 'linestring'; - } + if (! in_array($subtype, ['point', 'linestring', 'polygon', 'geometrycollection', 'multipoint', 'multilinestring', 'multipolygon'])) { + $subtype = null; + } - /** - * Create the column definition for a spatial Polygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePolygon(Fluent $column) - { - return 'polygon'; + return sprintf('%s%s', + $subtype ?? 'geometry', + match (true) { + $column->srid && $this->connection->isMaria() => ' ref_system_id='.$column->srid, + (bool) $column->srid => ' srid '.$column->srid, + default => '', + } + ); } /** - * Create the column definition for a spatial GeometryCollection type. + * Create the column definition for a spatial Geography type. * * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeGeometryCollection(Fluent $column) + protected function typeGeography(Fluent $column) { - return 'geometrycollection'; + return $this->typeGeometry($column); } /** - * Create the column definition for a spatial MultiPoint type. + * Create the column definition for a generated, computed column type. * * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPoint(Fluent $column) - { - return 'multipoint'; - } - - /** - * Create the column definition for a spatial MultiLineString type. + * @return void * - * @param \Illuminate\Support\Fluent $column - * @return string + * @throws \RuntimeException */ - public function typeMultiLineString(Fluent $column) + protected function typeComputed(Fluent $column) { - return 'multilinestring'; + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); } /** - * Create the column definition for a spatial MultiPolygon type. + * Create the column definition for a vector type. * * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeMultiPolygon(Fluent $column) - { - return 'multipolygon'; - } - - /** - * Create the column definition for a generated, computed column type. - * - * @param \Illuminate\Support\Fluent $column - * @return void - * - * @throws \RuntimeException - */ - protected function typeComputed(Fluent $column) + protected function typeVector(Fluent $column) { - throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); + return isset($column->dimensions) && $column->dimensions !== '' + ? "vector({$column->dimensions})" + : 'vector'; } /** @@ -858,8 +1207,16 @@ protected function typeComputed(Fluent $column) */ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->virtualAs)) { - return " as ({$column->virtualAs})"; + if (! is_null($virtualAs = $column->virtualAsJson)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } + + return " as ({$virtualAs})"; + } + + if (! is_null($virtualAs = $column->virtualAs)) { + return " as ({$this->getValue($virtualAs)})"; } } @@ -872,8 +1229,16 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) */ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->storedAs)) { - return " as ({$column->storedAs}) stored"; + if (! is_null($storedAs = $column->storedAsJson)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; + } + + if (! is_null($storedAs = $column->storedAs)) { + return " as ({$this->getValue($storedAs)}) stored"; } } @@ -928,9 +1293,30 @@ protected function modifyCollate(Blueprint $blueprint, Fluent $column) */ protected function modifyNullable(Blueprint $blueprint, Fluent $column) { - if (is_null($column->virtualAs) && is_null($column->storedAs)) { + if (is_null($column->virtualAs) && + is_null($column->virtualAsJson) && + is_null($column->storedAs) && + is_null($column->storedAsJson)) { return $column->nullable ? ' null' : ' not null'; } + + if ($column->nullable === false) { + return ' not null'; + } + } + + /** + * Get the SQL for an invisible column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyInvisible(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->invisible)) { + return ' invisible'; + } } /** @@ -947,6 +1333,20 @@ protected function modifyDefault(Blueprint $blueprint, Fluent $column) } } + /** + * Get the SQL for an "on update" column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyOnUpdate(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->onUpdate)) { + return ' on update '.$this->getValue($column->onUpdate); + } + } + /** * Get the SQL for an auto-increment column modifier. * @@ -957,7 +1357,9 @@ protected function modifyDefault(Blueprint $blueprint, Fluent $column) protected function modifyIncrement(Blueprint $blueprint, Fluent $column) { if (in_array($column->type, $this->serials) && $column->autoIncrement) { - return ' auto_increment primary key'; + return $this->hasCommand($blueprint, 'primary') || ($column->change && ! $column->primary) + ? ' auto_increment' + : ' auto_increment primary key'; } } @@ -1004,31 +1406,30 @@ protected function modifyComment(Blueprint $blueprint, Fluent $column) } /** - * Get the SQL for a SRID column modifier. + * Wrap a single string in keyword identifiers. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $column - * @return string|null + * @param string $value + * @return string */ - protected function modifySrid(Blueprint $blueprint, Fluent $column) + protected function wrapValue($value) { - if (! is_null($column->srid) && is_int($column->srid) && $column->srid > 0) { - return ' srid '.$column->srid; + if ($value !== '*') { + return '`'.str_replace('`', '``', $value).'`'; } + + return $value; } /** - * Wrap a single string in keyword identifiers. + * Wrap the given JSON selector. * * @param string $value * @return string */ - protected function wrapValue($value) + protected function wrapJsonSelector($value) { - if ($value !== '*') { - return '`'.str_replace('`', '``', $value).'`'; - } + [$field, $path] = $this->wrapJsonFieldAndPath($value); - return $value; + return 'json_unquote(json_extract('.$field.$path.'))'; } } diff --git a/Schema/Grammars/PostgresGrammar.php b/Schema/Grammars/PostgresGrammar.php index 7233e07003..4a1660a20e 100755 --- a/Schema/Grammars/PostgresGrammar.php +++ b/Schema/Grammars/PostgresGrammar.php @@ -2,8 +2,11 @@ namespace Illuminate\Database\Schema\Grammars; -use Illuminate\Support\Fluent; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Collection; +use Illuminate\Support\Fluent; +use LogicException; class PostgresGrammar extends Grammar { @@ -17,42 +20,211 @@ class PostgresGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ - protected $modifiers = ['Increment', 'Nullable', 'Default']; + protected $modifiers = ['Collate', 'Nullable', 'Default', 'VirtualAs', 'StoredAs', 'GeneratedAs', 'Increment']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; /** * The commands to be executed outside of create or alter command. * - * @var array + * @var string[] + */ + protected $fluentCommands = ['AutoIncrementStartingValues', 'Comment']; + + /** + * Compile a create database command. + * + * @param string $name + * @return string + */ + public function compileCreateDatabase($name) + { + $sql = parent::compileCreateDatabase($name); + + if ($charset = $this->connection->getConfig('charset')) { + $sql .= sprintf(' encoding %s', $this->wrapValue($charset)); + } + + return $sql; + } + + /** + * Compile the query to determine the schemas. + * + * @return string + */ + public function compileSchemas() + { + return 'select nspname as name, nspname = current_schema() as "default" from pg_namespace where ' + .$this->compileSchemaWhereClause(null, 'nspname') + .' order by nspname'; + } + + /** + * Compile the query to determine if the given table exists. + * + * @param string|null $schema + * @param string $table + * @return string */ - protected $fluentCommands = ['Comment']; + public function compileTableExists($schema, $table) + { + return sprintf( + 'select exists (select 1 from pg_class c, pg_namespace n where ' + ."n.nspname = %s and c.relname = %s and c.relkind in ('r', 'p') and n.oid = c.relnamespace)", + $schema ? $this->quoteString($schema) : 'current_schema()', + $this->quoteString($table) + ); + } /** - * Compile the query to determine if a table exists. + * Compile the query to determine the tables. * + * @param string|string[]|null $schema * @return string */ - public function compileTableExists() + public function compileTables($schema) { - return 'select * from information_schema.tables where table_schema = ? and table_name = ?'; + return 'select c.relname as name, n.nspname as schema, pg_total_relation_size(c.oid) as size, ' + ."obj_description(c.oid, 'pg_class') as comment from pg_class c, pg_namespace n " + ."where c.relkind in ('r', 'p') and n.oid = c.relnamespace and " + .$this->compileSchemaWhereClause($schema, 'n.nspname') + .' order by n.nspname, c.relname'; } /** - * Compile the query to determine the list of columns. + * Compile the query to determine the views. * + * @param string|string[]|null $schema * @return string */ - public function compileColumnListing() + public function compileViews($schema) { - return 'select column_name from information_schema.columns where table_schema = ? and table_name = ?'; + return 'select viewname as name, schemaname as schema, definition from pg_views where ' + .$this->compileSchemaWhereClause($schema, 'schemaname') + .' order by schemaname, viewname'; + } + + /** + * Compile the query to determine the user-defined types. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileTypes($schema) + { + return 'select t.typname as name, n.nspname as schema, t.typtype as type, t.typcategory as category, ' + ."((t.typinput = 'array_in'::regproc and t.typoutput = 'array_out'::regproc) or t.typtype = 'm') as implicit " + .'from pg_type t join pg_namespace n on n.oid = t.typnamespace ' + .'left join pg_class c on c.oid = t.typrelid ' + .'left join pg_type el on el.oid = t.typelem ' + .'left join pg_class ce on ce.oid = el.typrelid ' + ."where ((t.typrelid = 0 and (ce.relkind = 'c' or ce.relkind is null)) or c.relkind = 'c') " + ."and not exists (select 1 from pg_depend d where d.objid in (t.oid, t.typelem) and d.deptype = 'e') and " + .$this->compileSchemaWhereClause($schema, 'n.nspname'); + } + + /** + * Compile the query to compare the schema. + * + * @param string|string[]|null $schema + * @param string $column + * @return string + */ + protected function compileSchemaWhereClause($schema, $column) + { + return $column.(match (true) { + ! empty($schema) && is_array($schema) => ' in ('.$this->quoteString($schema).')', + ! empty($schema) => ' = '.$this->quoteString($schema), + default => " <> 'information_schema' and $column not like 'pg\_%'", + }); + } + + /** + * Compile the query to determine the columns. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileColumns($schema, $table) + { + return sprintf( + 'select a.attname as name, t.typname as type_name, format_type(a.atttypid, a.atttypmod) as type, ' + .'(select tc.collcollate from pg_catalog.pg_collation tc where tc.oid = a.attcollation) as collation, ' + .'not a.attnotnull as nullable, ' + .'(select pg_get_expr(adbin, adrelid) from pg_attrdef where c.oid = pg_attrdef.adrelid and pg_attrdef.adnum = a.attnum) as default, ' + .(version_compare($this->connection->getServerVersion(), '12.0', '<') ? "'' as generated, " : 'a.attgenerated as generated, ') + .'col_description(c.oid, a.attnum) as comment ' + .'from pg_attribute a, pg_class c, pg_type t, pg_namespace n ' + .'where c.relname = %s and n.nspname = %s and a.attnum > 0 and a.attrelid = c.oid and a.atttypid = t.oid and n.oid = c.relnamespace ' + .'order by a.attnum', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); + } + + /** + * Compile the query to determine the indexes. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileIndexes($schema, $table) + { + return sprintf( + "select ic.relname as name, string_agg(a.attname, ',' order by indseq.ord) as columns, " + .'am.amname as "type", i.indisunique as "unique", i.indisprimary as "primary" ' + .'from pg_index i ' + .'join pg_class tc on tc.oid = i.indrelid ' + .'join pg_namespace tn on tn.oid = tc.relnamespace ' + .'join pg_class ic on ic.oid = i.indexrelid ' + .'join pg_am am on am.oid = ic.relam ' + .'join lateral unnest(i.indkey) with ordinality as indseq(num, ord) on true ' + .'left join pg_attribute a on a.attrelid = i.indrelid and a.attnum = indseq.num ' + .'where tc.relname = %s and tn.nspname = %s ' + .'group by ic.relname, am.amname, i.indisunique, i.indisprimary', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); + } + + /** + * Compile the query to determine the foreign keys. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileForeignKeys($schema, $table) + { + return sprintf( + 'select c.conname as name, ' + ."string_agg(la.attname, ',' order by conseq.ord) as columns, " + .'fn.nspname as foreign_schema, fc.relname as foreign_table, ' + ."string_agg(fa.attname, ',' order by conseq.ord) as foreign_columns, " + .'c.confupdtype as on_update, c.confdeltype as on_delete ' + .'from pg_constraint c ' + .'join pg_class tc on c.conrelid = tc.oid ' + .'join pg_namespace tn on tn.oid = tc.relnamespace ' + .'join pg_class fc on c.confrelid = fc.oid ' + .'join pg_namespace fn on fn.oid = fc.relnamespace ' + .'join lateral unnest(c.conkey) with ordinality as conseq(num, ord) on true ' + .'join pg_attribute la on la.attrelid = c.conrelid and la.attnum = conseq.num ' + .'join pg_attribute fa on fa.attrelid = c.confrelid and fa.attnum = c.confkey[conseq.ord] ' + ."where c.contype = 'f' and tc.relname = %s and tn.nspname = %s " + .'group by c.conname, fn.nspname, fc.relname, c.confupdtype, c.confdeltype', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'current_schema()' + ); } /** @@ -80,9 +252,56 @@ public function compileCreate(Blueprint $blueprint, Fluent $command) */ public function compileAdd(Blueprint $blueprint, Fluent $command) { + return sprintf('alter table %s add column %s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column) + ); + } + + /** + * Compile the auto-incrementing column starting values. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command) + { + if ($command->column->autoIncrement + && $value = $command->column->get('startingValue', $command->column->get('from'))) { + return sprintf( + 'select setval(pg_get_serial_sequence(%s, %s), %s, false)', + $this->quoteString($this->wrapTable($blueprint)), + $this->quoteString($command->column->name), + $value + ); + } + } + + /** @inheritDoc */ + public function compileChange(Blueprint $blueprint, Fluent $command) + { + $column = $command->column; + + $changes = ['type '.$this->getType($column).$this->modifyCollate($blueprint, $column)]; + + foreach ($this->modifiers as $modifier) { + if ($modifier === 'Collate') { + continue; + } + + if (method_exists($this, $method = "modify{$modifier}")) { + $constraints = (array) $this->{$method}($blueprint, $column); + + foreach ($constraints as $constraint) { + $changes[] = $constraint; + } + } + } + return sprintf('alter table %s %s', $this->wrapTable($blueprint), - implode(', ', $this->prefixArray('add column', $this->getColumns($blueprint))) + implode(', ', $this->prefixArray('alter column '.$this->wrap($column), $changes)) ); } @@ -105,15 +324,49 @@ public function compilePrimary(Blueprint $blueprint, Fluent $command) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return string + * @return string[] */ public function compileUnique(Blueprint $blueprint, Fluent $command) { - return sprintf('alter table %s add constraint %s unique (%s)', - $this->wrapTable($blueprint), - $this->wrap($command->index), - $this->columnize($command->columns) - ); + $uniqueStatement = 'unique'; + + if (! is_null($command->nullsNotDistinct)) { + $uniqueStatement .= ' nulls '.($command->nullsNotDistinct ? 'not distinct' : 'distinct'); + } + + if ($command->online || $command->algorithm) { + $createIndexSql = sprintf('create unique index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $command->algorithm ? ' using '.$command->algorithm : '', + $this->columnize($command->columns) + ); + + $sql = sprintf('alter table %s add constraint %s unique using index %s', + $this->wrapTable($blueprint), + $this->wrap($command->index), + $this->wrap($command->index) + ); + } else { + $sql = sprintf( + 'alter table %s add constraint %s %s (%s)', + $this->wrapTable($blueprint), + $this->wrap($command->index), + $uniqueStatement, + $this->columnize($command->columns) + ); + } + + if (! is_null($command->deferrable)) { + $sql .= $command->deferrable ? ' deferrable' : ' not deferrable'; + } + + if ($command->deferrable && ! is_null($command->initiallyImmediate)) { + $sql .= $command->initiallyImmediate ? ' initially immediate' : ' initially deferred'; + } + + return isset($createIndexSql) ? [$createIndexSql, $sql] : [$sql]; } /** @@ -125,7 +378,8 @@ public function compileUnique(Blueprint $blueprint, Fluent $command) */ public function compileIndex(Blueprint $blueprint, Fluent $command) { - return sprintf('create index %s on %s%s (%s)', + return sprintf('create index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', $this->wrap($command->index), $this->wrapTable($blueprint), $command->algorithm ? ' using '.$command->algorithm : '', @@ -133,6 +387,31 @@ public function compileIndex(Blueprint $blueprint, Fluent $command) ); } + /** + * Compile a fulltext index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + * + * @throws \RuntimeException + */ + public function compileFulltext(Blueprint $blueprint, Fluent $command) + { + $language = $command->language ?: 'english'; + + $columns = array_map(function ($column) use ($language) { + return "to_tsvector({$this->quoteString($language)}, {$this->wrap($column)})"; + }, $command->columns); + + return sprintf('create index %s%s on %s using gin ((%s))', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + implode(' || ', $columns) + ); + } + /** * Compile a spatial index key command. * @@ -144,9 +423,59 @@ public function compileSpatialIndex(Blueprint $blueprint, Fluent $command) { $command->algorithm = 'gist'; + if (! is_null($command->operatorClass)) { + return $this->compileIndexWithOperatorClass($blueprint, $command); + } + return $this->compileIndex($blueprint, $command); } + /** + * Compile a vector index key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileVectorIndex(Blueprint $blueprint, Fluent $command) + { + return $this->compileIndexWithOperatorClass($blueprint, $command); + } + + /** + * Compile a spatial index with operator class key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + protected function compileIndexWithOperatorClass(Blueprint $blueprint, Fluent $command) + { + $columns = $this->columnizeWithOperatorClass($command->columns, $command->operatorClass); + + return sprintf('create index %s%s on %s%s (%s)', + $command->online ? 'concurrently ' : '', + $this->wrap($command->index), + $this->wrapTable($blueprint), + $command->algorithm ? ' using '.$command->algorithm : '', + $columns + ); + } + + /** + * Convert an array of column names to a delimited string with operator class. + * + * @param array $columns + * @param string $operatorClass + * @return string + */ + protected function columnizeWithOperatorClass(array $columns, $operatorClass) + { + return implode(', ', array_map(function ($column) use ($operatorClass) { + return $this->wrap($column).' '.$operatorClass; + }, $columns)); + } + /** * Compile a foreign key command. * @@ -200,45 +529,45 @@ public function compileDropIfExists(Blueprint $blueprint, Fluent $command) /** * Compile the SQL needed to drop all tables. * - * @param string $tables + * @param array $tables * @return string */ public function compileDropAllTables($tables) { - return 'drop table "'.implode('","', $tables).'" cascade'; + return 'drop table '.implode(', ', $this->escapeNames($tables)).' cascade'; } /** * Compile the SQL needed to drop all views. * - * @param string $views + * @param array $views * @return string */ public function compileDropAllViews($views) { - return 'drop view "'.implode('","', $views).'" cascade'; + return 'drop view '.implode(', ', $this->escapeNames($views)).' cascade'; } /** - * Compile the SQL needed to retrieve all table names. + * Compile the SQL needed to drop all types. * - * @param string $schema + * @param array $types * @return string */ - public function compileGetAllTables($schema) + public function compileDropAllTypes($types) { - return "select tablename from pg_catalog.pg_tables where schemaname = '{$schema}'"; + return 'drop type '.implode(', ', $this->escapeNames($types)).' cascade'; } /** - * Compile the SQL needed to retrieve all view names. + * Compile the SQL needed to drop all domains. * - * @param string $schema + * @param array $domains * @return string */ - public function compileGetAllViews($schema) + public function compileDropAllDomains($domains) { - return "select viewname from pg_catalog.pg_views where schemaname = '{$schema}'"; + return 'drop domain '.implode(', ', $this->escapeNames($domains)).' cascade'; } /** @@ -264,7 +593,8 @@ public function compileDropColumn(Blueprint $blueprint, Fluent $command) */ public function compileDropPrimary(Blueprint $blueprint, Fluent $command) { - $index = $this->wrap("{$blueprint->getTable()}_pkey"); + [, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + $index = $this->wrap("{$this->connection->getTablePrefix()}{$table}_pkey"); return 'alter table '.$this->wrapTable($blueprint)." drop constraint {$index}"; } @@ -295,6 +625,18 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command) return "drop index {$this->wrap($command->index)}"; } + /** + * Compile a drop fulltext index command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropFullText(Blueprint $blueprint, Fluent $command) + { + return $this->compileDropIndex($blueprint, $command); + } + /** * Compile a drop spatial index command. * @@ -338,8 +680,8 @@ public function compileRename(Blueprint $blueprint, Fluent $command) /** * Compile a rename index command. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command * @return string */ public function compileRenameIndex(Blueprint $blueprint, Fluent $command) @@ -379,10 +721,41 @@ public function compileDisableForeignKeyConstraints() */ public function compileComment(Blueprint $blueprint, Fluent $command) { - return sprintf('comment on column %s.%s is %s', + if (! is_null($comment = $command->column->comment) || $command->column->change) { + return sprintf('comment on column %s.%s is %s', + $this->wrapTable($blueprint), + $this->wrap($command->column->name), + is_null($comment) ? 'NULL' : "'".str_replace("'", "''", $comment)."'" + ); + } + } + + /** + * Compile a table comment command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileTableComment(Blueprint $blueprint, Fluent $command) + { + return sprintf('comment on table %s is %s', $this->wrapTable($blueprint), - $this->wrap($command->column->name), - "'".str_replace("'", "''", $command->value)."'" + "'".str_replace("'", "''", $command->comment)."'" + ); + } + + /** + * Quote-escape the given tables, views, or types. + * + * @param array $names + * @return array + */ + public function escapeNames($names) + { + return array_map( + fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + $names ); } @@ -394,7 +767,11 @@ public function compileComment(Blueprint $blueprint, Fluent $command) */ protected function typeChar(Fluent $column) { - return "char({$column->length})"; + if ($column->length) { + return "char({$column->length})"; + } + + return 'char'; } /** @@ -405,7 +782,22 @@ protected function typeChar(Fluent $column) */ protected function typeString(Fluent $column) { - return "varchar({$column->length})"; + if ($column->length) { + return "varchar({$column->length})"; + } + + return 'varchar'; + } + + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'varchar(255)'; } /** @@ -449,7 +841,7 @@ protected function typeLongText(Fluent $column) */ protected function typeInteger(Fluent $column) { - return $this->generatableColumn('integer', $column); + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'serial' : 'integer'; } /** @@ -460,7 +852,7 @@ protected function typeInteger(Fluent $column) */ protected function typeBigInteger(Fluent $column) { - return $this->generatableColumn('bigint', $column); + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'bigserial' : 'bigint'; } /** @@ -471,7 +863,7 @@ protected function typeBigInteger(Fluent $column) */ protected function typeMediumInteger(Fluent $column) { - return $this->generatableColumn('integer', $column); + return $this->typeInteger($column); } /** @@ -482,7 +874,7 @@ protected function typeMediumInteger(Fluent $column) */ protected function typeTinyInteger(Fluent $column) { - return $this->generatableColumn('smallint', $column); + return $this->typeSmallInteger($column); } /** @@ -493,42 +885,7 @@ protected function typeTinyInteger(Fluent $column) */ protected function typeSmallInteger(Fluent $column) { - return $this->generatableColumn('smallint', $column); - } - - /** - * Create the column definition for a generatable column. - * - * @param string $type - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function generatableColumn($type, Fluent $column) - { - if (! $column->autoIncrement && is_null($column->generatedAs)) { - return $type; - } - - if ($column->autoIncrement && is_null($column->generatedAs)) { - return with([ - 'integer' => 'serial', - 'bigint' => 'bigserial', - 'smallint' => 'smallserial', - ])[$type]; - } - - $options = ''; - - if (! is_bool($column->generatedAs) && ! empty($column->generatedAs)) { - $options = sprintf(' (%s)', $column->generatedAs); - } - - return sprintf( - '%s generated %s as identity%s', - $type, - $column->always ? 'always' : 'by default', - $options - ); + return $column->autoIncrement && is_null($column->generatedAs) && ! $column->change ? 'smallserial' : 'smallint'; } /** @@ -539,7 +896,11 @@ protected function generatableColumn($type, Fluent $column) */ protected function typeFloat(Fluent $column) { - return $this->typeDouble($column); + if ($column->precision) { + return "float({$column->precision})"; + } + + return 'float'; } /** @@ -631,6 +992,10 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + return 'date'; } @@ -664,7 +1029,7 @@ protected function typeDateTimeTz(Fluent $column) */ protected function typeTime(Fluent $column) { - return "time($column->precision) without time zone"; + return 'time'.(is_null($column->precision) ? '' : "($column->precision)").' without time zone'; } /** @@ -675,7 +1040,7 @@ protected function typeTime(Fluent $column) */ protected function typeTimeTz(Fluent $column) { - return "time($column->precision) with time zone"; + return 'time'.(is_null($column->precision) ? '' : "($column->precision)").' with time zone'; } /** @@ -686,9 +1051,11 @@ protected function typeTimeTz(Fluent $column) */ protected function typeTimestamp(Fluent $column) { - $columnType = "timestamp($column->precision) without time zone"; + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + return 'timestamp'.(is_null($column->precision) ? '' : "($column->precision)").' without time zone'; } /** @@ -699,9 +1066,11 @@ protected function typeTimestamp(Fluent $column) */ protected function typeTimestampTz(Fluent $column) { - $columnType = "timestamp($column->precision) with time zone"; + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + return 'timestamp'.(is_null($column->precision) ? '' : "($column->precision)").' with time zone'; } /** @@ -712,6 +1081,10 @@ protected function typeTimestampTz(Fluent $column) */ protected function typeYear(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('EXTRACT(YEAR FROM CURRENT_DATE)')); + } + return $this->typeInteger($column); } @@ -767,134 +1140,197 @@ protected function typeMacAddress(Fluent $column) */ protected function typeGeometry(Fluent $column) { - return $this->formatPostGisType('geometry'); - } + if ($column->subtype) { + return sprintf('geometry(%s%s)', + strtolower($column->subtype), + $column->srid ? ','.$column->srid : '' + ); + } - /** - * Create the column definition for a spatial Point type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typePoint(Fluent $column) - { - return $this->formatPostGisType('point'); + return 'geometry'; } /** - * Create the column definition for a spatial LineString type. + * Create the column definition for a spatial Geography type. * * @param \Illuminate\Support\Fluent $column * @return string */ - protected function typeLineString(Fluent $column) + protected function typeGeography(Fluent $column) { - return $this->formatPostGisType('linestring'); - } + if ($column->subtype) { + return sprintf('geography(%s%s)', + strtolower($column->subtype), + $column->srid ? ','.$column->srid : '' + ); + } - /** - * Create the column definition for a spatial Polygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typePolygon(Fluent $column) - { - return $this->formatPostGisType('polygon'); + return 'geography'; } /** - * Create the column definition for a spatial GeometryCollection type. + * Create the column definition for a vector type. * * @param \Illuminate\Support\Fluent $column * @return string */ - protected function typeGeometryCollection(Fluent $column) + protected function typeVector(Fluent $column) { - return $this->formatPostGisType('geometrycollection'); + return isset($column->dimensions) && $column->dimensions !== '' + ? "vector({$column->dimensions})" + : 'vector'; } /** - * Create the column definition for a spatial MultiPoint type. + * Get the SQL for a collation column modifier. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string + * @return string|null */ - protected function typeMultiPoint(Fluent $column) + protected function modifyCollate(Blueprint $blueprint, Fluent $column) { - return $this->formatPostGisType('multipoint'); + if (! is_null($column->collation)) { + return ' collate '.$this->wrapValue($column->collation); + } } /** - * Create the column definition for a spatial MultiLineString type. + * Get the SQL for a nullable column modifier. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string + * @return string|null */ - public function typeMultiLineString(Fluent $column) + protected function modifyNullable(Blueprint $blueprint, Fluent $column) { - return $this->formatPostGisType('multilinestring'); + if ($column->change) { + return $column->nullable ? 'drop not null' : 'set not null'; + } + + return $column->nullable ? ' null' : ' not null'; } /** - * Create the column definition for a spatial MultiPolygon type. + * Get the SQL for a default column modifier. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string + * @return string|null */ - protected function typeMultiPolygon(Fluent $column) + protected function modifyDefault(Blueprint $blueprint, Fluent $column) { - return $this->formatPostGisType('multipolygon'); + if ($column->change) { + if (! $column->autoIncrement || ! is_null($column->generatedAs)) { + return is_null($column->default) ? 'drop default' : 'set default '.$this->getDefaultValue($column->default); + } + + return null; + } + + if (! is_null($column->default)) { + return ' default '.$this->getDefaultValue($column->default); + } } /** - * Format the column definition for a PostGIS spatial type. + * Get the SQL for an auto-increment column modifier. * - * @param string $type - * @return string + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null */ - private function formatPostGisType(string $type) + protected function modifyIncrement(Blueprint $blueprint, Fluent $column) { - return "geography($type, 4326)"; + if (! $column->change + && ! $this->hasCommand($blueprint, 'primary') + && (in_array($column->type, $this->serials) || ($column->generatedAs !== null)) + && $column->autoIncrement) { + return ' primary key'; + } } /** - * Get the SQL for a nullable column modifier. + * Get the SQL for a generated virtual column modifier. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column * @return string|null + * + * @throws \LogicException */ - protected function modifyNullable(Blueprint $blueprint, Fluent $column) + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - return $column->nullable ? ' null' : ' not null'; + if ($column->change) { + if (array_key_exists('virtualAs', $column->getAttributes())) { + return is_null($column->virtualAs) + ? 'drop expression if exists' + : throw new LogicException('This database driver does not support modifying generated columns.'); + } + + return null; + } + + if (! is_null($column->virtualAs)) { + return " generated always as ({$this->getValue($column->virtualAs)}) virtual"; + } } /** - * Get the SQL for a default column modifier. + * Get the SQL for a generated stored column modifier. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column * @return string|null + * + * @throws \LogicException */ - protected function modifyDefault(Blueprint $blueprint, Fluent $column) + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->default)) { - return ' default '.$this->getDefaultValue($column->default); + if ($column->change) { + if (array_key_exists('storedAs', $column->getAttributes())) { + return is_null($column->storedAs) + ? 'drop expression if exists' + : throw new LogicException('This database driver does not support modifying generated columns.'); + } + + return null; + } + + if (! is_null($column->storedAs)) { + return " generated always as ({$this->getValue($column->storedAs)}) stored"; } } /** - * Get the SQL for an auto-increment column modifier. + * Get the SQL for an identity column modifier. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string|null + * @return string|list|null */ - protected function modifyIncrement(Blueprint $blueprint, Fluent $column) + protected function modifyGeneratedAs(Blueprint $blueprint, Fluent $column) { - if ((in_array($column->type, $this->serials) || ($column->generatedAs !== null)) && $column->autoIncrement) { - return ' primary key'; + $sql = null; + + if (! is_null($column->generatedAs)) { + $sql = sprintf( + ' generated %s as identity%s', + $column->always ? 'always' : 'by default', + ! is_bool($column->generatedAs) && ! empty($column->generatedAs) ? " ({$column->generatedAs})" : '' + ); } + + if ($column->change) { + $changes = $column->autoIncrement && is_null($sql) ? [] : ['drop identity if exists']; + + if (! is_null($sql)) { + $changes[] = 'add '.$sql; + } + + return $changes; + } + + return $sql; } } diff --git a/Schema/Grammars/RenameColumn.php b/Schema/Grammars/RenameColumn.php deleted file mode 100644 index a07c4febb9..0000000000 --- a/Schema/Grammars/RenameColumn.php +++ /dev/null @@ -1,69 +0,0 @@ -getDoctrineColumn( - $grammar->getTablePrefix().$blueprint->getTable(), $command->from - ); - - $schema = $connection->getDoctrineSchemaManager(); - - return (array) $schema->getDatabasePlatform()->getAlterTableSQL(static::getRenamedDiff( - $grammar, $blueprint, $command, $column, $schema - )); - } - - /** - * Get a new column instance with the new column name. - * - * @param \Illuminate\Database\Schema\Grammars\Grammar $grammar - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $command - * @param \Doctrine\DBAL\Schema\Column $column - * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema - * @return \Doctrine\DBAL\Schema\TableDiff - */ - protected static function getRenamedDiff(Grammar $grammar, Blueprint $blueprint, Fluent $command, Column $column, SchemaManager $schema) - { - return static::setRenamedColumns( - $grammar->getDoctrineTableDiff($blueprint, $schema), $command, $column - ); - } - - /** - * Set the renamed columns on the table diff. - * - * @param \Doctrine\DBAL\Schema\TableDiff $tableDiff - * @param \Illuminate\Support\Fluent $command - * @param \Doctrine\DBAL\Schema\Column $column - * @return \Doctrine\DBAL\Schema\TableDiff - */ - protected static function setRenamedColumns(TableDiff $tableDiff, Fluent $command, Column $column) - { - $tableDiff->renamedColumns = [ - $command->from => new Column($command->to, $column->getType(), $column->toArray()), - ]; - - return $tableDiff; - } -} diff --git a/Schema/Grammars/SQLiteGrammar.php b/Schema/Grammars/SQLiteGrammar.php old mode 100755 new mode 100644 index ac825e7342..c472266c43 --- a/Schema/Grammars/SQLiteGrammar.php +++ b/Schema/Grammars/SQLiteGrammar.php @@ -2,48 +2,223 @@ namespace Illuminate\Database\Schema\Grammars; -use RuntimeException; +use Illuminate\Database\Query\Expression; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\IndexDefinition; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Fluent; -use Doctrine\DBAL\Schema\Index; -use Illuminate\Database\Connection; -use Illuminate\Database\Schema\Blueprint; +use RuntimeException; class SQLiteGrammar extends Grammar { /** * The possible column modifiers. * - * @var array + * @var string[] */ - protected $modifiers = ['Nullable', 'Default', 'Increment']; + protected $modifiers = ['Increment', 'Nullable', 'Default', 'Collate', 'VirtualAs', 'StoredAs']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; /** - * Compile the query to determine if a table exists. + * Get the commands to be compiled on the alter command. + * + * @return array + */ + public function getAlterCommands() + { + $alterCommands = ['change', 'primary', 'dropPrimary', 'foreign', 'dropForeign']; + + if (version_compare($this->connection->getServerVersion(), '3.35', '<')) { + $alterCommands[] = 'dropColumn'; + } + + return $alterCommands; + } + + /** + * Compile the query to determine the SQL text that describes the given object. + * + * @param string|null $schema + * @param string $name + * @param string $type + * @return string + */ + public function compileSqlCreateStatement($schema, $name, $type = 'table') + { + return sprintf('select "sql" from %s.sqlite_master where type = %s and name = %s', + $this->wrapValue($schema ?? 'main'), + $this->quoteString($type), + $this->quoteString($name) + ); + } + + /** + * Compile the query to determine if the dbstat table is available. + * + * @return string + */ + public function compileDbstatExists() + { + return "select exists (select 1 from pragma_compile_options where compile_options = 'ENABLE_DBSTAT_VTAB') as enabled"; + } + + /** + * Compile the query to determine the schemas. + * + * @return string + */ + public function compileSchemas() + { + return 'select name, file as path, name = \'main\' as "default" from pragma_database_list order by name'; + } + + /** + * Compile the query to determine if the given table exists. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileTableExists($schema, $table) + { + return sprintf( + 'select exists (select 1 from %s.sqlite_master where name = %s and type = \'table\') as "exists"', + $this->wrapValue($schema ?? 'main'), + $this->quoteString($table) + ); + } + + /** + * Compile the query to determine the tables. + * + * @param string|string[]|null $schema + * @param bool $withSize + * @return string + */ + public function compileTables($schema, $withSize = false) + { + return 'select tl.name as name, tl.schema as schema' + .($withSize ? ', (select sum(s.pgsize) ' + .'from (select tl.name as name union select il.name as name from pragma_index_list(tl.name, tl.schema) as il) as es ' + .'join dbstat(tl.schema) as s on s.name = es.name) as size' : '') + .' from pragma_table_list as tl where' + .(match (true) { + ! empty($schema) && is_array($schema) => ' tl.schema in ('.$this->quoteString($schema).') and', + ! empty($schema) => ' tl.schema = '.$this->quoteString($schema).' and', + default => '', + }) + ." tl.type in ('table', 'virtual') and tl.name not like 'sqlite\_%' escape '\' " + .'order by tl.schema, tl.name'; + } + + /** + * Compile the query for legacy versions of SQLite to determine the tables. + * + * @param string $schema + * @param bool $withSize + * @return string + */ + public function compileLegacyTables($schema, $withSize = false) + { + return $withSize + ? sprintf( + 'select m.tbl_name as name, %s as schema, sum(s.pgsize) as size from %s.sqlite_master as m ' + .'join dbstat(%s) as s on s.name = m.name ' + ."where m.type in ('table', 'index') and m.tbl_name not like 'sqlite\_%%' escape '\' " + .'group by m.tbl_name ' + .'order by m.tbl_name', + $this->quoteString($schema), + $this->wrapValue($schema), + $this->quoteString($schema) + ) + : sprintf( + 'select name, %s as schema from %s.sqlite_master ' + ."where type = 'table' and name not like 'sqlite\_%%' escape '\' order by name", + $this->quoteString($schema), + $this->wrapValue($schema) + ); + } + + /** + * Compile the query to determine the views. + * + * @param string $schema + * @return string + */ + public function compileViews($schema) + { + return sprintf( + "select name, %s as schema, sql as definition from %s.sqlite_master where type = 'view' order by name", + $this->quoteString($schema), + $this->wrapValue($schema) + ); + } + + /** + * Compile the query to determine the columns. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileColumns($schema, $table) + { + return sprintf( + 'select name, type, not "notnull" as "nullable", dflt_value as "default", pk as "primary", hidden as "extra" ' + .'from pragma_table_xinfo(%s, %s) order by cid asc', + $this->quoteString($table), + $this->quoteString($schema ?? 'main') + ); + } + + /** + * Compile the query to determine the indexes. * + * @param string|null $schema + * @param string $table * @return string */ - public function compileTableExists() + public function compileIndexes($schema, $table) { - return "select * from sqlite_master where type = 'table' and name = ?"; + return sprintf( + 'select \'primary\' as name, group_concat(col) as columns, 1 as "unique", 1 as "primary" ' + .'from (select name as col from pragma_table_xinfo(%s, %s) where pk > 0 order by pk, cid) group by name ' + .'union select name, group_concat(col) as columns, "unique", origin = \'pk\' as "primary" ' + .'from (select il.*, ii.name as col from pragma_index_list(%s, %s) il, pragma_index_info(il.name, %s) ii order by il.seq, ii.seqno) ' + .'group by name, "unique", "primary"', + $table = $this->quoteString($table), + $schema = $this->quoteString($schema ?? 'main'), + $table, + $schema, + $schema + ); } /** - * Compile the query to determine the list of columns. + * Compile the query to determine the foreign keys. * + * @param string|null $schema * @param string $table * @return string */ - public function compileColumnListing($table) + public function compileForeignKeys($schema, $table) { - return 'pragma table_info('.$this->wrap(str_replace('.', '__', $table)).')'; + return sprintf( + 'select group_concat("from") as columns, %s as foreign_schema, "table" as foreign_table, ' + .'group_concat("to") as foreign_columns, on_update, on_delete ' + .'from (select * from pragma_foreign_key_list(%s, %s) order by id desc, seq) ' + .'group by id, "table", on_update, on_delete', + $schema = $this->quoteString($schema ?? 'main'), + $this->quoteString($table), + $schema + ); } /** @@ -59,39 +234,24 @@ public function compileCreate(Blueprint $blueprint, Fluent $command) $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), implode(', ', $this->getColumns($blueprint)), - (string) $this->addForeignKeys($blueprint), - (string) $this->addPrimaryKeys($blueprint) + $this->addForeignKeys($this->getCommandsByName($blueprint, 'foreign')), + $this->addPrimaryKeys($this->getCommandByName($blueprint, 'primary')) ); } /** * Get the foreign key syntax for a table creation statement. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Database\Schema\ForeignKeyDefinition[] $foreignKeys * @return string|null */ - protected function addForeignKeys(Blueprint $blueprint) + protected function addForeignKeys($foreignKeys) { - $foreigns = $this->getCommandsByName($blueprint, 'foreign'); - - return collect($foreigns)->reduce(function ($sql, $foreign) { + return (new Collection($foreignKeys))->reduce(function ($sql, $foreign) { // Once we have all the foreign key commands for the table creation statement // we'll loop through each of them and add them to the create table SQL we // are building, since SQLite needs foreign keys on the tables creation. - $sql .= $this->getForeignKey($foreign); - - if (! is_null($foreign->onDelete)) { - $sql .= " on delete {$foreign->onDelete}"; - } - - // If this foreign key specifies the action to be taken on update we will add - // that to the statement here. We'll append it to this SQL and then return - // the SQL so we can keep adding any other foreign constraints onto this. - if (! is_null($foreign->onUpdate)) { - $sql .= " on update {$foreign->onUpdate}"; - } - - return $sql; + return $sql.$this->getForeignKey($foreign); }, ''); } @@ -106,22 +266,35 @@ protected function getForeignKey($foreign) // We need to columnize the columns that the foreign key is being defined for // so that it is a properly formatted list. Once we have done this, we can // return the foreign key SQL declaration to the calling method for use. - return sprintf(', foreign key(%s) references %s(%s)', + $sql = sprintf(', foreign key(%s) references %s(%s)', $this->columnize($foreign->columns), $this->wrapTable($foreign->on), $this->columnize((array) $foreign->references) ); + + if (! is_null($foreign->onDelete)) { + $sql .= " on delete {$foreign->onDelete}"; + } + + // If this foreign key specifies the action to be taken on update we will add + // that to the statement here. We'll append it to this SQL and then return + // this SQL so we can keep adding any other foreign constraints to this. + if (! is_null($foreign->onUpdate)) { + $sql .= " on update {$foreign->onUpdate}"; + } + + return $sql; } /** * Get the primary key syntax for a table creation statement. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent|null $primary * @return string|null */ - protected function addPrimaryKeys(Blueprint $blueprint) + protected function addPrimaryKeys($primary) { - if (! is_null($primary = $this->getCommandByName($blueprint, 'primary'))) { + if (! is_null($primary)) { return ", primary key ({$this->columnize($primary->columns)})"; } } @@ -131,15 +304,88 @@ protected function addPrimaryKeys(Blueprint $blueprint) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return array + * @return string */ public function compileAdd(Blueprint $blueprint, Fluent $command) { - $columns = $this->prefixArray('add column', $this->getColumns($blueprint)); + return sprintf('alter table %s add column %s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column) + ); + } + + /** + * Compile alter table command into a series of SQL statements. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return list|string + */ + public function compileAlter(Blueprint $blueprint, Fluent $command) + { + $columnNames = []; + $autoIncrementColumn = null; + + $columns = (new Collection($blueprint->getState()->getColumns())) + ->map(function ($column) use ($blueprint, &$columnNames, &$autoIncrementColumn) { + $name = $this->wrap($column); + + $autoIncrementColumn = $column->autoIncrement ? $column->name : $autoIncrementColumn; + + if (is_null($column->virtualAs) && is_null($column->virtualAsJson) && + is_null($column->storedAs) && is_null($column->storedAsJson)) { + $columnNames[] = $name; + } + + return $this->addModifiers( + $this->wrap($column).' '.($column->full_type_definition ?? $this->getType($column)), + $blueprint, + $column + ); + })->all(); + + $indexes = (new Collection($blueprint->getState()->getIndexes())) + ->reject(fn ($index) => str_starts_with('sqlite_', $index->index)) + ->map(fn ($index) => $this->{'compile'.ucfirst($index->name)}($blueprint, $index)) + ->all(); + + [, $tableName] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + $tempTable = $this->wrapTable($blueprint, '__temp__'.$this->connection->getTablePrefix()); + $table = $this->wrapTable($blueprint); + $columnNames = implode(', ', $columnNames); + + $foreignKeyConstraintsEnabled = $this->connection->scalar($this->pragma('foreign_keys')); + + return array_filter(array_merge([ + $foreignKeyConstraintsEnabled ? $this->compileDisableForeignKeyConstraints() : null, + sprintf('create table %s (%s%s%s)', + $tempTable, + implode(', ', $columns), + $this->addForeignKeys($blueprint->getState()->getForeignKeys()), + $autoIncrementColumn ? '' : $this->addPrimaryKeys($blueprint->getState()->getPrimaryKey()) + ), + sprintf('insert into %s (%s) select %s from %s', $tempTable, $columnNames, $columnNames, $table), + sprintf('drop table %s', $table), + sprintf('alter table %s rename to %s', $tempTable, $this->wrapTable($tableName)), + ], $indexes, [$foreignKeyConstraintsEnabled ? $this->compileEnableForeignKeyConstraints() : null])); + } + + /** @inheritDoc */ + public function compileChange(Blueprint $blueprint, Fluent $command) + { + // Handled on table alteration... + } - return collect($columns)->map(function ($column) use ($blueprint) { - return 'alter table '.$this->wrapTable($blueprint).' '.$column; - })->all(); + /** + * Compile a primary key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compilePrimary(Blueprint $blueprint, Fluent $command) + { + // Handled on table creation or alteration... } /** @@ -151,9 +397,12 @@ public function compileAdd(Blueprint $blueprint, Fluent $command) */ public function compileUnique(Blueprint $blueprint, Fluent $command) { - return sprintf('create unique index %s on %s (%s)', + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return sprintf('create unique index %s%s on %s (%s)', + $schema ? $this->wrapValue($schema).'.' : '', $this->wrap($command->index), - $this->wrapTable($blueprint), + $this->wrapTable($table), $this->columnize($command->columns) ); } @@ -167,9 +416,12 @@ public function compileUnique(Blueprint $blueprint, Fluent $command) */ public function compileIndex(Blueprint $blueprint, Fluent $command) { - return sprintf('create index %s on %s (%s)', + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); + + return sprintf('create index %s%s on %s (%s)', + $schema ? $this->wrapValue($schema).'.' : '', $this->wrap($command->index), - $this->wrapTable($blueprint), + $this->wrapTable($table), $this->columnize($command->columns) ); } @@ -179,6 +431,7 @@ public function compileIndex(Blueprint $blueprint, Fluent $command) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command + * @return void * * @throws \RuntimeException */ @@ -192,11 +445,11 @@ public function compileSpatialIndex(Blueprint $blueprint, Fluent $command) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return string + * @return string|null */ public function compileForeign(Blueprint $blueprint, Fluent $command) { - // Handled on table creation... + // Handled on table creation or alteration... } /** @@ -226,31 +479,40 @@ public function compileDropIfExists(Blueprint $blueprint, Fluent $command) /** * Compile the SQL needed to drop all tables. * + * @param string|null $schema * @return string */ - public function compileDropAllTables() + public function compileDropAllTables($schema = null) { - return "delete from sqlite_master where type in ('table', 'index', 'trigger')"; + return sprintf("delete from %s.sqlite_master where type in ('table', 'index', 'trigger')", + $this->wrapValue($schema ?? 'main') + ); } /** * Compile the SQL needed to drop all views. * + * @param string|null $schema * @return string */ - public function compileDropAllViews() + public function compileDropAllViews($schema = null) { - return "delete from sqlite_master where type in ('view')"; + return sprintf("delete from %s.sqlite_master where type in ('view')", + $this->wrapValue($schema ?? 'main') + ); } /** * Compile the SQL needed to rebuild the database. * + * @param string|null $schema * @return string */ - public function compileRebuild() + public function compileRebuild($schema = null) { - return 'vacuum'; + return sprintf('vacuum %s', + $this->wrapValue($schema ?? 'main') + ); } /** @@ -258,22 +520,33 @@ public function compileRebuild() * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection - * @return array + * @return list|null */ - public function compileDropColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileDropColumn(Blueprint $blueprint, Fluent $command) { - $tableDiff = $this->getDoctrineTableDiff( - $blueprint, $schema = $connection->getDoctrineSchemaManager() - ); + if (version_compare($this->connection->getServerVersion(), '3.35', '<')) { + // Handled on table alteration... - foreach ($command->columns as $name) { - $tableDiff->removedColumns[$name] = $connection->getDoctrineColumn( - $this->getTablePrefix().$blueprint->getTable(), $name - ); + return null; } - return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); + $table = $this->wrapTable($blueprint); + + $columns = $this->prefixArray('drop column', $this->wrapArray($command->columns)); + + return (new Collection($columns))->map(fn ($column) => 'alter table '.$table.' '.$column)->all(); + } + + /** + * Compile a drop primary key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropPrimary(Blueprint $blueprint, Fluent $command) + { + // Handled on table alteration... } /** @@ -285,9 +558,7 @@ public function compileDropColumn(Blueprint $blueprint, Fluent $command, Connect */ public function compileDropUnique(Blueprint $blueprint, Fluent $command) { - $index = $this->wrap($command->index); - - return "drop index {$index}"; + return $this->compileDropIndex($blueprint, $command); } /** @@ -299,9 +570,12 @@ public function compileDropUnique(Blueprint $blueprint, Fluent $command) */ public function compileDropIndex(Blueprint $blueprint, Fluent $command) { - $index = $this->wrap($command->index); + [$schema] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($blueprint->getTable()); - return "drop index {$index}"; + return sprintf('drop index %s%s', + $schema ? $this->wrapValue($schema).'.' : '', + $this->wrap($command->index) + ); } /** @@ -309,6 +583,7 @@ public function compileDropIndex(Blueprint $blueprint, Fluent $command) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command + * @return void * * @throws \RuntimeException */ @@ -317,6 +592,24 @@ public function compileDropSpatialIndex(Blueprint $blueprint, Fluent $command) throw new RuntimeException('The database driver in use does not support spatial indexes.'); } + /** + * Compile a drop foreign key command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return array + * + * @throws \RuntimeException + */ + public function compileDropForeign(Blueprint $blueprint, Fluent $command) + { + if (empty($command->columns)) { + throw new RuntimeException('This database driver does not support dropping foreign keys by name.'); + } + + // Handled on table alteration... + } + /** * Compile a rename table command. * @@ -334,33 +627,40 @@ public function compileRename(Blueprint $blueprint, Fluent $command) /** * Compile a rename index command. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command * @return array + * + * @throws \RuntimeException */ - public function compileRenameIndex(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileRenameIndex(Blueprint $blueprint, Fluent $command) { - $schemaManager = $connection->getDoctrineSchemaManager(); + $indexes = $this->connection->getSchemaBuilder()->getIndexes($blueprint->getTable()); - $indexes = $schemaManager->listTableIndexes($this->getTablePrefix().$blueprint->getTable()); - - $index = Arr::get($indexes, $command->from); + $index = Arr::first($indexes, fn ($index) => $index['name'] === $command->from); if (! $index) { throw new RuntimeException("Index [{$command->from}] does not exist."); } - $newIndex = new Index( - $command->to, $index->getColumns(), $index->isUnique(), - $index->isPrimary(), $index->getFlags(), $index->getOptions() - ); + if ($index['primary']) { + throw new RuntimeException('SQLite does not support altering primary keys.'); + } - $platform = $schemaManager->getDatabasePlatform(); + if ($index['unique']) { + return [ + $this->compileDropUnique($blueprint, new IndexDefinition(['index' => $index['name']])), + $this->compileUnique($blueprint, + new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + ), + ]; + } return [ - $platform->getDropIndexSQL($command->from, $this->getTablePrefix().$blueprint->getTable()), - $platform->getCreateIndexSQL($newIndex, $this->getTablePrefix().$blueprint->getTable()), + $this->compileDropIndex($blueprint, new IndexDefinition(['index' => $index['name']])), + $this->compileIndex($blueprint, + new IndexDefinition(['index' => $command->to, 'columns' => $index['columns']]) + ), ]; } @@ -371,7 +671,7 @@ public function compileRenameIndex(Blueprint $blueprint, Fluent $command, Connec */ public function compileEnableForeignKeyConstraints() { - return 'PRAGMA foreign_keys = ON;'; + return $this->pragma('foreign_keys', 1); } /** @@ -381,49 +681,55 @@ public function compileEnableForeignKeyConstraints() */ public function compileDisableForeignKeyConstraints() { - return 'PRAGMA foreign_keys = OFF;'; + return $this->pragma('foreign_keys', 0); } /** - * Compile the SQL needed to enable a writable schema. + * Get the SQL to get or set a PRAGMA value. * + * @param string $key + * @param mixed $value * @return string */ - public function compileEnableWriteableSchema() + public function pragma(string $key, mixed $value = null): string { - return 'PRAGMA writable_schema = 1;'; + return sprintf('pragma %s%s', + $key, + is_null($value) ? '' : ' = '.$value + ); } /** - * Compile the SQL needed to disable a writable schema. + * Create the column definition for a char type. * + * @param \Illuminate\Support\Fluent $column * @return string */ - public function compileDisableWriteableSchema() + protected function typeChar(Fluent $column) { - return 'PRAGMA writable_schema = 0;'; + return 'varchar'; } /** - * Create the column definition for a char type. + * Create the column definition for a string type. * * @param \Illuminate\Support\Fluent $column * @return string */ - protected function typeChar(Fluent $column) + protected function typeString(Fluent $column) { return 'varchar'; } /** - * Create the column definition for a string type. + * Create the column definition for a tiny text type. * * @param \Illuminate\Support\Fluent $column * @return string */ - protected function typeString(Fluent $column) + protected function typeTinyText(Fluent $column) { - return 'varchar'; + return 'text'; } /** @@ -460,7 +766,7 @@ protected function typeLongText(Fluent $column) } /** - * Create the column definition for a integer type. + * Create the column definition for an integer type. * * @param \Illuminate\Support\Fluent $column * @return string @@ -533,7 +839,7 @@ protected function typeFloat(Fluent $column) */ protected function typeDouble(Fluent $column) { - return 'float'; + return 'double'; } /** @@ -581,7 +887,7 @@ protected function typeEnum(Fluent $column) */ protected function typeJson(Fluent $column) { - return 'text'; + return $this->connection->getConfig('use_native_json') ? 'json' : 'text'; } /** @@ -592,7 +898,7 @@ protected function typeJson(Fluent $column) */ protected function typeJsonb(Fluent $column) { - return 'text'; + return $this->connection->getConfig('use_native_jsonb') ? 'jsonb' : 'text'; } /** @@ -603,6 +909,10 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + return 'date'; } @@ -621,6 +931,7 @@ protected function typeDateTime(Fluent $column) * Create the column definition for a date-time (with time zone) type. * * Note: "SQLite does not have a storage class set aside for storing dates and/or times." + * * @link https://www.sqlite.org/datatype3.html * * @param \Illuminate\Support\Fluent $column @@ -661,7 +972,11 @@ protected function typeTimeTz(Fluent $column) */ protected function typeTimestamp(Fluent $column) { - return $column->useCurrent ? 'datetime default CURRENT_TIMESTAMP' : 'datetime'; + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } + + return 'datetime'; } /** @@ -683,6 +998,10 @@ protected function typeTimestampTz(Fluent $column) */ protected function typeYear(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression("(CAST(strftime('%Y', 'now') AS INTEGER))")); + } + return $this->typeInteger($column); } @@ -736,86 +1055,77 @@ protected function typeMacAddress(Fluent $column) * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeGeometry(Fluent $column) + protected function typeGeometry(Fluent $column) { return 'geometry'; } /** - * Create the column definition for a spatial Point type. + * Create the column definition for a spatial Geography type. * * @param \Illuminate\Support\Fluent $column * @return string */ - public function typePoint(Fluent $column) + protected function typeGeography(Fluent $column) { - return 'point'; + return $this->typeGeometry($column); } /** - * Create the column definition for a spatial LineString type. + * Create the column definition for a generated, computed column type. * * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeLineString(Fluent $column) - { - return 'linestring'; - } - - /** - * Create the column definition for a spatial Polygon type. + * @return void * - * @param \Illuminate\Support\Fluent $column - * @return string + * @throws \RuntimeException */ - public function typePolygon(Fluent $column) + protected function typeComputed(Fluent $column) { - return 'polygon'; + throw new RuntimeException('This database driver requires a type, see the virtualAs / storedAs modifiers.'); } /** - * Create the column definition for a spatial GeometryCollection type. + * Get the SQL for a generated virtual column modifier. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string + * @return string|null */ - public function typeGeometryCollection(Fluent $column) + protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - return 'geometrycollection'; - } + if (! is_null($virtualAs = $column->virtualAsJson)) { + if ($this->isJsonSelector($virtualAs)) { + $virtualAs = $this->wrapJsonSelector($virtualAs); + } - /** - * Create the column definition for a spatial MultiPoint type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPoint(Fluent $column) - { - return 'multipoint'; - } + return " as ({$virtualAs})"; + } - /** - * Create the column definition for a spatial MultiLineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiLineString(Fluent $column) - { - return 'multilinestring'; + if (! is_null($virtualAs = $column->virtualAs)) { + return " as ({$this->getValue($virtualAs)})"; + } } /** - * Create the column definition for a spatial MultiPolygon type. + * Get the SQL for a generated stored column modifier. * + * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column - * @return string + * @return string|null */ - public function typeMultiPolygon(Fluent $column) + protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - return 'multipolygon'; + if (! is_null($storedAs = $column->storedAsJson)) { + if ($this->isJsonSelector($storedAs)) { + $storedAs = $this->wrapJsonSelector($storedAs); + } + + return " as ({$storedAs}) stored"; + } + + if (! is_null($storedAs = $column->storedAs)) { + return " as ({$this->getValue($column->storedAs)}) stored"; + } } /** @@ -827,7 +1137,16 @@ public function typeMultiPolygon(Fluent $column) */ protected function modifyNullable(Blueprint $blueprint, Fluent $column) { - return $column->nullable ? ' null' : ' not null'; + if (is_null($column->virtualAs) && + is_null($column->virtualAsJson) && + is_null($column->storedAs) && + is_null($column->storedAsJson)) { + return $column->nullable ? '' : ' not null'; + } + + if ($column->nullable === false) { + return ' not null'; + } } /** @@ -839,7 +1158,7 @@ protected function modifyNullable(Blueprint $blueprint, Fluent $column) */ protected function modifyDefault(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->default)) { + if (! is_null($column->default) && is_null($column->virtualAs) && is_null($column->virtualAsJson) && is_null($column->storedAs)) { return ' default '.$this->getDefaultValue($column->default); } } @@ -857,4 +1176,31 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) return ' primary key autoincrement'; } } + + /** + * Get the SQL for a collation column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|null + */ + protected function modifyCollate(Blueprint $blueprint, Fluent $column) + { + if (! is_null($column->collation)) { + return " collate '{$column->collation}'"; + } + } + + /** + * Wrap the given JSON selector. + * + * @param string $value + * @return string + */ + protected function wrapJsonSelector($value) + { + [$field, $path] = $this->wrapJsonFieldAndPath($value); + + return 'json_extract('.$field.$path.')'; + } } diff --git a/Schema/Grammars/SqlServerGrammar.php b/Schema/Grammars/SqlServerGrammar.php index 8d057f5bdf..28b5e5a7a1 100755 --- a/Schema/Grammars/SqlServerGrammar.php +++ b/Schema/Grammars/SqlServerGrammar.php @@ -2,8 +2,9 @@ namespace Illuminate\Database\Schema\Grammars; -use Illuminate\Support\Fluent; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Fluent; class SqlServerGrammar extends Grammar { @@ -17,38 +18,182 @@ class SqlServerGrammar extends Grammar /** * The possible column modifiers. * - * @var array + * @var string[] */ - protected $modifiers = ['Increment', 'Collate', 'Nullable', 'Default', 'Persisted']; + protected $modifiers = ['Collate', 'Nullable', 'Default', 'Persisted', 'Increment']; /** * The columns available as serials. * - * @var array + * @var string[] */ protected $serials = ['tinyInteger', 'smallInteger', 'mediumInteger', 'integer', 'bigInteger']; /** - * Compile the query to determine if a table exists. + * The commands to be executed outside of create or alter command. + * + * @var string[] + */ + protected $fluentCommands = ['Default']; + + /** + * Compile the query to determine the schemas. * * @return string */ - public function compileTableExists() + public function compileSchemas() { - return "select * from sysobjects where type = 'U' and name = ?"; + return 'select name, iif(schema_id = schema_id(), 1, 0) as [default] from sys.schemas ' + ."where name not in ('information_schema', 'sys') and name not like 'db[_]%' order by name"; } /** - * Compile the query to determine the list of columns. + * Compile the query to determine if the given table exists. * + * @param string|null $schema * @param string $table * @return string */ - public function compileColumnListing($table) + public function compileTableExists($schema, $table) { - return "select col.name from sys.columns as col - join sys.objects as obj on col.object_id = obj.object_id - where obj.type = 'U' and obj.name = '$table'"; + return sprintf( + 'select (case when object_id(%s, \'U\') is null then 0 else 1 end) as [exists]', + $this->quoteString($schema ? $schema.'.'.$table : $table) + ); + } + + /** + * Compile the query to determine the tables. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileTables($schema) + { + return 'select t.name as name, schema_name(t.schema_id) as [schema], sum(u.total_pages) * 8 * 1024 as size ' + .'from sys.tables as t ' + .'join sys.partitions as p on p.object_id = t.object_id ' + .'join sys.allocation_units as u on u.container_id = p.hobt_id ' + ."where t.is_ms_shipped = 0 and t.name <> 'sysdiagrams'" + .$this->compileSchemaWhereClause($schema, 'schema_name(t.schema_id)') + .' group by t.name, t.schema_id ' + .'order by [schema], t.name'; + } + + /** + * Compile the query to determine the views. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileViews($schema) + { + return 'select name, schema_name(v.schema_id) as [schema], definition from sys.views as v ' + .'inner join sys.sql_modules as m on v.object_id = m.object_id ' + .'where v.is_ms_shipped = 0' + .$this->compileSchemaWhereClause($schema, 'schema_name(v.schema_id)') + .' order by [schema], name'; + } + + /** + * Compile the query to compare the schema. + * + * @param string|string[]|null $schema + * @param string $column + * @return string + */ + protected function compileSchemaWhereClause($schema, $column) + { + return match (true) { + ! empty($schema) && is_array($schema) => " and $column in (".$this->quoteString($schema).')', + ! empty($schema) => " and $column = ".$this->quoteString($schema), + default => '', + }; + } + + /** + * Compile the query to determine the columns. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileColumns($schema, $table) + { + return sprintf( + 'select col.name, type.name as type_name, ' + .'col.max_length as length, col.precision as precision, col.scale as places, ' + .'col.is_nullable as nullable, def.definition as [default], ' + .'col.is_identity as autoincrement, col.collation_name as collation, ' + .'com.definition as [expression], is_persisted as [persisted], ' + .'cast(prop.value as nvarchar(max)) as comment ' + .'from sys.columns as col ' + .'join sys.types as type on col.user_type_id = type.user_type_id ' + .'join sys.objects as obj on col.object_id = obj.object_id ' + .'join sys.schemas as scm on obj.schema_id = scm.schema_id ' + .'left join sys.default_constraints def on col.default_object_id = def.object_id and col.object_id = def.parent_object_id ' + ."left join sys.extended_properties as prop on obj.object_id = prop.major_id and col.column_id = prop.minor_id and prop.name = 'MS_Description' " + .'left join sys.computed_columns as com on col.column_id = com.column_id and col.object_id = com.object_id ' + ."where obj.type in ('U', 'V') and obj.name = %s and scm.name = %s " + .'order by col.column_id', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'schema_name()', + ); + } + + /** + * Compile the query to determine the indexes. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileIndexes($schema, $table) + { + return sprintf( + "select idx.name as name, string_agg(col.name, ',') within group (order by idxcol.key_ordinal) as columns, " + .'idx.type_desc as [type], idx.is_unique as [unique], idx.is_primary_key as [primary] ' + .'from sys.indexes as idx ' + .'join sys.tables as tbl on idx.object_id = tbl.object_id ' + .'join sys.schemas as scm on tbl.schema_id = scm.schema_id ' + .'join sys.index_columns as idxcol on idx.object_id = idxcol.object_id and idx.index_id = idxcol.index_id ' + .'join sys.columns as col on idxcol.object_id = col.object_id and idxcol.column_id = col.column_id ' + .'where tbl.name = %s and scm.name = %s ' + .'group by idx.name, idx.type_desc, idx.is_unique, idx.is_primary_key', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'schema_name()', + ); + } + + /** + * Compile the query to determine the foreign keys. + * + * @param string|null $schema + * @param string $table + * @return string + */ + public function compileForeignKeys($schema, $table) + { + return sprintf( + 'select fk.name as name, ' + ."string_agg(lc.name, ',') within group (order by fkc.constraint_column_id) as columns, " + .'fs.name as foreign_schema, ft.name as foreign_table, ' + ."string_agg(fc.name, ',') within group (order by fkc.constraint_column_id) as foreign_columns, " + .'fk.update_referential_action_desc as on_update, ' + .'fk.delete_referential_action_desc as on_delete ' + .'from sys.foreign_keys as fk ' + .'join sys.foreign_key_columns as fkc on fkc.constraint_object_id = fk.object_id ' + .'join sys.tables as lt on lt.object_id = fk.parent_object_id ' + .'join sys.schemas as ls on lt.schema_id = ls.schema_id ' + .'join sys.columns as lc on fkc.parent_object_id = lc.object_id and fkc.parent_column_id = lc.column_id ' + .'join sys.tables as ft on ft.object_id = fk.referenced_object_id ' + .'join sys.schemas as fs on ft.schema_id = fs.schema_id ' + .'join sys.columns as fc on fkc.referenced_object_id = fc.object_id and fkc.referenced_column_id = fc.column_id ' + .'where lt.name = %s and ls.name = %s ' + .'group by fk.name, fs.name, ft.name, fk.update_referential_action_desc, fk.delete_referential_action_desc', + $this->quoteString($table), + $schema ? $this->quoteString($schema) : 'schema_name()', + ); } /** @@ -60,9 +205,10 @@ public function compileColumnListing($table) */ public function compileCreate(Blueprint $blueprint, Fluent $command) { - $columns = implode(', ', $this->getColumns($blueprint)); - - return 'create table '.$this->wrapTable($blueprint)." ($columns)"; + return sprintf('create table %s (%s)', + $this->wrapTable($blueprint, $blueprint->temporary ? '#'.$this->connection->getTablePrefix() : null), + implode(', ', $this->getColumns($blueprint)) + ); } /** @@ -76,10 +222,31 @@ public function compileAdd(Blueprint $blueprint, Fluent $command) { return sprintf('alter table %s add %s', $this->wrapTable($blueprint), - implode(', ', $this->getColumns($blueprint)) + $this->getColumn($blueprint, $command->column) + ); + } + + /** @inheritDoc */ + public function compileRenameColumn(Blueprint $blueprint, Fluent $command) + { + return sprintf("sp_rename %s, %s, N'COLUMN'", + $this->quoteString($this->wrapTable($blueprint).'.'.$this->wrap($command->from)), + $this->wrap($command->to) ); } + /** @inheritDoc */ + public function compileChange(Blueprint $blueprint, Fluent $command) + { + return [ + $this->compileDropDefaultConstraint($blueprint, $command), + sprintf('alter table %s alter column %s', + $this->wrapTable($blueprint), + $this->getColumn($blueprint, $command->column), + ), + ]; + } + /** * Compile a primary key command. * @@ -105,10 +272,11 @@ public function compilePrimary(Blueprint $blueprint, Fluent $command) */ public function compileUnique(Blueprint $blueprint, Fluent $command) { - return sprintf('create unique index %s on %s (%s)', + return sprintf('create unique index %s on %s (%s)%s', $this->wrap($command->index), $this->wrapTable($blueprint), - $this->columnize($command->columns) + $this->columnize($command->columns), + $command->online ? ' with (online = on)' : '' ); } @@ -121,10 +289,11 @@ public function compileUnique(Blueprint $blueprint, Fluent $command) */ public function compileIndex(Blueprint $blueprint, Fluent $command) { - return sprintf('create index %s on %s (%s)', + return sprintf('create index %s on %s (%s)%s', $this->wrap($command->index), $this->wrapTable($blueprint), - $this->columnize($command->columns) + $this->columnize($command->columns), + $command->online ? ' with (online = on)' : '' ); } @@ -144,6 +313,24 @@ public function compileSpatialIndex(Blueprint $blueprint, Fluent $command) ); } + /** + * Compile a default command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string|null + */ + public function compileDefault(Blueprint $blueprint, Fluent $command) + { + if ($command->column->change && ! is_null($command->column->default)) { + return sprintf('alter table %s add default %s for %s', + $this->wrapTable($blueprint), + $this->getDefaultValue($command->column->default), + $this->wrap($command->column) + ); + } + } + /** * Compile a drop table command. * @@ -165,8 +352,8 @@ public function compileDrop(Blueprint $blueprint, Fluent $command) */ public function compileDropIfExists(Blueprint $blueprint, Fluent $command) { - return sprintf('if exists (select * from INFORMATION_SCHEMA.TABLES where TABLE_NAME = %s) drop table %s', - "'".str_replace("'", "''", $this->getTablePrefix().$blueprint->getTable())."'", + return sprintf('if object_id(%s, \'U\') is not null drop table %s', + $this->quoteString($this->wrapTable($blueprint)), $this->wrapTable($blueprint) ); } @@ -192,7 +379,34 @@ public function compileDropColumn(Blueprint $blueprint, Fluent $command) { $columns = $this->wrapArray($command->columns); - return 'alter table '.$this->wrapTable($blueprint).' drop column '.implode(', ', $columns); + $dropExistingConstraintsSql = $this->compileDropDefaultConstraint($blueprint, $command).';'; + + return $dropExistingConstraintsSql.'alter table '.$this->wrapTable($blueprint).' drop column '.implode(', ', $columns); + } + + /** + * Compile a drop default constraint command. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return string + */ + public function compileDropDefaultConstraint(Blueprint $blueprint, Fluent $command) + { + $columns = $command->name === 'change' + ? "'".$command->column->name."'" + : "'".implode("', '", $command->columns)."'"; + + $table = $this->wrapTable($blueprint); + $tableName = $this->quoteString($this->wrapTable($blueprint)); + + $sql = "DECLARE @sql NVARCHAR(MAX) = '';"; + $sql .= "SELECT @sql += 'ALTER TABLE $table DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' "; + $sql .= 'FROM sys.columns '; + $sql .= "WHERE [object_id] = OBJECT_ID($tableName) AND [name] in ($columns) AND [default_object_id] <> 0;"; + $sql .= 'EXEC(@sql)'; + + return $sql; } /** @@ -272,22 +486,23 @@ public function compileDropForeign(Blueprint $blueprint, Fluent $command) */ public function compileRename(Blueprint $blueprint, Fluent $command) { - $from = $this->wrapTable($blueprint); - - return "sp_rename {$from}, ".$this->wrapTable($command->to); + return sprintf('sp_rename %s, %s', + $this->quoteString($this->wrapTable($blueprint)), + $this->wrapTable($command->to) + ); } /** * Compile a rename index command. * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $command + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command * @return string */ public function compileRenameIndex(Blueprint $blueprint, Fluent $command) { - return sprintf("sp_rename N'%s', %s, N'INDEX'", - $this->wrap($blueprint->getTable().'.'.$command->from), + return sprintf("sp_rename %s, %s, N'INDEX'", + $this->quoteString($this->wrapTable($blueprint).'.'.$this->wrap($command->from)), $this->wrap($command->to) ); } @@ -312,6 +527,36 @@ public function compileDisableForeignKeyConstraints() return 'EXEC sp_msforeachtable "ALTER TABLE ? NOCHECK CONSTRAINT all";'; } + /** + * Compile the command to drop all foreign keys. + * + * @return string + */ + public function compileDropAllForeignKeys() + { + return "DECLARE @sql NVARCHAR(MAX) = N''; + SELECT @sql += 'ALTER TABLE ' + + QUOTENAME(OBJECT_SCHEMA_NAME(parent_object_id)) + '.' + + QUOTENAME(OBJECT_NAME(parent_object_id)) + + ' DROP CONSTRAINT ' + QUOTENAME(name) + ';' + FROM sys.foreign_keys; + + EXEC sp_executesql @sql;"; + } + + /** + * Compile the command to drop all views. + * + * @return string + */ + public function compileDropAllViews() + { + return "DECLARE @sql NVARCHAR(MAX) = N''; + SELECT @sql += 'DROP VIEW ' + QUOTENAME(OBJECT_SCHEMA_NAME(object_id)) + '.' + QUOTENAME(name) + ';' + FROM sys.views; + + EXEC sp_executesql @sql;"; + } + /** * Create the column definition for a char type. * @@ -334,6 +579,17 @@ protected function typeString(Fluent $column) return "nvarchar({$column->length})"; } + /** + * Create the column definition for a tiny text type. + * + * @param \Illuminate\Support\Fluent $column + * @return string + */ + protected function typeTinyText(Fluent $column) + { + return 'nvarchar(255)'; + } + /** * Create the column definition for a text type. * @@ -430,6 +686,10 @@ protected function typeSmallInteger(Fluent $column) */ protected function typeFloat(Fluent $column) { + if ($column->precision) { + return "float({$column->precision})"; + } + return 'float'; } @@ -441,7 +701,7 @@ protected function typeFloat(Fluent $column) */ protected function typeDouble(Fluent $column) { - return 'float'; + return 'double precision'; } /** @@ -511,6 +771,10 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CAST(GETDATE() AS DATE)')); + } + return 'date'; } @@ -566,24 +830,28 @@ protected function typeTimeTz(Fluent $column) */ protected function typeTimestamp(Fluent $column) { - $columnType = $column->precision ? "datetime2($column->precision)" : 'datetime'; + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + return $column->precision ? "datetime2($column->precision)" : 'datetime'; } /** * Create the column definition for a timestamp (with time zone) type. * - * @link https://msdn.microsoft.com/en-us/library/bb630289(v=sql.120).aspx + * @link https://docs.microsoft.com/en-us/sql/t-sql/data-types/datetimeoffset-transact-sql?view=sql-server-ver15 * * @param \Illuminate\Support\Fluent $column * @return string */ protected function typeTimestampTz(Fluent $column) { - $columnType = $column->precision ? "datetimeoffset($column->precision)" : 'datetimeoffset'; + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_TIMESTAMP')); + } - return $column->useCurrent ? "$columnType default CURRENT_TIMESTAMP" : $columnType; + return $column->precision ? "datetimeoffset($column->precision)" : 'datetimeoffset'; } /** @@ -594,6 +862,10 @@ protected function typeTimestampTz(Fluent $column) */ protected function typeYear(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CAST(YEAR(GETDATE()) AS INTEGER)')); + } + return $this->typeInteger($column); } @@ -605,6 +877,10 @@ protected function typeYear(Fluent $column) */ protected function typeBinary(Fluent $column) { + if ($column->length) { + return $column->fixed ? "binary({$column->length})" : "varbinary({$column->length})"; + } + return 'varbinary(max)'; } @@ -647,84 +923,18 @@ protected function typeMacAddress(Fluent $column) * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeGeometry(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial Point type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePoint(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial LineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeLineString(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial Polygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePolygon(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial GeometryCollection type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeGeometryCollection(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial MultiPoint type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPoint(Fluent $column) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial MultiLineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiLineString(Fluent $column) + protected function typeGeometry(Fluent $column) { - return 'geography'; + return 'geometry'; } /** - * Create the column definition for a spatial MultiPolygon type. + * Create the column definition for a spatial Geography type. * * @param \Illuminate\Support\Fluent $column * @return string */ - public function typeMultiPolygon(Fluent $column) + protected function typeGeography(Fluent $column) { return 'geography'; } @@ -737,7 +947,7 @@ public function typeMultiPolygon(Fluent $column) */ protected function typeComputed(Fluent $column) { - return "as ({$column->expression})"; + return "as ({$this->getValue($column->expression)})"; } /** @@ -777,7 +987,7 @@ protected function modifyNullable(Blueprint $blueprint, Fluent $column) */ protected function modifyDefault(Blueprint $blueprint, Fluent $column) { - if (! is_null($column->default)) { + if (! $column->change && ! is_null($column->default)) { return ' default '.$this->getDefaultValue($column->default); } } @@ -791,8 +1001,8 @@ protected function modifyDefault(Blueprint $blueprint, Fluent $column) */ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) { - if (in_array($column->type, $this->serials) && $column->autoIncrement) { - return ' identity primary key'; + if (! $column->change && in_array($column->type, $this->serials) && $column->autoIncrement) { + return $this->hasCommand($blueprint, 'primary') ? ' identity' : ' identity primary key'; } } @@ -805,23 +1015,31 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) */ protected function modifyPersisted(Blueprint $blueprint, Fluent $column) { + if ($column->change) { + if ($column->type === 'computed') { + return $column->persisted ? ' add persisted' : ' drop persisted'; + } + + return null; + } + if ($column->persisted) { return ' persisted'; } } /** - * Wrap a table in keyword identifiers. + * Quote the given string literal. * - * @param \Illuminate\Database\Query\Expression|string $table + * @param string|array $value * @return string */ - public function wrapTable($table) + public function quoteString($value) { - if ($table instanceof Blueprint && $table->temporary) { - $this->setTablePrefix('#'); + if (is_array($value)) { + return implode(', ', array_map([$this, __FUNCTION__], $value)); } - return parent::wrapTable($table); + return "N'$value'"; } } diff --git a/Schema/IndexDefinition.php b/Schema/IndexDefinition.php new file mode 100644 index 0000000000..2ebe07a0ea --- /dev/null +++ b/Schema/IndexDefinition.php @@ -0,0 +1,19 @@ +detectClientVersion(); + + $command = 'mariadb '.$this->connectionString($versionInfo).' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + + $process = $this->makeProcess($command)->setTimeout(null); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for MariaDB as a string. + * + * @return string + */ + protected function baseDumpCommand() + { + $versionInfo = $this->detectClientVersion(); + + $command = 'mariadb-dump '.$this->connectionString($versionInfo).' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc'; + + return $command.' "${:LARAVEL_LOAD_DATABASE}"'; + } +} diff --git a/Schema/MySqlBuilder.php b/Schema/MySqlBuilder.php index 85f3e92c25..6676411225 100755 --- a/Schema/MySqlBuilder.php +++ b/Schema/MySqlBuilder.php @@ -4,38 +4,6 @@ class MySqlBuilder extends Builder { - /** - * Determine if the given table exists. - * - * @param string $table - * @return bool - */ - public function hasTable($table) - { - $table = $this->connection->getTablePrefix().$table; - - return count($this->connection->select( - $this->grammar->compileTableExists(), [$this->connection->getDatabaseName(), $table] - )) > 0; - } - - /** - * Get the column listing for a given table. - * - * @param string $table - * @return array - */ - public function getColumnListing($table) - { - $table = $this->connection->getTablePrefix().$table; - - $results = $this->connection->select( - $this->grammar->compileColumnListing(), [$this->connection->getDatabaseName(), $table] - ); - - return $this->connection->getPostProcessor()->processColumnListing($results); - } - /** * Drop all tables from the database. * @@ -43,13 +11,7 @@ public function getColumnListing($table) */ public function dropAllTables() { - $tables = []; - - foreach ($this->getAllTables() as $row) { - $row = (array) $row; - - $tables[] = reset($row); - } + $tables = $this->getTableListing($this->getCurrentSchemaListing()); if (empty($tables)) { return; @@ -57,11 +19,13 @@ public function dropAllTables() $this->disableForeignKeyConstraints(); - $this->connection->statement( - $this->grammar->compileDropAllTables($tables) - ); - - $this->enableForeignKeyConstraints(); + try { + $this->connection->statement( + $this->grammar->compileDropAllTables($tables) + ); + } finally { + $this->enableForeignKeyConstraints(); + } } /** @@ -71,13 +35,7 @@ public function dropAllTables() */ public function dropAllViews() { - $views = []; - - foreach ($this->getAllViews() as $row) { - $row = (array) $row; - - $views[] = reset($row); - } + $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); if (empty($views)) { return; @@ -89,26 +47,12 @@ public function dropAllViews() } /** - * Get all of the table names for the database. + * Get the names of current schemas for the connection. * - * @return array + * @return string[]|null */ - protected function getAllTables() + public function getCurrentSchemaListing() { - return $this->connection->select( - $this->grammar->compileGetAllTables() - ); - } - - /** - * Get all of the view names for the database. - * - * @return array - */ - protected function getAllViews() - { - return $this->connection->select( - $this->grammar->compileGetAllViews() - ); + return [$this->connection->getDatabaseName()]; } } diff --git a/Schema/MySqlSchemaState.php b/Schema/MySqlSchemaState.php new file mode 100644 index 0000000000..dbfa3ccaef --- /dev/null +++ b/Schema/MySqlSchemaState.php @@ -0,0 +1,235 @@ +executeDumpProcess($this->makeProcess( + $this->baseDumpCommand().' --routines --result-file="${:LARAVEL_LOAD_PATH}" --no-data' + ), $this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + + $this->removeAutoIncrementingState($path); + + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } + } + + /** + * Remove the auto-incrementing state from the given schema dump. + * + * @param string $path + * @return void + */ + protected function removeAutoIncrementingState(string $path) + { + $this->files->put($path, preg_replace( + '/\s+AUTO_INCREMENT=[0-9]+/iu', + '', + $this->files->get($path) + )); + } + + /** + * Append the migration data to the schema dump. + * + * @param string $path + * @return void + */ + protected function appendMigrationData(string $path) + { + $process = $this->executeDumpProcess($this->makeProcess( + $this->baseDumpCommand().' '.$this->getMigrationTable().' --no-create-info --skip-extended-insert --skip-routines --compact --complete-insert' + ), null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $this->files->append($path, $process->getOutput()); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $versionInfo = $this->detectClientVersion(); + + $command = 'mysql '.$this->connectionString($versionInfo).' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + + $process = $this->makeProcess($command)->setTimeout(null); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base dump command arguments for MySQL as a string. + * + * @return string + */ + protected function baseDumpCommand() + { + $versionInfo = $this->detectClientVersion(); + + $command = 'mysqldump '.$this->connectionString($versionInfo).' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0'; + + if (! $this->connection->isMaria()) { + $command .= ' --set-gtid-purged=OFF'; + } + + return $command.' "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Generate a basic connection string (--socket, --host, --port, --user, --password) for the database. + * + * @param array{version: string, isMariaDb: bool} $versionInfo + * @return string + */ + protected function connectionString(array $versionInfo) + { + $value = ' --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}"'; + + $config = $this->connection->getConfig(); + + $value .= $config['unix_socket'] ?? false + ? ' --socket="${:LARAVEL_LOAD_SOCKET}"' + : ' --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"'; + + /** @phpstan-ignore class.notFound */ + if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA])) { + $value .= ' --ssl-ca="${:LARAVEL_LOAD_SSL_CA}"'; + } + + /** @phpstan-ignore class.notFound */ + if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : \PDO::MYSQL_ATTR_SSL_CERT])) { + $value .= ' --ssl-cert="${:LARAVEL_LOAD_SSL_CERT}"'; + } + + /** @phpstan-ignore class.notFound */ + if (isset($config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : \PDO::MYSQL_ATTR_SSL_KEY])) { + $value .= ' --ssl-key="${:LARAVEL_LOAD_SSL_KEY}"'; + } + + /** @phpstan-ignore class.notFound */ + $verifyCertOption = PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_VERIFY_SERVER_CERT : \PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT; + + if (isset($config['options'][$verifyCertOption]) && $config['options'][$verifyCertOption] === false) { + if (version_compare($versionInfo['version'], '5.7.11', '>=') && ! $versionInfo['isMariaDb']) { + $value .= ' --ssl-mode=DISABLED'; + } else { + $value .= ' --ssl=off'; + } + } + + return $value; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + $config['host'] ??= ''; + + return [ + 'LARAVEL_LOAD_SOCKET' => $config['unix_socket'] ?? '', + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', + 'LARAVEL_LOAD_USER' => $config['username'], + 'LARAVEL_LOAD_PASSWORD' => $config['password'] ?? '', + 'LARAVEL_LOAD_DATABASE' => $config['database'], + 'LARAVEL_LOAD_SSL_CA' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA] ?? '', // @phpstan-ignore class.notFound + 'LARAVEL_LOAD_SSL_CERT' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CERT : \PDO::MYSQL_ATTR_SSL_CERT] ?? '', // @phpstan-ignore class.notFound + 'LARAVEL_LOAD_SSL_KEY' => $config['options'][PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_KEY : \PDO::MYSQL_ATTR_SSL_KEY] ?? '', // @phpstan-ignore class.notFound + ]; + } + + /** + * Execute the given dump process. + * + * @param \Symfony\Component\Process\Process $process + * @param callable $output + * @param array $variables + * @param int $depth + * @return \Symfony\Component\Process\Process + * + * @throws \Throwable + */ + protected function executeDumpProcess(Process $process, $output, array $variables, int $depth = 0) + { + if ($depth > 30) { + throw new Exception('Dump execution exceeded maximum depth of 30.'); + } + + try { + $process->setTimeout(null)->mustRun($output, $variables); + } catch (Exception $e) { + if (Str::contains($e->getMessage(), ['column-statistics', 'column_statistics'])) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --column-statistics=0', '', $process->getCommandLine()) + ), $output, $variables, $depth + 1); + } + + if (str_contains($e->getMessage(), 'set-gtid-purged')) { + return $this->executeDumpProcess(Process::fromShellCommandLine( + str_replace(' --set-gtid-purged=OFF', '', $process->getCommandLine()) + ), $output, $variables, $depth + 1); + } + + throw $e; + } + + return $process; + } + + /** + * Detect the MySQL client version. + * + * @return array{version: string, isMariaDb: bool} + */ + protected function detectClientVersion(): array + { + [$version, $isMariaDb] = ['8.0.0', false]; + + try { + $versionOutput = $this->makeProcess('mysql --version')->mustRun()->getOutput(); + + if (preg_match('/(\d+\.\d+\.\d+)/', $versionOutput, $matches)) { + $version = $matches[1]; + } + + $isMariaDb = stripos($versionOutput, 'mariadb') !== false; + } catch (ProcessFailedException) { + } + + return [ + 'version' => $version, + 'isMariaDb' => $isMariaDb, + ]; + } +} diff --git a/Schema/PostgresBuilder.php b/Schema/PostgresBuilder.php index bdeb972a67..66f3117427 100755 --- a/Schema/PostgresBuilder.php +++ b/Schema/PostgresBuilder.php @@ -2,24 +2,11 @@ namespace Illuminate\Database\Schema; +use Illuminate\Database\Concerns\ParsesSearchPath; + class PostgresBuilder extends Builder { - /** - * Determine if the given table exists. - * - * @param string $table - * @return bool - */ - public function hasTable($table) - { - [$schema, $table] = $this->parseSchemaAndTable($table); - - $table = $this->connection->getTablePrefix().$table; - - return count($this->connection->select( - $this->grammar->compileTableExists(), [$schema, $table] - )) > 0; - } + use ParsesSearchPath; /** * Drop all tables from the database. @@ -30,15 +17,11 @@ public function dropAllTables() { $tables = []; - $excludedTables = ['spatial_ref_sys']; - - foreach ($this->getAllTables() as $row) { - $row = (array) $row; - - $table = reset($row); + $excludedTables = $this->connection->getConfig('dont_drop') ?? ['spatial_ref_sys']; - if (! in_array($table, $excludedTables)) { - $tables[] = $table; + foreach ($this->getTables($this->getCurrentSchemaListing()) as $table) { + if (empty(array_intersect([$table['name'], $table['schema_qualified_name']], $excludedTables))) { + $tables[] = $table['schema_qualified_name']; } } @@ -58,13 +41,7 @@ public function dropAllTables() */ public function dropAllViews() { - $views = []; - - foreach ($this->getAllViews() as $row) { - $row = (array) $row; - - $views[] = reset($row); - } + $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); if (empty($views)) { return; @@ -76,66 +53,48 @@ public function dropAllViews() } /** - * Get all of the table names for the database. - * - * @return array - */ - protected function getAllTables() - { - return $this->connection->select( - $this->grammar->compileGetAllTables($this->connection->getConfig('schema')) - ); - } - - /** - * Get all of the view names for the database. + * Drop all types from the database. * - * @return array - */ - protected function getAllViews() - { - return $this->connection->select( - $this->grammar->compileGetAllViews($this->connection->getConfig('schema')) - ); - } - - /** - * Get the column listing for a given table. - * - * @param string $table - * @return array + * @return void */ - public function getColumnListing($table) + public function dropAllTypes() { - [$schema, $table] = $this->parseSchemaAndTable($table); - - $table = $this->connection->getTablePrefix().$table; + $types = []; + $domains = []; + + foreach ($this->getTypes($this->getCurrentSchemaListing()) as $type) { + if (! $type['implicit']) { + if ($type['type'] === 'domain') { + $domains[] = $type['schema_qualified_name']; + } else { + $types[] = $type['schema_qualified_name']; + } + } + } - $results = $this->connection->select( - $this->grammar->compileColumnListing(), [$schema, $table] - ); + if (! empty($types)) { + $this->connection->statement($this->grammar->compileDropAllTypes($types)); + } - return $this->connection->getPostProcessor()->processColumnListing($results); + if (! empty($domains)) { + $this->connection->statement($this->grammar->compileDropAllDomains($domains)); + } } /** - * Parse the table name and extract the schema and table. + * Get the current schemas for the connection. * - * @param string $table - * @return array + * @return string[] */ - protected function parseSchemaAndTable($table) + public function getCurrentSchemaListing() { - $table = explode('.', $table); - - if (is_array($schema = $this->connection->getConfig('schema'))) { - if (in_array($table[0], $schema)) { - return [array_shift($table), implode('.', $table)]; - } - - $schema = head($schema); - } - - return [$schema ?: 'public', implode('.', $table)]; + return array_map( + fn ($schema) => $schema === '$user' ? $this->connection->getConfig('username') : $schema, + $this->parseSearchPath( + $this->connection->getConfig('search_path') + ?: $this->connection->getConfig('schema') + ?: 'public' + ) + ); } } diff --git a/Schema/PostgresSchemaState.php b/Schema/PostgresSchemaState.php new file mode 100644 index 0000000000..25da812e61 --- /dev/null +++ b/Schema/PostgresSchemaState.php @@ -0,0 +1,95 @@ +baseDumpCommand().' --schema-only > '.$path, + ]); + + if ($this->hasMigrationTable()) { + $commands->push($this->baseDumpCommand().' -t '.$this->getMigrationTable().' --data-only >> '.$path); + } + + $commands->map(function ($command, $path) { + $this->makeProcess($command)->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + }); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $command = 'pg_restore --no-owner --no-acl --clean --if-exists --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}" "${:LARAVEL_LOAD_PATH}"'; + + if (str_ends_with($path, '.sql')) { + $command = 'psql --file="${:LARAVEL_LOAD_PATH}" --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + $process = $this->makeProcess($command); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the name of the application's migration table. + * + * @return string + */ + protected function getMigrationTable(): string + { + [$schema, $table] = $this->connection->getSchemaBuilder()->parseSchemaAndTable($this->migrationTable, withDefaultSchema: true); + + return $schema.'.'.$this->connection->getTablePrefix().$table; + } + + /** + * Get the base dump command arguments for PostgreSQL as a string. + * + * @return string + */ + protected function baseDumpCommand() + { + return 'pg_dump --no-owner --no-acl --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + $config['host'] ??= ''; + + return [ + 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], + 'LARAVEL_LOAD_PORT' => $config['port'] ?? '', + 'LARAVEL_LOAD_USER' => $config['username'], + 'PGPASSWORD' => $config['password'], + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/Schema/SQLiteBuilder.php b/Schema/SQLiteBuilder.php index 78b6b9c78d..f750e97edf 100644 --- a/Schema/SQLiteBuilder.php +++ b/Schema/SQLiteBuilder.php @@ -2,8 +2,93 @@ namespace Illuminate\Database\Schema; +use Illuminate\Database\QueryException; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\File; + class SQLiteBuilder extends Builder { + /** + * Create a database in the schema. + * + * @param string $name + * @return bool + */ + public function createDatabase($name) + { + return File::put($name, '') !== false; + } + + /** + * Drop a database from the schema if the database exists. + * + * @param string $name + * @return bool + */ + public function dropDatabaseIfExists($name) + { + return ! File::exists($name) || File::delete($name); + } + + /** @inheritDoc */ + public function getTables($schema = null) + { + try { + $withSize = $this->connection->scalar($this->grammar->compileDbstatExists()); + } catch (QueryException) { + $withSize = false; + } + + if (version_compare($this->connection->getServerVersion(), '3.37.0', '<')) { + $schema ??= array_column($this->getSchemas(), 'name'); + + $tables = []; + + foreach (Arr::wrap($schema) as $name) { + $tables = array_merge($tables, $this->connection->selectFromWriteConnection( + $this->grammar->compileLegacyTables($name, $withSize) + )); + } + + return $this->connection->getPostProcessor()->processTables($tables); + } + + return $this->connection->getPostProcessor()->processTables( + $this->connection->selectFromWriteConnection( + $this->grammar->compileTables($schema) + ) + ); + } + + /** @inheritDoc */ + public function getViews($schema = null) + { + $schema ??= array_column($this->getSchemas(), 'name'); + + $views = []; + + foreach (Arr::wrap($schema) as $name) { + $views = array_merge($views, $this->connection->selectFromWriteConnection( + $this->grammar->compileViews($name) + )); + } + + return $this->connection->getPostProcessor()->processViews($views); + } + + /** @inheritDoc */ + public function getColumns($table) + { + [$schema, $table] = $this->parseSchemaAndTable($table); + + $table = $this->connection->getTablePrefix().$table; + + return $this->connection->getPostProcessor()->processColumns( + $this->connection->selectFromWriteConnection($this->grammar->compileColumns($schema, $table)), + $this->connection->scalar($this->grammar->compileSqlCreateStatement($schema, $table)) + ); + } + /** * Drop all tables from the database. * @@ -11,17 +96,26 @@ class SQLiteBuilder extends Builder */ public function dropAllTables() { - if ($this->connection->getDatabaseName() !== ':memory:') { - return $this->refreshDatabaseFile(); - } + foreach ($this->getCurrentSchemaListing() as $schema) { + $database = $schema === 'main' + ? $this->connection->getDatabaseName() + : (array_column($this->getSchemas(), 'path', 'name')[$schema] ?: ':memory:'); - $this->connection->select($this->grammar->compileEnableWriteableSchema()); + if ($database !== ':memory:' && + ! str_contains($database, '?mode=memory') && + ! str_contains($database, '&mode=memory') + ) { + $this->refreshDatabaseFile($database); + } else { + $this->pragma('writable_schema', 1); - $this->connection->select($this->grammar->compileDropAllTables()); + $this->connection->statement($this->grammar->compileDropAllTables($schema)); - $this->connection->select($this->grammar->compileDisableWriteableSchema()); + $this->pragma('writable_schema', 0); - $this->connection->select($this->grammar->compileRebuild()); + $this->connection->statement($this->grammar->compileRebuild($schema)); + } + } } /** @@ -31,22 +125,49 @@ public function dropAllTables() */ public function dropAllViews() { - $this->connection->select($this->grammar->compileEnableWriteableSchema()); + foreach ($this->getCurrentSchemaListing() as $schema) { + $this->pragma('writable_schema', 1); - $this->connection->select($this->grammar->compileDropAllViews()); + $this->connection->statement($this->grammar->compileDropAllViews($schema)); - $this->connection->select($this->grammar->compileDisableWriteableSchema()); + $this->pragma('writable_schema', 0); - $this->connection->select($this->grammar->compileRebuild()); + $this->connection->statement($this->grammar->compileRebuild($schema)); + } + } + + /** + * Get the value for the given pragma name or set the given value. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + public function pragma($key, $value = null) + { + return is_null($value) + ? $this->connection->scalar($this->grammar->pragma($key)) + : $this->connection->statement($this->grammar->pragma($key, $value)); } /** * Empty the database file. * + * @param string|null $path * @return void */ - public function refreshDatabaseFile() + public function refreshDatabaseFile($path = null) + { + file_put_contents($path ?? $this->connection->getDatabaseName(), ''); + } + + /** + * Get the names of current schemas for the connection. + * + * @return string[]|null + */ + public function getCurrentSchemaListing() { - file_put_contents($this->connection->getDatabaseName(), ''); + return ['main']; } } diff --git a/Schema/SchemaState.php b/Schema/SchemaState.php new file mode 100644 index 0000000000..be792138f7 --- /dev/null +++ b/Schema/SchemaState.php @@ -0,0 +1,141 @@ +connection = $connection; + + $this->files = $files ?: new Filesystem; + + $this->processFactory = $processFactory ?: function (...$arguments) { + return Process::fromShellCommandline(...$arguments)->setTimeout(null); + }; + + $this->handleOutputUsing(function () { + // + }); + } + + /** + * Dump the database's schema into a file. + * + * @param \Illuminate\Database\Connection $connection + * @param string $path + * @return void + */ + abstract public function dump(Connection $connection, $path); + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + abstract public function load($path); + + /** + * Create a new process instance. + * + * @param mixed ...$arguments + * @return \Symfony\Component\Process\Process + */ + public function makeProcess(...$arguments) + { + return call_user_func($this->processFactory, ...$arguments); + } + + /** + * Determine if the current connection has a migration table. + * + * @return bool + */ + public function hasMigrationTable(): bool + { + return $this->connection->getSchemaBuilder()->hasTable($this->migrationTable); + } + + /** + * Get the name of the application's migration table. + * + * @return string + */ + protected function getMigrationTable(): string + { + return $this->connection->getTablePrefix().$this->migrationTable; + } + + /** + * Specify the name of the application's migration table. + * + * @param string $table + * @return $this + */ + public function withMigrationTable(string $table) + { + $this->migrationTable = $table; + + return $this; + } + + /** + * Specify the callback that should be used to handle process output. + * + * @param callable $output + * @return $this + */ + public function handleOutputUsing(callable $output) + { + $this->output = $output; + + return $this; + } +} diff --git a/Schema/SqlServerBuilder.php b/Schema/SqlServerBuilder.php index 2c54282dd2..9161bc61af 100644 --- a/Schema/SqlServerBuilder.php +++ b/Schema/SqlServerBuilder.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Schema; +use Illuminate\Support\Arr; + class SqlServerBuilder extends Builder { /** @@ -11,10 +13,28 @@ class SqlServerBuilder extends Builder */ public function dropAllTables() { - $this->disableForeignKeyConstraints(); + $this->connection->statement($this->grammar->compileDropAllForeignKeys()); $this->connection->statement($this->grammar->compileDropAllTables()); + } - $this->enableForeignKeyConstraints(); + /** + * Drop all views from the database. + * + * @return void + */ + public function dropAllViews() + { + $this->connection->statement($this->grammar->compileDropAllViews()); + } + + /** + * Get the default schema name for the connection. + * + * @return string|null + */ + public function getCurrentSchemaName() + { + return Arr::first($this->getSchemas(), fn ($schema) => $schema['default'])['name']; } } diff --git a/Schema/SqliteSchemaState.php b/Schema/SqliteSchemaState.php new file mode 100644 index 0000000000..3d954a39de --- /dev/null +++ b/Schema/SqliteSchemaState.php @@ -0,0 +1,103 @@ +makeProcess($this->baseCommand().' ".schema --indent"') + ->setTimeout(null) + ->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $migrations = preg_replace('/CREATE TABLE sqlite_.+?\);[\r\n]+/is', '', $process->getOutput()); + + $this->files->put($path, $migrations.PHP_EOL); + + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } + } + + /** + * Append the migration data to the schema dump. + * + * @param string $path + * @return void + */ + protected function appendMigrationData(string $path) + { + $process = $this->makeProcess( + $this->baseCommand().' ".dump \''.$this->getMigrationTable().'\'"' + )->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); + + $migrations = (new Collection(preg_split("/\r\n|\n|\r/", $process->getOutput()))) + ->filter(fn ($line) => preg_match('/^\s*(--|INSERT\s)/iu', $line) === 1 && strlen($line) > 0) + ->all(); + + $this->files->append($path, implode(PHP_EOL, $migrations).PHP_EOL); + } + + /** + * Load the given schema file into the database. + * + * @param string $path + * @return void + */ + public function load($path) + { + $database = $this->connection->getDatabaseName(); + + if ($database === ':memory:' || + str_contains($database, '?mode=memory') || + str_contains($database, '&mode=memory') + ) { + $this->connection->getPdo()->exec($this->files->get($path)); + + return; + } + + $process = $this->makeProcess($this->baseCommand().' < "${:LARAVEL_LOAD_PATH}"'); + + $process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + 'LARAVEL_LOAD_PATH' => $path, + ])); + } + + /** + * Get the base sqlite command arguments as a string. + * + * @return string + */ + protected function baseCommand() + { + return 'sqlite3 "${:LARAVEL_LOAD_DATABASE}"'; + } + + /** + * Get the base variables for a dump / load command. + * + * @param array $config + * @return array + */ + protected function baseVariables(array $config) + { + return [ + 'LARAVEL_LOAD_DATABASE' => $config['database'], + ]; + } +} diff --git a/Seeder.php b/Seeder.php index f8675ea3cd..cac50afb57 100755 --- a/Seeder.php +++ b/Seeder.php @@ -2,17 +2,19 @@ namespace Illuminate\Database; +use Illuminate\Console\Command; +use Illuminate\Console\View\Components\TwoColumnDetail; +use Illuminate\Contracts\Container\Container; +use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Support\Arr; use InvalidArgumentException; -use Illuminate\Console\Command; -use Illuminate\Container\Container; abstract class Seeder { /** * The container instance. * - * @var \Illuminate\Container\Container + * @var \Illuminate\Contracts\Container\Container */ protected $container; @@ -24,36 +26,95 @@ abstract class Seeder protected $command; /** - * Seed the given connection from the given path. + * Seeders that have been called at least one time. + * + * @var array + */ + protected static $called = []; + + /** + * Run the given seeder class. * * @param array|string $class * @param bool $silent + * @param array $parameters * @return $this */ - public function call($class, $silent = false) + public function call($class, $silent = false, array $parameters = []) { $classes = Arr::wrap($class); foreach ($classes as $class) { + $seeder = $this->resolve($class); + + $name = get_class($seeder); + if ($silent === false && isset($this->command)) { - $this->command->getOutput()->writeln("Seeding: $class"); + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, 'RUNNING'); } - $this->resolve($class)->__invoke(); + $startTime = microtime(true); + + $seeder->__invoke($parameters); + + if ($silent === false && isset($this->command)) { + $runTime = number_format((microtime(true) - $startTime) * 1000); + + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, "$runTime ms DONE"); + + $this->command->getOutput()->writeln(''); + } + + static::$called[] = $class; } return $this; } /** - * Silently seed the given connection from the given path. + * Run the given seeder class. + * + * @param array|string $class + * @param array $parameters + * @return void + */ + public function callWith($class, array $parameters = []) + { + $this->call($class, false, $parameters); + } + + /** + * Silently run the given seeder class. * * @param array|string $class + * @param array $parameters * @return void */ - public function callSilent($class) + public function callSilent($class, array $parameters = []) { - $this->call($class, true); + $this->call($class, true, $parameters); + } + + /** + * Run the given seeder class once. + * + * @param array|string $class + * @param bool $silent + * @return void + */ + public function callOnce($class, $silent = false, array $parameters = []) + { + $classes = Arr::wrap($class); + + foreach ($classes as $class) { + if (in_array($class, static::$called)) { + continue; + } + + $this->call($class, $silent, $parameters); + } } /** @@ -82,7 +143,7 @@ protected function resolve($class) /** * Set the IoC container instance. * - * @param \Illuminate\Container\Container $container + * @param \Illuminate\Contracts\Container\Container $container * @return $this */ public function setContainer(Container $container) @@ -108,18 +169,27 @@ public function setCommand(Command $command) /** * Run the database seeds. * - * @return dynamic + * @param array $parameters + * @return mixed * * @throws \InvalidArgumentException */ - public function __invoke() + public function __invoke(array $parameters = []) { if (! method_exists($this, 'run')) { throw new InvalidArgumentException('Method [run] missing from '.get_class($this)); } - return isset($this->container) - ? $this->container->call([$this, 'run']) - : $this->run(); + $callback = fn () => isset($this->container) + ? $this->container->call([$this, 'run'], $parameters) + : $this->run(...$parameters); + + $uses = array_flip(class_uses_recursive(static::class)); + + if (isset($uses[WithoutModelEvents::class])) { + $callback = $this->withoutModelEvents($callback); + } + + return $callback(); } } diff --git a/SqlServerConnection.php b/SqlServerConnection.php index db5a0bed8c..7b3d0c5f01 100755 --- a/SqlServerConnection.php +++ b/SqlServerConnection.php @@ -4,15 +4,24 @@ use Closure; use Exception; -use Throwable; -use Illuminate\Database\Schema\SqlServerBuilder; -use Doctrine\DBAL\Driver\PDOSqlsrv\Driver as DoctrineDriver; -use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; +use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; +use Illuminate\Database\Schema\SqlServerBuilder; +use Illuminate\Filesystem\Filesystem; +use RuntimeException; +use Throwable; class SqlServerConnection extends Connection { + /** + * {@inheritdoc} + */ + public function getDriverTitle() + { + return 'SQL Server'; + } + /** * Execute a Closure within a transaction. * @@ -20,13 +29,13 @@ class SqlServerConnection extends Connection * @param int $attempts * @return mixed * - * @throws \Exception|\Throwable + * @throws \Throwable */ public function transaction(Closure $callback, $attempts = 1) { for ($a = 1; $a <= $attempts; $a++) { if ($this->getDriverName() === 'sqlsrv') { - return parent::transaction($callback); + return parent::transaction($callback, $attempts); } $this->getPdo()->exec('BEGIN TRAN'); @@ -40,14 +49,10 @@ public function transaction(Closure $callback, $attempts = 1) $this->getPdo()->exec('COMMIT TRAN'); } - // If we catch an exception, we will roll back so nothing gets messed + // If we catch an exception, we will rollback so nothing gets messed // up in the database. Then we'll re-throw the exception so it can // be handled how the developer sees fit for their applications. - catch (Exception $e) { - $this->getPdo()->exec('ROLLBACK TRAN'); - - throw $e; - } catch (Throwable $e) { + catch (Throwable $e) { $this->getPdo()->exec('ROLLBACK TRAN'); throw $e; @@ -57,6 +62,30 @@ public function transaction(Closure $callback, $attempts = 1) } } + /** + * Escape a binary value for safe SQL embedding. + * + * @param string $value + * @return string + */ + protected function escapeBinary($value) + { + $hex = bin2hex($value); + + return "0x{$hex}"; + } + + /** + * Determine if the given database exception was caused by a unique constraint violation. + * + * @param \Exception $exception + * @return bool + */ + protected function isUniqueConstraintError(Exception $exception) + { + return (bool) preg_match('#Cannot insert duplicate key row in object#i', $exception->getMessage()); + } + /** * Get the default query grammar instance. * @@ -64,7 +93,7 @@ public function transaction(Closure $callback, $attempts = 1) */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -88,26 +117,29 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** - * Get the default post processor instance. + * Get the schema state for the connection. * - * @return \Illuminate\Database\Query\Processors\SqlServerProcessor + * @param \Illuminate\Filesystem\Filesystem|null $files + * @param callable|null $processFactory + * + * @throws \RuntimeException */ - protected function getDefaultPostProcessor() + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { - return new SqlServerProcessor; + throw new RuntimeException('Schema dumping is not supported when using SQL Server.'); } /** - * Get the Doctrine DBAL driver. + * Get the default post processor instance. * - * @return \Doctrine\DBAL\Driver\PDOSqlsrv\Driver + * @return \Illuminate\Database\Query\Processors\SqlServerProcessor */ - protected function getDoctrineDriver() + protected function getDefaultPostProcessor() { - return new DoctrineDriver; + return new SqlServerProcessor; } } diff --git a/UniqueConstraintViolationException.php b/UniqueConstraintViolationException.php new file mode 100644 index 0000000000..13b705b77c --- /dev/null +++ b/UniqueConstraintViolationException.php @@ -0,0 +1,7 @@ +