diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml index 226152e7f1..6cbfcf0671 100644 --- a/.github/workflows/close-pull-request.yml +++ b/.github/workflows/close-pull-request.yml @@ -6,7 +6,7 @@ on: jobs: run: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: superbrothers/close-pull-request@v3 with: diff --git a/Capsule/Manager.php b/Capsule/Manager.php index b877e7c6d2..ddcc85dcf7 100755 --- a/Capsule/Manager.php +++ b/Capsule/Manager.php @@ -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); diff --git a/Concerns/BuildsQueries.php b/Concerns/BuildsQueries.php index 2012742653..537594e085 100644 --- a/Concerns/BuildsQueries.php +++ b/Concerns/BuildsQueries.php @@ -6,6 +6,7 @@ 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; @@ -18,6 +19,11 @@ use InvalidArgumentException; use RuntimeException; +/** + * @template TValue + * + * @mixin \Illuminate\Database\Query\Builder + */ trait BuildsQueries { use Conditionable; @@ -26,20 +32,28 @@ trait BuildsQueries * 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 { - // 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(); + $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(); @@ -47,9 +61,10 @@ public function chunk($count, callable $callback) 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 (! is_null($remaining)) { + $remaining = max($remaining - $countResults, 0); + } + if ($callback($results, $page) === false) { return false; } @@ -65,13 +80,15 @@ public function chunk($count, callable $callback) /** * Run a map over each item while chunking. * - * @param callable $callback + * @template TReturn + * + * @param callable(TValue): TReturn $callback * @param int $count - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function chunkMap(callable $callback, $count = 1000) { - $collection = Collection::make(); + $collection = new Collection; $this->chunk($count, function ($items) use ($collection, $callback) { $items->each(function ($item) use ($collection, $callback) { @@ -85,7 +102,7 @@ public function chunkMap(callable $callback, $count = 1000) /** * Execute a callback over each item while chunking. * - * @param callable $callback + * @param callable(TValue, int): mixed $callback * @param int $count * @return bool * @@ -106,28 +123,73 @@ public function each(callable $callback, $count = 1000) * Chunk the results of a query by comparing IDs. * * @param int $count - * @param callable $callback + * @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) { - $column ??= $this->defaultKeyName(); + return $this->orderedChunkById($count, $callback, $column, $alias); + } - $alias ??= $column; + /** + * 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 = $clone->forPageAfterId($count, $lastId, $column)->get(); + if ($descending) { + $results = $clone->forPageBeforeId($limit, $lastId, $column)->get(); + } else { + $results = $clone->forPageAfterId($limit, $lastId, $column)->get(); + } $countResults = $results->count(); @@ -135,6 +197,10 @@ public function chunkById($count, callable $callback, $column = null, $alias = n 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. @@ -159,7 +225,7 @@ public function chunkById($count, callable $callback, $column = null, $alias = n /** * 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 @@ -180,7 +246,7 @@ public function eachById(callable $callback, $count = 1000, $column = null, $ali * Query lazily, by chunks of the given size. * * @param int $chunkSize - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection * * @throws \InvalidArgumentException */ @@ -192,7 +258,7 @@ public function lazy($chunkSize = 1000) $this->enforceOrderBy(); - return LazyCollection::make(function () use ($chunkSize) { + return new LazyCollection(function () use ($chunkSize) { $page = 1; while (true) { @@ -215,7 +281,7 @@ public function lazy($chunkSize = 1000) * @param int $chunkSize * @param string|null $column * @param string|null $alias - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection * * @throws \InvalidArgumentException */ @@ -230,7 +296,7 @@ public function lazyById($chunkSize = 1000, $column = null, $alias = null) * @param int $chunkSize * @param string|null $column * @param string|null $alias - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection * * @throws \InvalidArgumentException */ @@ -249,6 +315,7 @@ public function lazyByIdDesc($chunkSize = 1000, $column = null, $alias = null) * @return \Illuminate\Support\LazyCollection * * @throws \InvalidArgumentException + * @throws \RuntimeException */ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = null, $descending = false) { @@ -260,7 +327,7 @@ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = n $alias ??= $column; - return LazyCollection::make(function () use ($chunkSize, $column, $alias, $descending) { + return new LazyCollection(function () use ($chunkSize, $column, $alias, $descending) { $lastId = null; while (true) { @@ -281,6 +348,10 @@ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = n } $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."); + } } }); } @@ -289,25 +360,43 @@ protected function orderedLazyById($chunkSize = 1000, $column = null, $alias = n * Execute the query and get the first result. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|object|static|null + * @return TValue|null */ public function first($columns = ['*']) { - return $this->take(1)->get($columns)->first(); + return $this->limit(1)->get($columns)->first(); + } + + /** + * Execute the query and get the first result or throw an exception. + * + * @param array|string $columns + * @param string|null $message + * @return TValue + * + * @throws \Illuminate\Database\RecordNotFoundException + */ + public function firstOrFail($columns = ['*'], $message = null) + { + if (! is_null($result = $this->first($columns))) { + return $result; + } + + throw new RecordNotFoundException($message ?: 'No record found for the given query.'); } /** * Execute the query and get the first result if it's the sole matching record. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|object|static|null + * @return TValue * * @throws \Illuminate\Database\RecordsNotFoundException * @throws \Illuminate\Database\MultipleRecordsFoundException */ public function sole($columns = ['*']) { - $result = $this->take(2)->get($columns); + $result = $this->limit(2)->get($columns); $count = $result->count(); @@ -342,11 +431,14 @@ protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); if (! is_null($cursor)) { - $addCursorConditions = function (self $builder, $previousColumn, $i) use (&$addCursorConditions, $cursor, $orders) { - $unionBuilders = isset($builder->unions) ? collect($builder->unions)->pluck('query') : collect(); + // 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); + $originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn); $builder->where( Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, @@ -356,7 +448,7 @@ protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = $unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) { $unionBuilder->where( - $this->getOriginalColumnNameForCursorPagination($this, $previousColumn), + $this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn), '=', $cursor->parameter($previousColumn) ); @@ -365,44 +457,48 @@ protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = }); } - $builder->where(function (self $builder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { + $builder->where(function (self $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) { ['column' => $column, 'direction' => $direction] = $orders[$i]; $originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column); - $builder->where( + $secondBuilder->where( Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn, $direction === 'asc' ? '>' : '<', $cursor->parameter($column) ); if ($i < $orders->count() - 1) { - $builder->orWhere(function (self $builder) use ($addCursorConditions, $column, $i) { - $addCursorConditions($builder, $column, $i + 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) { - $unionBuilder->where(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( - $this->getOriginalColumnNameForCursorPagination($this, $column), + $originalColumn, $direction === 'asc' ? '>' : '<', $cursor->parameter($column) ); if ($i < $orders->count() - 1) { - $unionBuilder->orWhere(function (self $builder) use ($addCursorConditions, $column, $i) { - $addCursorConditions($builder, $column, $i + 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, 0); + $addCursorConditions($this, null, null, 0); } $this->limit($perPage + 1); @@ -417,20 +513,20 @@ protected function paginateUsingCursor($perPage, $columns = ['*'], $cursorName = /** * Get the original column name of the given column, without any aliasing. * - * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $builder + * @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()->columns : $builder->columns; + $columns = $builder instanceof Builder ? $builder->getQuery()->getColumns() : $builder->getColumns(); if (! is_null($columns)) { foreach ($columns as $column) { - if (($position = stripos($column, ' as ')) !== false) { - $as = substr($column, $position, 4); + if (($position = strripos($column, ' as ')) !== false) { + $original = substr($column, 0, $position); - [$original, $alias] = explode($as, $column); + $alias = substr($column, $position + 4); if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) { return $original; @@ -492,9 +588,9 @@ protected function cursorPaginator($items, $perPage, $cursor, $options) } /** - * Pass the query to a given callback. + * Pass the query to a given callback and then return it. * - * @param callable $callback + * @param callable($this): mixed $callback * @return $this */ public function tap($callback) @@ -503,4 +599,17 @@ public function tap($callback) 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 index cd520e7885..fb62914374 100644 --- a/Concerns/CompilesJsonPaths.php +++ b/Concerns/CompilesJsonPaths.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Concerns; +use Illuminate\Support\Collection; use Illuminate\Support\Str; trait CompilesJsonPaths @@ -34,8 +35,8 @@ protected function wrapJsonPath($value, $delimiter = '->') { $value = preg_replace("/([\\\\]+)?\\'/", "''", $value); - $jsonPath = collect(explode($delimiter, $value)) - ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) + $jsonPath = (new Collection(explode($delimiter, $value))) + ->map(fn ($segment) => $this->wrapJsonPathSegment($segment)) ->join('.'); return "'$".(str_starts_with($jsonPath, '[') ? '' : '.').$jsonPath."'"; diff --git a/Concerns/ManagesTransactions.php b/Concerns/ManagesTransactions.php index 2f316a3939..9874727d26 100644 --- a/Concerns/ManagesTransactions.php +++ b/Concerns/ManagesTransactions.php @@ -7,14 +7,19 @@ 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 \Throwable */ @@ -41,6 +46,8 @@ public function transaction(Closure $callback, $attempts = 1) continue; } + $levelBeingCommitted = $this->transactions; + try { if ($this->transactions == 1) { $this->fireConnectionEvent('committing'); @@ -48,10 +55,6 @@ public function transaction(Closure $callback, $attempts = 1) } $this->transactions = max(0, $this->transactions - 1); - - if ($this->afterCommitCallbacksShouldBeExecuted()) { - $this->transactionsManager?->commit($this->getName()); - } } catch (Throwable $e) { $this->handleCommitTransactionException( $e, $currentAttempt, $attempts @@ -60,6 +63,12 @@ public function transaction(Closure $callback, $attempts = 1) continue; } + $this->transactionsManager?->commit( + $this->getName(), + $levelBeingCommitted, + $this->transactions + ); + $this->fireConnectionEvent('committed'); return $callbackResult; @@ -114,6 +123,10 @@ protected function handleTransactionException(Throwable $e, $currentAttempt, $ma */ public function beginTransaction() { + foreach ($this->beforeStartingTransaction as $callback) { + $callback($this); + } + $this->createTransaction(); $this->transactions++; @@ -138,7 +151,7 @@ protected function createTransaction() $this->reconnectIfMissingConnection(); try { - $this->getPdo()->beginTransaction(); + $this->executeBeginTransactionStatement(); } catch (Throwable $e) { $this->handleBeginTransactionException($e); } @@ -174,7 +187,7 @@ protected function handleBeginTransactionException(Throwable $e) if ($this->causedByLostConnection($e)) { $this->reconnect(); - $this->getPdo()->beginTransaction(); + $this->executeBeginTransactionStatement(); } else { throw $e; } @@ -194,27 +207,18 @@ public function commit() $this->getPdo()->commit(); } - $this->transactions = max(0, $this->transactions - 1); + [$levelBeingCommitted, $this->transactions] = [ + $this->transactions, + max(0, $this->transactions - 1), + ]; - if ($this->afterCommitCallbacksShouldBeExecuted()) { - $this->transactionsManager?->commit($this->getName()); - } + $this->transactionsManager?->commit( + $this->getName(), $levelBeingCommitted, $this->transactions + ); $this->fireConnectionEvent('committed'); } - /** - * Determine if after commit callbacks should be executed. - * - * @return bool - */ - protected function afterCommitCallbacksShouldBeExecuted() - { - return $this->transactions == 0 || - ($this->transactionsManager && - $this->transactionsManager->callbackApplicableTransactions()->count() === 1); - } - /** * Handle an exception encountered when committing a transaction. * @@ -254,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; @@ -290,7 +294,11 @@ public function rollBack($toLevel = null) 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)) @@ -345,4 +353,21 @@ public function afterCommit($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/ConcurrencyErrorDetector.php b/ConcurrencyErrorDetector.php new file mode 100644 index 0000000000..3a5cb1dcd0 --- /dev/null +++ b/ConcurrencyErrorDetector.php @@ -0,0 +1,39 @@ +getCode() === 40001 || $e->getCode() === '40001')) { + return true; + } + + $message = $e->getMessage(); + + return Str::contains($message, [ + 'Deadlock found when trying to get lock', + 'deadlock detected', + 'The database file is locked', + 'database is locked', + 'database table is locked', + 'A table in the database is locked', + '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/Connection.php b/Connection.php index b6808eb43b..8c337d2ae2 100755 --- a/Connection.php +++ b/Connection.php @@ -5,8 +5,6 @@ use Carbon\CarbonInterval; use Closure; use DateTimeInterface; -use Doctrine\DBAL\Connection as DoctrineConnection; -use Doctrine\DBAL\Types\Type; use Exception; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Events\QueryExecuted; @@ -27,6 +25,8 @@ use PDOStatement; use RuntimeException; +use function Illuminate\Support\enum_value; + class Connection implements ConnectionInterface { use DetectsConcurrencyErrors, @@ -38,17 +38,24 @@ class Connection implements ConnectionInterface /** * The active PDO connection. * - * @var \PDO|\Closure + * @var \PDO|(\Closure(): \PDO) */ protected $pdo; /** * The active PDO connection used for reads. * - * @var \PDO|\Closure + * @var \PDO|(\Closure(): \PDO) */ protected $readPdo; + /** + * The database connection configuration options for reading. + * + * @var array + */ + protected $readPdoConfig = []; + /** * The name of the connected database. * @@ -80,7 +87,7 @@ class Connection implements ConnectionInterface /** * The reconnector instance for the connection. * - * @var callable + * @var (callable(\Illuminate\Database\Connection): mixed) */ protected $reconnector; @@ -108,7 +115,7 @@ class Connection implements ConnectionInterface /** * The event dispatcher instance. * - * @var \Illuminate\Contracts\Events\Dispatcher + * @var \Illuminate\Contracts\Events\Dispatcher|null */ protected $events; @@ -129,7 +136,7 @@ class Connection implements ConnectionInterface /** * The transaction manager instance. * - * @var \Illuminate\Database\DatabaseTransactionsManager + * @var \Illuminate\Database\DatabaseTransactionsManager|null */ protected $transactionsManager; @@ -150,7 +157,7 @@ class Connection implements ConnectionInterface /** * All of the queries run against the connection. * - * @var array + * @var array{query: string, bindings: array, time: float|null}[] */ protected $queryLog = []; @@ -171,7 +178,7 @@ class Connection implements ConnectionInterface /** * All of the registered query duration handlers. * - * @var array + * @var array{has_run: bool, handler: (callable(\Illuminate\Database\Connection, class-string<\Illuminate\Database\Events\QueryExecuted>): mixed)}[] */ protected $queryDurationHandlers = []; @@ -183,41 +190,40 @@ class Connection implements ConnectionInterface protected $pretending = false; /** - * All of the callbacks that should be invoked before a query is executed. + * All of the callbacks that should be invoked before a transaction is started. * * @var \Closure[] */ - protected $beforeExecutingCallbacks = []; + protected $beforeStartingTransaction = []; /** - * The instance of Doctrine connection. + * 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 = []; /** - * Type mappings that should be registered with new Doctrine connections. + * The connection resolvers. * - * @var array + * @var \Closure[] */ - protected $doctrineTypeMappings = []; + protected static $resolvers = []; /** - * The connection resolvers. + * The last retrieved PDO read / write type. * - * @var \Closure[] + * @var null|'read'|'write' */ - protected static $resolvers = []; + protected $latestPdoTypeRetrieved = null; /** * Create a new database connection instance. * - * @param \PDO|\Closure $pdo + * @param \PDO|(\Closure(): \PDO) $pdo * @param string $database * @param string $tablePrefix * @param array $config - * @return void */ public function __construct($pdo, $database = '', $tablePrefix = '', array $config = []) { @@ -257,7 +263,7 @@ public function useDefaultQueryGrammar() */ protected function getDefaultQueryGrammar() { - return new QueryGrammar; + return new QueryGrammar($this); } /** @@ -273,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() { @@ -317,13 +323,13 @@ public function getSchemaBuilder() /** * Begin a fluent query against a database table. * - * @param \Closure|\Illuminate\Database\Query\Builder|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, $as = null) { - return $this->query()->from($table, $as); + return $this->query()->from(enum_value($table), $as); } /** @@ -377,7 +383,7 @@ public function scalar($query, $bindings = [], $useReadPdo = true) throw new MultipleColumnsSelectedException; } - return reset($record); + return array_first($record); } /** @@ -398,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 []; } @@ -418,7 +425,41 @@ public function select($query, $bindings = [], $useReadPdo = true) $statement->execute(); - return $statement->fetchAll(); + 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(); + + $sets = []; + + do { + $sets[] = $statement->fetchAll(...$fetchUsing); + } while ($statement->nextRowset()); + + return $sets; }); } @@ -428,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()) { @@ -441,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) @@ -455,7 +497,7 @@ public function cursor($query, $bindings = [], $useReadPdo = true) return $statement; }); - while ($record = $statement->fetch()) { + while ($record = $statement->fetch(...$fetchUsing)) { yield $record; } } @@ -598,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) { @@ -757,12 +834,32 @@ protected function runQueryCallback($query, $bindings, Closure $callback) // message to include the bindings with SQL, which will make this exception a // lot more helpful to the developer instead of just the database's errors. catch (Exception $e) { - throw new QueryException( - $this->getName(), $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(), ); } } + /** + * 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. * @@ -775,17 +872,23 @@ public function logQuery($query, $bindings, $time = null) { $this->totalQueryDuration += $time ?? 0.0; - $this->event(new QueryExecuted($query, $bindings, $time, $this)); + $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) @@ -797,7 +900,7 @@ protected function getElapsedTime($start) * 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 $handler + * @param (callable(\Illuminate\Database\Connection, \Illuminate\Database\Events\QueryExecuted): mixed) $handler * @return void */ public function whenQueryingForLongerThan($threshold, $handler) @@ -912,8 +1015,6 @@ protected function tryAgainIfCausedByLostConnection(QueryException $e, $query, $ public function reconnect() { if (is_callable($this->reconnector)) { - $this->doctrineConnection = null; - return call_user_func($this->reconnector, $this); } @@ -940,8 +1041,19 @@ public function reconnectIfMissingConnection() public function disconnect() { $this->setPdo(null)->setReadPdo(null); + } + + /** + * 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; - $this->doctrineConnection = null; + return $this; } /** @@ -960,7 +1072,7 @@ public function beforeExecuting(Closure $callback) /** * Register a database query listener with the connection. * - * @param \Closure $callback + * @param \Closure(\Illuminate\Database\Events\QueryExecuted) $callback * @return void */ public function listen(Closure $callback) @@ -1000,13 +1112,82 @@ protected function event($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) { return new Expression($value); } + /** + * 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 string + */ + protected function escapeBool($value) + { + return $value ? '1' : '0'; + } + + /** + * Escape a binary value for safe SQL embedding. + * + * @param string $value + * @return string + * + * @throws \RuntimeException + */ + protected function escapeBinary($value) + { + throw new RuntimeException('The database connection does not support escaping binary values.'); + } + /** * Determine if the database connection has modified any database records. * @@ -1066,100 +1247,6 @@ public function useWriteConnectionWhenReading($value = true) return $this; } - /** - * Is Doctrine available? - * - * @return bool - */ - public function isDoctrineAvailable() - { - return class_exists('Doctrine\DBAL\Connection'); - } - - /** - * Get a Doctrine Schema Column instance. - * - * @param string $table - * @param string $column - * @return \Doctrine\DBAL\Schema\Column - */ - public function getDoctrineColumn($table, $column) - { - $schema = $this->getDoctrineSchemaManager(); - - return $schema->listTableDetails($table)->getColumn($column); - } - - /** - * Get the Doctrine DBAL schema manager for the connection. - * - * @return \Doctrine\DBAL\Schema\AbstractSchemaManager - */ - public function getDoctrineSchemaManager() - { - $connection = $this->getDoctrineConnection(); - - // Doctrine v2 expects one parameter while v3 expects two. 2nd will be ignored on v2... - return $this->getDoctrineDriver()->getSchemaManager( - $connection, - $connection->getDatabasePlatform() - ); - } - - /** - * Get the Doctrine DBAL database connection instance. - * - * @return \Doctrine\DBAL\Connection - */ - public function getDoctrineConnection() - { - if (is_null($this->doctrineConnection)) { - $driver = $this->getDoctrineDriver(); - - $this->doctrineConnection = new DoctrineConnection(array_filter([ - 'pdo' => $this->getPdo(), - 'dbname' => $this->getDatabaseName(), - 'driver' => $driver->getName(), - 'serverVersion' => $this->getConfig('server_version'), - ]), $driver); - - foreach ($this->doctrineTypeMappings as $name => $type) { - $this->doctrineConnection - ->getDatabasePlatform() - ->registerDoctrineTypeMapping($type, $name); - } - } - - return $this->doctrineConnection; - } - - /** - * Register a custom Doctrine mapping type. - * - * @param Type|class-string $class - * @param string $name - * @param string $type - * @return void - * - * @throws \Doctrine\DBAL\DBALException - * @throws \RuntimeException - */ - public function registerDoctrineType(Type|string $class, string $name, string $type): void - { - if (! $this->isDoctrineAvailable()) { - throw new RuntimeException( - 'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).' - ); - } - - if (! Type::hasType($name)) { - Type::getTypeRegistry() - ->register($name, is_string($class) ? new $class() : $class); - } - - $this->doctrineTypeMappings[$name] = $type; - } - /** * Get the current PDO connection. * @@ -1167,6 +1254,8 @@ public function registerDoctrineType(Type|string $class, string $name, string $t */ public function getPdo() { + $this->latestPdoTypeRetrieved = 'write'; + if ($this->pdo instanceof Closure) { return $this->pdo = call_user_func($this->pdo); } @@ -1200,6 +1289,8 @@ public function getReadPdo() return $this->getPdo(); } + $this->latestPdoTypeRetrieved = 'read'; + if ($this->readPdo instanceof Closure) { return $this->readPdo = call_user_func($this->readPdo); } @@ -1245,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) @@ -1269,13 +1373,15 @@ public function getName() } /** - * Get the database connection full name. + * Get the database connection with its read / write type. * * @return string|null */ public function getNameWithReadWriteType() { - return $this->getName().($this->readWriteType ? '::'.$this->readWriteType : ''); + $name = $this->getName().($this->readWriteType ? '::'.$this->readWriteType : ''); + + return empty($name) ? null : $name; } /** @@ -1289,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. * @@ -1299,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. * @@ -1371,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() { @@ -1401,6 +1538,16 @@ public function unsetEventDispatcher() $this->events = null; } + /** + * Run the statement to start a new transaction. + * + * @return void + */ + protected function executeBeginTransactionStatement() + { + $this->getPdo()->beginTransaction(); + } + /** * Set the transaction manager instance on the connection. * @@ -1437,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. * @@ -1520,6 +1683,16 @@ public function setReadWriteType($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. * @@ -1540,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(''); - return $grammar; + try { + return $callback($this); + } finally { + $this->setTablePrefix($tablePrefix); + } + } + + /** + * Get the server version for the connection. + * + * @return string + */ + public function getServerVersion(): string + { + return $this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); } /** @@ -1574,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 00b23952a3..69e1fafa38 100755 --- a/ConnectionInterface.php +++ b/ConnectionInterface.php @@ -9,7 +9,7 @@ interface ConnectionInterface /** * Begin a fluent query against a database table. * - * @param \Closure|\Illuminate\Database\Query\Builder|string $table + * @param \Closure|\Illuminate\Database\Query\Builder|\UnitEnum|string $table * @param string|null $as * @return \Illuminate\Database\Query\Builder */ @@ -19,7 +19,7 @@ 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); @@ -33,15 +33,28 @@ public function raw($value); */ 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 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. @@ -49,9 +62,10 @@ 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. diff --git a/ConnectionResolver.php b/ConnectionResolver.php index dd16ffd657..b7b6279e1f 100755 --- a/ConnectionResolver.php +++ b/ConnectionResolver.php @@ -22,7 +22,6 @@ class ConnectionResolver implements ConnectionResolverInterface * Create a new connection resolver instance. * * @param array $connections - * @return void */ public function __construct(array $connections = []) { diff --git a/ConnectionResolverInterface.php b/ConnectionResolverInterface.php index b31e5a7925..47161d37d6 100755 --- a/ConnectionResolverInterface.php +++ b/ConnectionResolverInterface.php @@ -7,7 +7,7 @@ interface ConnectionResolverInterface /** * Get a database connection instance. * - * @param string|null $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 80b25d0223..7017c0aa5c 100755 --- a/Connectors/ConnectionFactory.php +++ b/Connectors/ConnectionFactory.php @@ -4,6 +4,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Database\Connection; +use Illuminate\Database\MariaDbConnection; use Illuminate\Database\MySqlConnection; use Illuminate\Database\PostgresConnection; use Illuminate\Database\SQLiteConnection; @@ -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) { @@ -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)); } /** @@ -137,8 +139,8 @@ protected function getWriteConfig(array $config) 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); } /** @@ -187,7 +189,9 @@ protected function createPdoResolverWithHosts(array $config) } } - throw $e; + if (isset($e)) { + throw $e; + } }; } @@ -241,6 +245,7 @@ public function createConnector(array $config) return match ($config['driver']) { 'mysql' => new MySqlConnector, + 'mariadb' => new MariaDbConnector, 'pgsql' => new PostgresConnector, 'sqlite' => new SQLiteConnector, 'sqlsrv' => new SqlServerConnector, @@ -268,6 +273,7 @@ protected function createConnection($driver, $connection, $database, $prefix = ' 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), diff --git a/Connectors/Connector.php b/Connectors/Connector.php index 0fecfb5e26..8d0af49a9a 100755 --- a/Connectors/Connector.php +++ b/Connectors/Connector.php @@ -2,7 +2,6 @@ namespace Illuminate\Database\Connectors; -use Doctrine\DBAL\Driver\PDOConnection; use Exception; use Illuminate\Database\DetectsLostConnections; use PDO; @@ -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) */ } /** @@ -92,9 +77,9 @@ protected function isPersistentConnection($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->configureIsolationLevel($connection, $config); - - $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 transaction isolation level. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function configureIsolationLevel($connection, array $config) - { - if (! isset($config['isolation_level'])) { - return; - } - - $connection->prepare( - "SET SESSION TRANSACTION ISOLATION LEVEL {$config['isolation_level']}" - )->execute(); - } - - /** - * Set the connection character set and collation. - * - * @param \PDO $connection - * @param array $config - * @return void|\PDO - */ - 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. * @@ -113,8 +45,8 @@ protected function configureTimezone($connection, array $config) protected function getDsn(array $config) { return $this->hasSocket($config) - ? $this->getSocketDsn($config) - : $this->getHostDsn($config); + ? $this->getSocketDsn($config) + : $this->getHostDsn($config); } /** @@ -147,62 +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, $config))->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']); } } - } - /** - * Set the custom modes on the connection. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function setCustomModes(PDO $connection, array $config) - { - $modes = implode(',', $config['modes']); + if (isset($config['timezone'])) { + $statements[] = sprintf("time_zone='%s'", $config['timezone']); + } - $connection->prepare("set session sql_mode='{$modes}'")->execute(); + $sqlMode = $this->getSqlMode($connection, $config); + + if ($sqlMode !== null) { + $statements[] = sprintf("SESSION sql_mode='%s'", $sqlMode); + } + + if ($statements !== []) { + $connection->exec(sprintf('SET %s;', implode(', ', $statements))); + } } /** - * Get the query to enable strict mode. + * Get the sql_mode value. * * @param \PDO $connection * @param array $config - * @return string + * @return string|null */ - protected function strictMode(PDO $connection, $config) + protected function getSqlMode(PDO $connection, array $config) { + if (isset($config['modes'])) { + return implode(',', $config['modes']); + } + + if (! isset($config['strict'])) { + return null; + } + + if (! $config['strict']) { + return 'NO_ENGINE_SUBSTITUTION'; + } + $version = $config['version'] ?? $connection->getAttribute(PDO::ATTR_SERVER_VERSION); - if (version_compare($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 (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 c54163f9b8..31d2ff4732 100755 --- a/Connectors/PostgresConnector.php +++ b/Connectors/PostgresConnector.php @@ -38,8 +38,6 @@ public function connect(array $config) $this->configureIsolationLevel($connection, $config); - $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. @@ -47,44 +45,85 @@ public function connect(array $config) $this->configureSearchPath($connection, $config); + $this->configureSynchronousCommit($connection, $config); + + return $connection; + } + + /** + * Create a DSN string from a configuration. + * + * @param array $config + * @return string + */ + protected function getDsn(array $config) + { + // 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}"; + } + + 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. - $this->configureApplicationName($connection, $config); - - $this->configureSynchronousCommit($connection, $config); + if (isset($application_name)) { + $dsn .= ";application_name='".str_replace("'", "\'", $application_name)."'"; + } - return $connection; + return $this->addSslOptions($dsn, $config); } /** - * Set the connection transaction isolation level. + * Add the SSL options to the DSN. * - * @param \PDO $connection + * @param string $dsn * @param array $config - * @return void + * @return string */ - protected function configureIsolationLevel($connection, array $config) + protected function addSslOptions($dsn, array $config) { - if (isset($config['isolation_level'])) { - $connection->prepare("set session characteristics as transaction isolation level {$config['isolation_level']}")->execute(); + foreach (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'] as $option) { + if (isset($config[$option])) { + $dsn .= ";{$option}={$config[$option]}"; + } } + + return $dsn; } /** - * Set the connection character set and collation. + * Set the connection transaction isolation level. * * @param \PDO $connection * @param array $config * @return void */ - protected function configureEncoding($connection, $config) + protected function configureIsolationLevel($connection, array $config) { - if (! isset($config['charset'])) { - return; + if (isset($config['isolation_level'])) { + $connection->prepare("set session characteristics as transaction isolation level {$config['isolation_level']}")->execute(); } - - $connection->prepare("set names '{$config['charset']}'")->execute(); } /** @@ -132,72 +171,6 @@ protected function quoteSearchPath($searchPath) return count($searchPath) === 1 ? '"'.$searchPath[0].'"' : '"'.implode('", "', $searchPath).'"'; } - /** - * Set the application name on the connection. - * - * @param \PDO $connection - * @param array $config - * @return void - */ - protected function configureApplicationName($connection, $config) - { - if (isset($config['application_name'])) { - $applicationName = $config['application_name']; - - $connection->prepare("set application_name to '$applicationName'")->execute(); - } - } - - /** - * Create a DSN string from a configuration. - * - * @param array $config - * @return string - */ - protected function getDsn(array $config) - { - // 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; - - $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); - } - - /** - * Add the SSL options to the DSN. - * - * @param string $dsn - * @param array $config - * @return string - */ - protected function addSslOptions($dsn, array $config) - { - foreach (['sslmode', 'sslcert', 'sslkey', 'sslrootcert'] as $option) { - if (isset($config[$option])) { - $dsn .= ";{$option}={$config[$option]}"; - } - } - - return $dsn; - } - /** * Configure the synchronous_commit setting. * @@ -207,10 +180,8 @@ protected function addSslOptions($dsn, array $config) */ protected function configureSynchronousCommit($connection, array $config) { - if (! isset($config['synchronous_commit'])) { - return; + if (isset($config['synchronous_commit'])) { + $connection->prepare("set synchronous_commit to '{$config['synchronous_commit']}'")->execute(); } - - $connection->prepare("set synchronous_commit to '{$config['synchronous_commit']}'")->execute(); } } diff --git a/Connectors/SQLiteConnector.php b/Connectors/SQLiteConnector.php index ddedfbf99e..858549ec55 100755 --- a/Connectors/SQLiteConnector.php +++ b/Connectors/SQLiteConnector.php @@ -11,29 +11,139 @@ class SQLiteConnector extends Connector implements ConnectorInterface * * @param array $config * @return \PDO - * - * @throws \Illuminate\Database\SQLiteDatabaseDoesNotExistException */ 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 SQLiteDatabaseDoesNotExistException($config['database']); + 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 b6ed47d196..14cb72dbbf 100755 --- a/Connectors/SqlServerConnector.php +++ b/Connectors/SqlServerConnector.php @@ -113,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'] + : ''; } /** diff --git a/Console/DatabaseInspectionCommand.php b/Console/DatabaseInspectionCommand.php index e3391a09e6..8faab04147 100644 --- a/Console/DatabaseInspectionCommand.php +++ b/Console/DatabaseInspectionCommand.php @@ -2,165 +2,24 @@ namespace Illuminate\Database\Console; -use Doctrine\DBAL\Platforms\AbstractPlatform; use Illuminate\Console\Command; use Illuminate\Database\ConnectionInterface; -use Illuminate\Database\MySqlConnection; -use Illuminate\Database\PostgresConnection; -use Illuminate\Database\QueryException; -use Illuminate\Database\SQLiteConnection; -use Illuminate\Database\SqlServerConnection; use Illuminate\Support\Arr; -use Illuminate\Support\Composer; -use Symfony\Component\Process\Exception\ProcessSignaledException; -use Symfony\Component\Process\Exception\RuntimeException; -use Symfony\Component\Process\Process; abstract class DatabaseInspectionCommand extends Command { /** - * A map of database column types. + * Get a human-readable name for the given connection. * - * @var array - */ - protected $typeMappings = [ - 'bit' => 'string', - 'citext' => 'string', - 'enum' => 'string', - 'geometry' => 'string', - 'geomcollection' => 'string', - 'linestring' => 'string', - 'ltree' => 'string', - 'multilinestring' => 'string', - 'multipoint' => 'string', - 'multipolygon' => 'string', - 'point' => 'string', - 'polygon' => 'string', - 'sysname' => 'string', - ]; - - /** - * The Composer instance. - * - * @var \Illuminate\Support\Composer - */ - protected $composer; - - /** - * Create a new command instance. - * - * @param \Illuminate\Support\Composer|null $composer - * @return void - */ - public function __construct(Composer $composer = null) - { - parent::__construct(); - - $this->composer = $composer ?? $this->laravel->make(Composer::class); - } - - /** - * Register the custom Doctrine type mappings for inspection commands. - * - * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform - * @return void - */ - protected function registerTypeMappings(AbstractPlatform $platform) - { - foreach ($this->typeMappings as $type => $value) { - $platform->registerDoctrineTypeMapping($type, $value); - } - } - - /** - * Get a human-readable platform name for the given platform. - * - * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform + * @param \Illuminate\Database\ConnectionInterface $connection * @param string $database * @return string - */ - protected function getPlatformName(AbstractPlatform $platform, $database) - { - return match (class_basename($platform)) { - 'MySQLPlatform' => 'MySQL <= 5', - 'MySQL57Platform' => 'MySQL 5.7', - 'MySQL80Platform' => 'MySQL 8', - 'PostgreSQL100Platform', 'PostgreSQLPlatform' => 'Postgres', - 'SqlitePlatform' => 'SQLite', - 'SQLServerPlatform' => 'SQL Server', - 'SQLServer2012Platform' => 'SQL Server 2012', - default => $database, - }; - } - - /** - * Get the size of a table in bytes. - * - * @param \Illuminate\Database\ConnectionInterface $connection - * @param string $table - * @return int|null - */ - protected function getTableSize(ConnectionInterface $connection, string $table) - { - return match (true) { - $connection instanceof MySqlConnection => $this->getMySQLTableSize($connection, $table), - $connection instanceof PostgresConnection => $this->getPostgresTableSize($connection, $table), - $connection instanceof SQLiteConnection => $this->getSqliteTableSize($connection, $table), - default => null, - }; - } - - /** - * Get the size of a MySQL table in bytes. * - * @param \Illuminate\Database\ConnectionInterface $connection - * @param string $table - * @return mixed + * @deprecated */ - protected function getMySQLTableSize(ConnectionInterface $connection, string $table) + protected function getConnectionName(ConnectionInterface $connection, $database) { - $result = $connection->selectOne('SELECT (data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?', [ - $connection->getDatabaseName(), - $table, - ]); - - return Arr::wrap((array) $result)['size']; - } - - /** - * Get the size of a Postgres table in bytes. - * - * @param \Illuminate\Database\ConnectionInterface $connection - * @param string $table - * @return mixed - */ - protected function getPostgresTableSize(ConnectionInterface $connection, string $table) - { - $result = $connection->selectOne('SELECT pg_total_relation_size(?) AS size;', [ - $table, - ]); - - return Arr::wrap((array) $result)['size']; - } - - /** - * Get the size of a SQLite table in bytes. - * - * @param \Illuminate\Database\ConnectionInterface $connection - * @param string $table - * @return mixed - */ - protected function getSqliteTableSize(ConnectionInterface $connection, string $table) - { - try { - $result = $connection->selectOne('SELECT SUM(pgsize) AS size FROM dbstat WHERE name=?', [ - $table, - ]); - - return Arr::wrap((array) $result)['size']; - } catch (QueryException $e) { - return null; - } + return $connection->getDriverTitle(); } /** @@ -168,27 +27,18 @@ protected function getSqliteTableSize(ConnectionInterface $connection, string $t * * @param \Illuminate\Database\ConnectionInterface $connection * @return int|null + * + * @deprecated */ protected function getConnectionCount(ConnectionInterface $connection) { - $result = match (true) { - $connection instanceof MySqlConnection => $connection->selectOne('show status where variable_name = "threads_connected"'), - $connection instanceof PostgresConnection => $connection->selectOne('select count(*) AS "Value" from pg_stat_activity'), - $connection instanceof SqlServerConnection => $connection->selectOne('SELECT COUNT(*) Value FROM sys.dm_exec_sessions WHERE status = ?', ['running']), - default => null, - }; - - if (! $result) { - return null; - } - - return Arr::wrap((array) $result)['Value']; + return $connection->threadCount(); } /** * Get the connection configuration details for the given connection. * - * @param string $database + * @param string|null $database * @return array */ protected function getConfigFromDatabase($database) @@ -197,50 +47,4 @@ protected function getConfigFromDatabase($database) return Arr::except(config('database.connections.'.$database), ['password']); } - - /** - * Ensure the dependencies for the database commands are available. - * - * @return bool - */ - protected function ensureDependenciesExist() - { - return tap(interface_exists('Doctrine\DBAL\Driver'), function ($dependenciesExist) { - if (! $dependenciesExist && $this->components->confirm('Inspecting database information requires the Doctrine DBAL (doctrine/dbal) package. Would you like to install it?')) { - $this->installDependencies(); - } - }); - } - - /** - * Install the command's dependencies. - * - * @return void - * - * @throws \Symfony\Component\Process\Exception\ProcessSignaledException - */ - protected function installDependencies() - { - $command = collect($this->composer->findComposer()) - ->push('require doctrine/dbal') - ->implode(' '); - - $process = Process::fromShellCommandline($command, null, null, null, null); - - if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { - try { - $process->setTty(true); - } catch (RuntimeException $e) { - $this->components->warn($e->getMessage()); - } - } - - try { - $process->run(fn ($type, $line) => $this->output->write($line)); - } catch (ProcessSignaledException $e) { - if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { - throw $e; - } - } - } } diff --git a/Console/DbCommand.php b/Console/DbCommand.php index caecafe3a6..3017607355 100644 --- a/Console/DbCommand.php +++ b/Console/DbCommand.php @@ -4,9 +4,12 @@ use Illuminate\Console\Command; use Illuminate\Support\ConfigurationUrlParser; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; use UnexpectedValueException; +#[AsCommand(name: 'db')] class DbCommand extends Command { /** @@ -42,13 +45,21 @@ public function handle() return Command::FAILURE; } - (new Process( - array_merge([$this->getCommand($connection)], $this->commandArguments($connection)), - null, - $this->commandEnvironment($connection) - ))->setTimeout(null)->setTty(true)->mustRun(function ($type, $buffer) { - $this->output->write($buffer); - }); + 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; } @@ -131,6 +142,7 @@ public function getCommand(array $connection) { return [ 'mysql' => 'mysql', + 'mariadb' => 'mariadb', 'pgsql' => 'psql', 'sqlite' => 'sqlite3', 'sqlsrv' => 'sqlcmd', @@ -145,15 +157,32 @@ public function getCommand(array $connection) */ 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([ - 'password' => '--password='.$connection['password'], - 'unix_socket' => '--socket='.($connection['unix_socket'] ?? ''), - 'charset' => '--default-character-set='.($connection['charset'] ?? ''), - ], $connection), [$connection['database']]); + ], $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); } /** @@ -192,6 +221,7 @@ protected function getSqlsrvArguments(array $connection) 'password' => ['-P', $connection['password']], 'host' => ['-S', 'tcp:'.$connection['host'] .($connection['port'] ? ','.$connection['port'] : ''), ], + 'trust_server_certificate' => ['-C'], ], $connection)); } diff --git a/Console/DumpCommand.php b/Console/DumpCommand.php index e493377933..fea8fc0554 100644 --- a/Console/DumpCommand.php +++ b/Console/DumpCommand.php @@ -3,9 +3,11 @@ namespace Illuminate\Database\Console; use Illuminate\Console\Command; +use Illuminate\Console\Prohibitable; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\Connection; use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Events\MigrationsPruned; use Illuminate\Database\Events\SchemaDumped; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Facades\Config; @@ -14,6 +16,8 @@ #[AsCommand(name: 'schema:dump')] class DumpCommand extends Command { + use Prohibitable; + /** * The console command name. * @@ -36,10 +40,14 @@ class DumpCommand extends Command * * @param \Illuminate\Database\ConnectionResolverInterface $connections * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @return int + * @return void */ public function handle(ConnectionResolverInterface $connections, Dispatcher $dispatcher) { + if ($this->isProhibited()) { + return Command::FAILURE; + } + $connection = $connections->connection($database = $this->input->getOption('database')); $this->schemaState($connection)->dump( @@ -52,10 +60,12 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis if ($this->option('prune')) { (new Filesystem)->deleteDirectory( - database_path('migrations'), $preserve = false + $path = database_path('migrations'), preserve: false ); $info .= ' and pruned'; + + $dispatcher->dispatch(new MigrationsPruned($connection, $path)); } $this->components->info($info.' successfully.'); @@ -69,11 +79,15 @@ public function handle(ConnectionResolverInterface $connections, Dispatcher $dis */ protected function schemaState(Connection $connection) { + $migrations = Config::get('database.migrations', 'migrations'); + + $migrationTable = is_array($migrations) ? ($migrations['table'] ?? 'migrations') : $migrations; + return $connection->getSchemaState() - ->withMigrationTable($connection->getTablePrefix().Config::get('database.migrations', 'migrations')) - ->handleOutputUsing(function ($type, $buffer) { - $this->output->write($buffer); - }); + ->withMigrationTable($migrationTable) + ->handleOutputUsing(function ($type, $buffer) { + $this->output->write($buffer); + }); } /** @@ -83,7 +97,7 @@ protected function schemaState(Connection $connection) */ protected function path(Connection $connection) { - return tap($this->option('path') ?: database_path('schema/'.$connection->getName().'-schema.dump'), function ($path) { + 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 ba8df30531..6d080a1439 100644 --- a/Console/Factories/FactoryMakeCommand.php +++ b/Console/Factories/FactoryMakeCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\GeneratorCommand; use Illuminate\Support\Str; +use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; @@ -65,8 +66,8 @@ protected function buildClass($name) $factory = class_basename(Str::ucfirst(str_replace('Factory', '', $name))); $namespaceModel = $this->option('model') - ? $this->qualifyModel($this->option('model')) - : $this->qualifyModel($this->guessModelName($name)); + ? $this->qualifyModel($this->option('model')) + : $this->qualifyModel($this->guessModelName($name)); $model = class_basename($namespaceModel); @@ -99,7 +100,7 @@ protected function buildClass($name) */ protected function getPath($name) { - $name = (string) Str::of($name)->replaceFirst($this->rootNamespace(), '')->finish('Factory'); + $name = (new Stringable($name))->replaceFirst($this->rootNamespace(), '')->finish('Factory')->value(); return $this->laravel->databasePath().'/factories/'.str_replace('\\', '/', $name).'.php'; } diff --git a/Console/Factories/stubs/factory.stub b/Console/Factories/stubs/factory.stub index 0759b5d2ac..f931493a27 100644 --- a/Console/Factories/stubs/factory.stub +++ b/Console/Factories/stubs/factory.stub @@ -14,7 +14,7 @@ class {{ factory }}Factory extends Factory * * @return array */ - public function definition() + 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 e319e74bc0..723d3c2298 100644 --- a/Console/Migrations/FreshCommand.php +++ b/Console/Migrations/FreshCommand.php @@ -4,13 +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. @@ -26,6 +30,25 @@ 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. * @@ -33,20 +56,25 @@ class FreshCommand extends Command */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } $database = $this->input->getOption('database'); - $this->newLine(); + $this->migrator->usingConnection($database, function () use ($database) { + if ($this->migrator->repositoryExists()) { + $this->newLine(); - $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->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->newLine(); @@ -61,7 +89,7 @@ public function handle() if ($this->laravel->bound(Dispatcher::class)) { $this->laravel[Dispatcher::class]->dispatch( - new DatabaseRefreshed + new DatabaseRefreshed($database, $this->needsSeeding()) ); } diff --git a/Console/Migrations/InstallCommand.php b/Console/Migrations/InstallCommand.php index 901a83babb..b89cd4b4e8 100755 --- a/Console/Migrations/InstallCommand.php +++ b/Console/Migrations/InstallCommand.php @@ -4,8 +4,10 @@ use Illuminate\Console\Command; 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,7 +52,9 @@ public function handle() { $this->repository->setSource($this->input->getOption('database')); - $this->repository->createRepository(); + if (! $this->repository->repositoryExists()) { + $this->repository->createRepository(); + } $this->components->info('Migration table created successfully.'); } diff --git a/Console/Migrations/MigrateCommand.php b/Console/Migrations/MigrateCommand.php index fc43bf5232..e3116ca3d1 100755 --- a/Console/Migrations/MigrateCommand.php +++ b/Console/Migrations/MigrateCommand.php @@ -9,9 +9,15 @@ 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; +use function Laravel\Prompts\confirm; + +#[AsCommand(name: 'migrate')] class MigrateCommand extends BaseCommand implements Isolatable { use ConfirmableTrait; @@ -29,7 +35,8 @@ class MigrateCommand extends BaseCommand implements Isolatable {--pretend : Dump the SQL queries that would be run} {--seed : Indicates if the seed task should be re-run} {--seeder= : The class name of the root seeder} - {--step : Force the migrations to be run so they can be rolled back individually}'; + {--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. @@ -57,7 +64,6 @@ class MigrateCommand extends BaseCommand implements Isolatable * * @param \Illuminate\Database\Migrations\Migrator $migrator * @param \Illuminate\Contracts\Events\Dispatcher $dispatcher - * @return void */ public function __construct(Migrator $migrator, Dispatcher $dispatcher) { @@ -71,6 +77,8 @@ public function __construct(Migrator $migrator, Dispatcher $dispatcher) * Execute the console command. * * @return int + * + * @throws \Throwable */ public function handle() { @@ -78,17 +86,39 @@ public function handle() return 1; } + try { + $this->runMigrations(); + } catch (Throwable $e) { + if ($this->option('graceful')) { + $this->components->warn($e->getMessage()); + + return 0; + } + + throw $e; + } + + 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. - $migrations = $this->migrator->setOutput($this->output) - ->run($this->getMigrationPaths(), [ - 'pretend' => $this->option('pretend'), - 'step' => $this->option('step'), - ]); + $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 @@ -100,8 +130,6 @@ public function handle() ]); } }); - - return 0; } /** @@ -137,33 +165,50 @@ protected function repositoryExists() { return retry(2, fn () => $this->migrator->repositoryExists(), 0, function ($e) { try { - if ($e->getPrevious() instanceof SQLiteDatabaseDoesNotExistException) { - return $this->createMissingSqliteDatbase($e->getPrevious()->path); - } - - $connection = $this->migrator->resolveConnection($this->option('database')); - - if ( - $e->getPrevious() instanceof PDOException && - $e->getPrevious()->getCode() === 1049 && - $connection->getDriverName() === 'mysql') { - return $this->createMissingMysqlDatabase($connection); - } - - return false; + 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 createMissingSqliteDatbase($path) + protected function createMissingSqliteDatabase($path) { if ($this->option('force')) { return touch($path); @@ -173,21 +218,26 @@ protected function createMissingSqliteDatbase($path) return false; } - $this->components->warn('The SQLite database does not exist: '.$path); + $this->components->warn('The SQLite database configured for this application does not exist: '.$path); - if (! $this->components->confirm('Would you like to create it?')) { - return false; + 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 database. + * Create a missing MySQL or Postgres database. * + * @param \Illuminate\Database\Connection $connection * @return bool + * + * @throws \RuntimeException */ - protected function createMissingMysqlDatabase($connection) + protected function createMissingMySqlOrPgsqlDatabase($connection) { if ($this->laravel['config']->get("database.connections.{$connection->getName()}.database") !== $connection->getDatabaseName()) { return false; @@ -200,19 +250,31 @@ protected function createMissingMysqlDatabase($connection) if (! $this->option('force') && ! $this->option('no-interaction')) { $this->components->warn("The database '{$connection->getDatabaseName()}' does not exist on the '{$connection->getName()}' connection."); - if (! $this->components->confirm('Would you like to create it?')) { - return false; + 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", null); + $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')); - return tap($freshConnection->unprepared("CREATE DATABASE IF NOT EXISTS `{$connection->getDatabaseName()}`"), function () { + 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 { diff --git a/Console/Migrations/MigrateMakeCommand.php b/Console/Migrations/MigrateMakeCommand.php index 94a193226d..ac5077f58d 100644 --- a/Console/Migrations/MigrateMakeCommand.php +++ b/Console/Migrations/MigrateMakeCommand.php @@ -2,11 +2,14 @@ namespace Illuminate\Database\Console\Migrations; +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. @@ -18,7 +21,7 @@ class MigrateMakeCommand extends BaseCommand {--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} - {--fullpath : Output the full path of the migration}'; + {--fullpath : Output the full path of the migration (Deprecated)}'; /** * The console command description. @@ -38,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; @@ -46,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) { @@ -92,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(); } /** @@ -102,7 +104,7 @@ public function handle() * @param string $name * @param string $table * @param bool $create - * @return string + * @return void */ protected function writeMigration($name, $table, $create) { @@ -110,8 +112,8 @@ protected function writeMigration($name, $table, $create) $name, $this->getMigrationPath(), $table, $create ); - if (! $this->option('fullpath')) { - $file = pathinfo($file, PATHINFO_FILENAME); + if (windows_os()) { + $file = str_replace('/', '\\', $file); } $this->components->info(sprintf('Migration [%s] created successfully.', $file)); @@ -126,10 +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(); } + + /** + * Prompt for missing input arguments using the returned questions. + * + * @return array + */ + protected function promptForMissingArgumentsUsing() + { + 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 2073cd9977..7d74f5b38c 100755 --- a/Console/Migrations/RefreshCommand.php +++ b/Console/Migrations/RefreshCommand.php @@ -4,13 +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. @@ -33,8 +36,9 @@ class RefreshCommand extends Command */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } // Next we'll gather some of the options so that we can have the right options @@ -67,7 +71,7 @@ public function handle() if ($this->laravel->bound(Dispatcher::class)) { $this->laravel[Dispatcher::class]->dispatch( - new DatabaseRefreshed + new DatabaseRefreshed($database, $this->needsSeeding()) ); } diff --git a/Console/Migrations/ResetCommand.php b/Console/Migrations/ResetCommand.php index c5952fa053..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) { @@ -51,8 +54,9 @@ public function __construct(Migrator $migrator) */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } return $this->migrator->usingConnection($this->option('database'), function () { diff --git a/Console/Migrations/RollbackCommand.php b/Console/Migrations/RollbackCommand.php index b9385bcaa0..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) { @@ -51,8 +54,9 @@ public function __construct(Migrator $migrator) */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } $this->migrator->usingConnection($this->option('database'), function () { diff --git a/Console/Migrations/StatusCommand.php b/Console/Migrations/StatusCommand.php index 60ad9dc19a..cbb16a133c 100644 --- a/Console/Migrations/StatusCommand.php +++ b/Console/Migrations/StatusCommand.php @@ -4,8 +4,11 @@ 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 { /** @@ -33,7 +36,6 @@ class StatusCommand extends BaseCommand * Create a new migration rollback command instance. * * @param \Illuminate\Database\Migrations\Migrator $migrator - * @return void */ public function __construct(Migrator $migrator) { @@ -60,19 +62,31 @@ public function handle() $batches = $this->migrator->getRepository()->getMigrationBatches(); - if (count($migrations = $this->getStatusFor($ran, $batches)) > 0) { + $migrations = $this->getStatusFor($ran, $batches) + ->when($this->option('pending') !== false, fn ($collection) => $collection->filter(function ($migration) { + return (new Stringable($migration[1]))->contains('Pending'); + })); + + if (count($migrations) > 0) { $this->newLine(); $this->components->twoColumnDetail('Migration name', 'Batch / Status'); - $migrations->each( - fn ($migration) => $this->components->twoColumnDetail($migration[0], $migration[1]) - ); + $migrations + ->each( + fn ($migration) => $this->components->twoColumnDetail($migration[0], $migration[1]) + ); $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'); + } }); } @@ -85,20 +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 (new Collection($this->getAllMigrationFiles())) + ->map(function ($migration) use ($ran, $batches) { + $migrationName = $this->migrator->getMigrationName($migration); - $status = in_array($migrationName, $ran) - ? 'Ran' - : 'Pending'; + $status = in_array($migrationName, $ran) + ? 'Ran' + : 'Pending'; - if (in_array($migrationName, $ran)) { - $status = '['.$batches[$migrationName].'] '.$status; - } + if (in_array($migrationName, $ran)) { + $status = '['.$batches[$migrationName].'] '.$status; + } - return [$migrationName, $status]; - }); + return [$migrationName, $status]; + }); } /** @@ -120,9 +134,8 @@ protected function getOptions() { return [ ['database', null, InputOption::VALUE_OPTIONAL, 'The database connection 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 index 5d0f3edcbd..334422b6c4 100644 --- a/Console/MonitorCommand.php +++ b/Console/MonitorCommand.php @@ -5,7 +5,7 @@ use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Events\DatabaseBusy; -use Illuminate\Support\Composer; +use Illuminate\Support\Collection; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'db:monitor')] @@ -20,17 +20,6 @@ class MonitorCommand extends DatabaseInspectionCommand {--databases= : The database connections to monitor} {--max= : The maximum number of connections that can be open before an event is dispatched}'; - /** - * The name of the console command. - * - * This name is used to identify the command during lazy loading. - * - * @var string|null - * - * @deprecated - */ - protected static $defaultName = 'db:monitor'; - /** * The console command description. * @@ -57,11 +46,10 @@ class MonitorCommand extends DatabaseInspectionCommand * * @param \Illuminate\Database\ConnectionResolverInterface $connection * @param \Illuminate\Contracts\Events\Dispatcher $events - * @param \Illuminate\Support\Composer $composer */ - public function __construct(ConnectionResolverInterface $connection, Dispatcher $events, Composer $composer) + public function __construct(ConnectionResolverInterface $connection, Dispatcher $events) { - parent::__construct($composer); + parent::__construct(); $this->connection = $connection; $this->events = $events; @@ -91,16 +79,18 @@ public function handle() */ protected function parseDatabases($databases) { - return collect(explode(',', $databases))->map(function ($database) { + 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 = $this->getConnectionCount($this->connection->connection($database)), + 'connections' => $connections, 'status' => $maxConnections && $connections >= $maxConnections ? 'ALERT' : 'OK', ]; }); diff --git a/Console/PruneCommand.php b/Console/PruneCommand.php index 7ea6cecdd3..527ab70c6f 100644 --- a/Console/PruneCommand.php +++ b/Console/PruneCommand.php @@ -4,14 +4,17 @@ use Illuminate\Console\Command; use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Database\Eloquent\MassPrunable; -use Illuminate\Database\Eloquent\Prunable; -use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Events\ModelPruningFinished; +use Illuminate\Database\Events\ModelPruningStarting; use Illuminate\Database\Events\ModelsPruned; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use InvalidArgumentException; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Finder\Finder; +#[AsCommand(name: 'model:prune')] class PruneCommand extends Command { /** @@ -22,6 +25,7 @@ class PruneCommand extends Command protected $signature = 'model:prune {--model=* : Class names of the models to be pruned} {--except=* : Class names of the models to be excluded from pruning} + {--path=* : Absolute path(s) to directories where models are located} {--chunk=1000 : The number of models to retrieve per chunk of models to be deleted} {--pretend : Display the number of prunable records found instead of deleting them}'; @@ -70,10 +74,14 @@ public function handle(Dispatcher $events) $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); } @@ -91,7 +99,7 @@ protected function pruneModel(string $model) ? $instance->prunableChunkSize : $this->option('chunk'); - $total = $this->isPrunable($model) + $total = $model::isPrunable() ? $instance->pruneAll($chunkSize) : 0; @@ -104,22 +112,25 @@ protected function pruneModel(string $model) * Determine the models that should be pruned. * * @return \Illuminate\Support\Collection + * + * @throws \InvalidArgumentException */ protected function models() { - if (! empty($models = $this->option('model'))) { - return collect($models)->filter(function ($model) { - return class_exists($model); - })->values(); - } - + $models = $this->option('model'); $except = $this->option('except'); - if (! empty($models) && ! empty($except)) { + if ($models && $except) { throw new InvalidArgumentException('The --models and --except options cannot be combined.'); } - return collect((new Finder)->in($this->getDefaultPath())->files()->name('*.php')) + 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(); @@ -128,44 +139,32 @@ protected function models() ['\\', ''], Str::after($model->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) ); - })->when(! empty($except), function ($models) use ($except) { - return $models->reject(function ($model) use ($except) { - return in_array($model, $except); - }); - })->filter(function ($model) { - return $this->isPrunable($model); - })->filter(function ($model) { - return class_exists($model); - })->values(); + }) + ->when(! empty($except), fn ($models) => $models->reject(fn ($model) => in_array($model, $except))) + ->filter(fn ($model) => $this->isPrunable($model)) + ->values(); } /** - * Get the default path where models are located. + * Get the path where models are located. * - * @return string|string[] + * @return string[]|string */ - protected function getDefaultPath() + protected function getPath() { - return app_path('Models'); - } - - /** - * Determine if the given model class is prunable. - * - * @param string $model - * @return bool - */ - protected function isPrunable($model) - { - $uses = class_uses_recursive($model); + if (! empty($path = $this->option('path'))) { + return (new Collection($path)) + ->map(fn ($path) => base_path($path)) + ->all(); + } - return in_array(Prunable::class, $uses) || in_array(MassPrunable::class, $uses); + return app_path('Models'); } /** * Display how many models will be pruned. * - * @param string $model + * @param class-string $model * @return void */ protected function pretendToPrune($model) @@ -173,7 +172,7 @@ protected function pretendToPrune($model) $instance = new $model; $count = $instance->prunable() - ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($instance))), function ($query) { + ->when($model::isSoftDeletable(), function ($query) { $query->withTrashed(); })->count(); @@ -183,4 +182,18 @@ protected function pretendToPrune($model) $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 0d4dbd5ad4..515ff410b3 100644 --- a/Console/Seeds/SeedCommand.php +++ b/Console/Seeds/SeedCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Console\Prohibitable; use Illuminate\Database\ConnectionResolverInterface as Resolver; use Illuminate\Database\Eloquent\Model; use Symfony\Component\Console\Attribute\AsCommand; @@ -13,7 +14,7 @@ #[AsCommand(name: 'db:seed')] class SeedCommand extends Command { - use ConfirmableTrait; + use ConfirmableTrait, Prohibitable; /** * The console command name. @@ -40,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) { @@ -56,8 +56,9 @@ public function __construct(Resolver $resolver) */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } $this->components->info('Seeding database.'); @@ -96,8 +97,8 @@ protected function getSeeder() } return $this->laravel->make($class) - ->setContainer($this->laravel) - ->setCommand($this); + ->setContainer($this->laravel) + ->setCommand($this); } /** diff --git a/Console/Seeds/stubs/seeder.stub b/Console/Seeds/stubs/seeder.stub index 19ae5f5eb2..8b5403f38d 100644 --- a/Console/Seeds/stubs/seeder.stub +++ b/Console/Seeds/stubs/seeder.stub @@ -9,10 +9,8 @@ class {{ class }} extends Seeder { /** * Run the database seeds. - * - * @return void */ - public function run() + public function run(): void { // } diff --git a/Console/ShowCommand.php b/Console/ShowCommand.php index 8fca46cafd..64c80572b9 100644 --- a/Console/ShowCommand.php +++ b/Console/ShowCommand.php @@ -2,12 +2,12 @@ namespace Illuminate\Database\Console; -use Doctrine\DBAL\Schema\AbstractSchemaManager; -use Doctrine\DBAL\Schema\Table; -use Doctrine\DBAL\Schema\View; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionResolverInterface; +use Illuminate\Database\Schema\Builder; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Number; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'db:show')] @@ -20,8 +20,9 @@ class ShowCommand extends DatabaseInspectionCommand */ protected $signature = 'db:show {--database= : The database connection} {--json : Output the database information as JSON} - {--counts : Show the table row count Note: This can be slow on large databases }; - {--views : Show the database views Note: This can be slow on large databases }'; + {--counts : Show the table row count 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. @@ -38,27 +39,27 @@ class ShowCommand extends DatabaseInspectionCommand */ public function handle(ConnectionResolverInterface $connections) { - if (! $this->ensureDependenciesExist()) { - return 1; - } - $connection = $connections->connection($database = $this->input->getOption('database')); - $schema = $connection->getDoctrineSchemaManager(); - - $this->registerTypeMappings($schema->getDatabasePlatform()); + $schema = $connection->getSchemaBuilder(); $data = [ 'platform' => [ 'config' => $this->getConfigFromDatabase($database), - 'name' => $this->getPlatformName($schema->getDatabasePlatform(), $database), - 'open_connections' => $this->getConnectionCount($connection), + '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->collectViews($connection, $schema); + $data['views'] = $this->views($connection, $schema); + } + + if ($this->option('types')) { + $data['types'] = $this->types($connection, $schema); } $this->display($data); @@ -70,17 +71,22 @@ public function handle(ConnectionResolverInterface $connections) * Get information regarding the tables within the database. * * @param \Illuminate\Database\ConnectionInterface $connection - * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema + * @param \Illuminate\Database\Schema\Builder $schema * @return \Illuminate\Support\Collection */ - protected function tables(ConnectionInterface $connection, AbstractSchemaManager $schema) + protected function tables(ConnectionInterface $connection, Builder $schema) { - return collect($schema->listTables())->map(fn (Table $table, $index) => [ - 'table' => $table->getName(), - 'size' => $this->getTableSize($connection, $table->getName()), - 'rows' => $this->option('counts') ? $connection->table($table->getName())->count() : null, - 'engine' => rescue(fn () => $table->getOption('engine'), null, false), - 'comment' => $table->getComment(), + 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'], ]); } @@ -88,17 +94,34 @@ protected function tables(ConnectionInterface $connection, AbstractSchemaManager * Get information regarding the views within the database. * * @param \Illuminate\Database\ConnectionInterface $connection - * @param \Doctrine\DBAL\Schema\AbstractSchemaManager $schema + * @param \Illuminate\Database\Schema\Builder $schema * @return \Illuminate\Support\Collection */ - protected function collectViews(ConnectionInterface $connection, AbstractSchemaManager $schema) + protected function views(ConnectionInterface $connection, Builder $schema) { - return collect($schema->listViews()) - ->reject(fn (View $view) => str($view->getName()) - ->startsWith(['pg_catalog', 'information_schema', 'spt_'])) - ->map(fn (View $view) => [ - 'view' => $view->getName(), - 'rows' => $connection->table($view->getName())->count(), + 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'], ]); } @@ -135,10 +158,12 @@ 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'].''); + $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')); @@ -148,22 +173,25 @@ protected function displayForCli(array $data) $this->components->twoColumnDetail('Tables', $tables->count()); if ($tableSizeSum = $tables->sum('size')) { - $this->components->twoColumnDetail('Total Size', number_format($tableSizeSum / 1024 / 1024, 2).'MiB'); + $this->components->twoColumnDetail('Total Size', Number::fileSize($tableSizeSum, 2)); } $this->newLine(); if ($tables->isNotEmpty()) { - $this->components->twoColumnDetail('Table', 'Size (MiB)'.($this->option('counts') ? ' / Rows' : '')); + $hasSchema = ! is_null($tables->first()['schema']); + + $this->components->twoColumnDetail( + ($hasSchema ? 'Schema / ' : '').'Table', + 'Size'.($this->option('counts') ? ' / Rows' : '') + ); $tables->each(function ($table) { - if ($tableSize = $table['size']) { - $tableSize = number_format($tableSize / 1024 / 1024, 2); - } + $tableSize = is_null($table['size']) ? null : Number::fileSize($table['size'], 2); $this->components->twoColumnDetail( - $table['table'].($this->output->isVerbose() ? ' '.$table['engine'].'' : null), - ($tableSize ? $tableSize : '—').($this->option('counts') ? ' / '.number_format($table['rows']).'' : '') + ($table['schema'] ? $table['schema'].' / ' : '').$table['table'].($this->output->isVerbose() ? ' '.$table['engine'].'' : null), + ($tableSize ?? '—').($this->option('counts') ? ' / '.Number::format($table['rows']).'' : '') ); if ($this->output->isVerbose()) { @@ -179,9 +207,33 @@ protected function displayForCli(array $data) } if ($views && $views->isNotEmpty()) { - $this->components->twoColumnDetail('View', 'Rows'); + $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' + ); - $views->each(fn ($view) => $this->components->twoColumnDetail($view['view'], number_format($view['rows']))); + $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 index 3b08bde064..ecfa00a9e1 100644 --- a/Console/TableCommand.php +++ b/Console/TableCommand.php @@ -2,14 +2,15 @@ namespace Illuminate\Database\Console; -use Doctrine\DBAL\Schema\Column; -use Doctrine\DBAL\Schema\ForeignKeyConstraint; -use Doctrine\DBAL\Schema\Index; -use Doctrine\DBAL\Schema\Table; use Illuminate\Database\ConnectionResolverInterface; -use Illuminate\Support\Str; +use Illuminate\Database\Schema\Builder; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; +use Illuminate\Support\Number; use Symfony\Component\Console\Attribute\AsCommand; +use function Laravel\Prompts\search; + #[AsCommand(name: 'db:table')] class TableCommand extends DatabaseInspectionCommand { @@ -37,36 +38,59 @@ class TableCommand extends DatabaseInspectionCommand */ public function handle(ConnectionResolverInterface $connections) { - if (! $this->ensureDependenciesExist()) { - return 1; - } - $connection = $connections->connection($this->input->getOption('database')); + $tables = (new Collection($connection->getSchemaBuilder()->getTables())) + ->keyBy('schema_qualified_name')->all(); - $schema = $connection->getDoctrineSchemaManager(); + $tableNames = (new Collection($tables))->keys(); - $this->registerTypeMappings($schema->getDatabasePlatform()); - - $table = $this->argument('table') ?: $this->components->choice( + $tableName = $this->argument('table') ?: search( 'Which table would you like to inspect?', - collect($schema->listTables())->flatMap(fn (Table $table) => [$table->getName()])->toArray() + fn (string $query) => $tableNames + ->filter(fn ($table) => str_contains(strtolower($table), strtolower($query))) + ->values() + ->all() ); - if (! $schema->tablesExist([$table])) { - return $this->components->warn("Table [{$table}] doesn't exist."); + $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; } - $table = $schema->listTableDetails($table); + [$columns, $indexes, $foreignKeys] = $connection->withoutTablePrefix(function ($connection) use ($table) { + $schema = $connection->getSchemaBuilder(); + $tableName = $table['schema_qualified_name']; - $columns = $this->columns($table); - $indexes = $this->indexes($table); - $foreignKeys = $this->foreignKeys($table); + return [ + $this->columns($schema, $tableName), + $this->indexes($schema, $tableName), + $this->foreignKeys($schema, $tableName), + ]; + }); $data = [ 'table' => [ - 'name' => $table->getName(), - 'columns' => $columns->count(), - 'size' => $this->getTableSize($connection, $table->getName()), + '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, @@ -81,46 +105,49 @@ public function handle(ConnectionResolverInterface $connections) /** * Get the information regarding the table's columns. * - * @param \Doctrine\DBAL\Schema\Table $table + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $table * @return \Illuminate\Support\Collection */ - protected function columns(Table $table) + protected function columns(Builder $schema, string $table) { - return collect($table->getColumns())->map(fn (Column $column) => [ - 'column' => $column->getName(), + return (new Collection($schema->getColumns($table)))->map(fn ($column) => [ + 'column' => $column['name'], 'attributes' => $this->getAttributesForColumn($column), - 'default' => $column->getDefault(), - 'type' => $column->getType()->getName(), + 'default' => $column['default'], + 'type' => $column['type'], ]); } /** * Get the attributes for a table column. * - * @param \Doctrine\DBAL\Schema\Column $column + * @param array $column * @return \Illuminate\Support\Collection */ - protected function getAttributesForColumn(Column $column) + protected function getAttributesForColumn($column) { - return collect([ - $column->getAutoincrement() ? 'autoincrement' : null, - 'type' => $column->getType()->getName(), - $column->getUnsigned() ? 'unsigned' : null, - ! $column->getNotNull() ? 'nullable' : null, - ])->filter(); + 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 \Doctrine\DBAL\Schema\Table $table + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $table * @return \Illuminate\Support\Collection */ - protected function indexes(Table $table) + protected function indexes(Builder $schema, string $table) { - return collect($table->getIndexes())->map(fn (Index $index) => [ - 'name' => $index->getName(), - 'columns' => collect($index->getColumns()), + return (new Collection($schema->getIndexes($table)))->map(fn ($index) => [ + 'name' => $index['name'], + 'columns' => new Collection($index['columns']), 'attributes' => $this->getAttributesForIndex($index), ]); } @@ -128,34 +155,36 @@ protected function indexes(Table $table) /** * Get the attributes for a table index. * - * @param \Doctrine\DBAL\Schema\Index $index + * @param array $index * @return \Illuminate\Support\Collection */ - protected function getAttributesForIndex(Index $index) + protected function getAttributesForIndex($index) { - return collect([ - 'compound' => count($index->getColumns()) > 1, - 'unique' => $index->isUnique(), - 'primary' => $index->isPrimary(), - ])->filter()->keys()->map(fn ($attribute) => Str::lower($attribute)); + 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 \Doctrine\DBAL\Schema\Table $table + * @param \Illuminate\Database\Schema\Builder $schema + * @param string $table * @return \Illuminate\Support\Collection */ - protected function foreignKeys(Table $table) + protected function foreignKeys(Builder $schema, string $table) { - return collect($table->getForeignKeys())->map(fn (ForeignKeyConstraint $foreignKey) => [ - 'name' => $foreignKey->getName(), - 'local_table' => $table->getName(), - 'local_columns' => collect($foreignKey->getLocalColumns()), - 'foreign_table' => $foreignKey->getForeignTableName(), - 'foreign_columns' => collect($foreignKey->getForeignColumns()), - 'on_update' => Str::lower(rescue(fn () => $foreignKey->getOption('onUpdate'), 'N/A')), - 'on_delete' => Str::lower(rescue(fn () => $foreignKey->getOption('onDelete'), 'N/A')), + 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'], ]); } @@ -195,11 +224,19 @@ protected function displayForCli(array $data) $this->newLine(); - $this->components->twoColumnDetail(''.$table['name'].''); + $this->components->twoColumnDetail(''.$table['schema_qualified_name'].'', $table['comment'] ? ''.$table['comment'].'' : null); $this->components->twoColumnDetail('Columns', $table['columns']); - if ($size = $table['size']) { - $this->components->twoColumnDetail('Size', number_format($size / 1024 / 1024, 2).'MiB'); + 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(); @@ -210,7 +247,7 @@ protected function displayForCli(array $data) $columns->each(function ($column) { $this->components->twoColumnDetail( $column['column'].' '.$column['attributes']->implode(', ').'', - ($column['default'] ? ''.$column['default'].' ' : '').''.$column['type'].'' + (! is_null($column['default']) ? ''.$column['default'].' ' : '').$column['type'] ); }); @@ -235,7 +272,7 @@ protected function displayForCli(array $data) $foreignKeys->each(function ($foreignKey) { $this->components->twoColumnDetail( - $foreignKey['name'].' '.$foreignKey['local_columns']->implode(', ').' references '.$foreignKey['foreign_columns']->implode(', ').' on '.$foreignKey['foreign_table'].'', + $foreignKey['name'].' '.$foreignKey['columns']->implode(', ').' references '.$foreignKey['foreign_columns']->implode(', ').' on '.$foreignKey['foreign_table'].'', $foreignKey['on_update'].' / '.$foreignKey['on_delete'], ); }); diff --git a/Console/WipeCommand.php b/Console/WipeCommand.php index e9c8e956d0..d638db41d0 100644 --- a/Console/WipeCommand.php +++ b/Console/WipeCommand.php @@ -4,13 +4,14 @@ use Illuminate\Console\Command; use Illuminate\Console\ConfirmableTrait; +use Illuminate\Console\Prohibitable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'db:wipe')] class WipeCommand extends Command { - use ConfirmableTrait; + use ConfirmableTrait, Prohibitable; /** * The console command name. @@ -33,8 +34,9 @@ class WipeCommand extends Command */ public function handle() { - if (! $this->confirmToProceed()) { - return 1; + if ($this->isProhibited() || + ! $this->confirmToProceed()) { + return Command::FAILURE; } $database = $this->input->getOption('database'); @@ -55,6 +57,8 @@ public function handle() $this->components->info('Dropped all types successfully.'); } + $this->flushDatabaseConnection($database); + return 0; } @@ -67,8 +71,8 @@ public function handle() protected function dropAllTables($database) { $this->laravel['db']->connection($database) - ->getSchemaBuilder() - ->dropAllTables(); + ->getSchemaBuilder() + ->dropAllTables(); } /** @@ -80,8 +84,8 @@ protected function dropAllTables($database) protected function dropAllViews($database) { $this->laravel['db']->connection($database) - ->getSchemaBuilder() - ->dropAllViews(); + ->getSchemaBuilder() + ->dropAllViews(); } /** @@ -93,8 +97,19 @@ protected function dropAllViews($database) protected function dropAllTypes($database) { $this->laravel['db']->connection($database) - ->getSchemaBuilder() - ->dropAllTypes(); + ->getSchemaBuilder() + ->dropAllTypes(); + } + + /** + * Flush the given database connection. + * + * @param string $database + * @return void + */ + protected function flushDatabaseConnection($database) + { + $this->laravel['db']->connection($database)->disconnect(); } /** diff --git a/DBAL/TimestampType.php b/DBAL/TimestampType.php deleted file mode 100644 index 409e7d7975..0000000000 --- a/DBAL/TimestampType.php +++ /dev/null @@ -1,100 +0,0 @@ - $this->getMySqlPlatformSQLDeclaration($column), - PostgreSQLPlatform::class, - PostgreSQL94Platform::class, - PostgreSQL100Platform::class => $this->getPostgresPlatformSQLDeclaration($column), - SQLServerPlatform::class, - SQLServer2012Platform::class => $this->getSqlServerPlatformSQLDeclaration($column), - SqlitePlatform::class => 'DATETIME', - default => throw new DBALException('Invalid platform: '.substr(strrchr(get_class($platform), '\\'), 1)), - }; - } - - /** - * Get the SQL declaration for MySQL. - * - * @param array $column - * @return string - */ - protected function getMySqlPlatformSQLDeclaration(array $column): string - { - $columnType = 'TIMESTAMP'; - - if ($column['precision']) { - $columnType = 'TIMESTAMP('.min((int) $column['precision'], 6).')'; - } - - $notNull = $column['notnull'] ?? false; - - if (! $notNull) { - return $columnType.' NULL'; - } - - return $columnType; - } - - /** - * Get the SQL declaration for PostgreSQL. - * - * @param array $column - * @return string - */ - protected function getPostgresPlatformSQLDeclaration(array $column): string - { - return 'TIMESTAMP('.min((int) $column['precision'], 6).')'; - } - - /** - * Get the SQL declaration for SQL Server. - * - * @param array $column - * @return string - */ - protected function getSqlServerPlatformSQLDeclaration(array $column): string - { - return $column['precision'] ?? false - ? 'DATETIME2('.min((int) $column['precision'], 7).')' - : 'DATETIME'; - } - - /** - * {@inheritdoc} - */ - public function getName() - { - return 'timestamp'; - } -} diff --git a/DatabaseManager.php b/DatabaseManager.php index 4f99071191..2d8edbc533 100755 --- a/DatabaseManager.php +++ b/DatabaseManager.php @@ -2,9 +2,10 @@ namespace Illuminate\Database; -use Doctrine\DBAL\Types\Type; 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; @@ -12,6 +13,8 @@ use PDO; use RuntimeException; +use function Illuminate\Support\enum_value; + /** * @mixin \Illuminate\Database\Connection */ @@ -42,6 +45,13 @@ class DatabaseManager implements ConnectionResolverInterface */ protected $connections = []; + /** + * The dynamically configured (DB::build) connection configurations. + * + * @var array + */ + protected $dynamicConnectionConfigurations = []; + /** * The custom connection resolvers. * @@ -56,19 +66,11 @@ class DatabaseManager implements ConnectionResolverInterface */ protected $reconnector; - /** - * The custom Doctrine column types. - * - * @var array - */ - protected $doctrineTypes = []; - /** * 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) { @@ -76,21 +78,21 @@ public function __construct($app, ConnectionFactory $factory) $this->factory = $factory; $this->reconnector = function ($connection) { - $this->reconnect($connection->getNameWithReadWriteType()); + $connection->setPdo( + $this->reconnect($connection->getNameWithReadWriteType())->getRawPdo() + ); }; } /** * Get a database connection instance. * - * @param string|null $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 @@ -99,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. * @@ -112,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]; } /** @@ -155,19 +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))) { + $config = $this->dynamicConnectionConfigurations[$name] ?? Arr::get($connections, $name); + + if (is_null($config)) { throw new InvalidArgumentException("Database connection [{$name}] not configured."); } return (new ConfigurationUrlParser) - ->parseConfiguration($config); + ->parseConfiguration($config); } /** @@ -197,83 +254,53 @@ protected function configure(Connection $connection, $type) // the connection, which will allow us to reconnect from the connections. $connection->setReconnector($this->reconnector); - $this->registerConfiguredDoctrineTypes($connection); - return $connection; } /** - * Prepare the read / write mode for database connection instance. - * - * @param \Illuminate\Database\Connection $connection - * @param string|null $type - * @return \Illuminate\Database\Connection - */ - protected function setPdoForType(Connection $connection, $type = null) - { - if ($type === 'read') { - $connection->setPdo($connection->getReadPdo()); - } elseif ($type === 'write') { - $connection->setReadPdo($connection->getPdo()); - } - - return $connection; - } - - /** - * Register custom Doctrine types with the connection. + * Dispatch the ConnectionEstablished event if the event dispatcher is available. * * @param \Illuminate\Database\Connection $connection * @return void */ - protected function registerConfiguredDoctrineTypes(Connection $connection): void + protected function dispatchConnectionEstablishedEvent(Connection $connection) { - foreach ($this->app['config']->get('database.dbal.types', []) as $name => $class) { - $this->registerDoctrineType($class, $name, $name); + if (! $this->app->bound('events')) { + return; } - foreach ($this->doctrineTypes as $name => [$type, $class]) { - $connection->registerDoctrineType($class, $name, $type); - } + $this->app['events']->dispatch( + new ConnectionEstablished($connection) + ); } /** - * Register a custom Doctrine type. - * - * @param string $class - * @param string $name - * @param string $type - * @return void + * Prepare the read / write mode for database connection instance. * - * @throws \Doctrine\DBAL\DBALException - * @throws \RuntimeException + * @param \Illuminate\Database\Connection $connection + * @param string|null $type + * @return \Illuminate\Database\Connection */ - public function registerDoctrineType(string $class, string $name, string $type): void + protected function setPdoForType(Connection $connection, $type = null) { - if (! class_exists('Doctrine\DBAL\Connection')) { - throw new RuntimeException( - 'Registering a custom Doctrine type requires Doctrine DBAL (doctrine/dbal).' - ); - } - - if (! Type::hasType($name)) { - Type::addType($name, $class); + if ($type === 'read') { + $connection->setPdo($connection->getReadPdo()); + } elseif ($type === 'write') { + $connection->setReadPdo($connection->getPdo()); } - $this->doctrineTypes[$name] = [$type, $class]; + return $connection; } /** * Disconnect from the given database and remove from local cache. * - * @param string|null $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]); } @@ -281,12 +308,12 @@ public function purge($name = null) /** * Disconnect from the given database. * - * @param string|null $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(); } } @@ -294,24 +321,26 @@ public function disconnect($name = null) /** * Reconnect to the given database. * - * @param string|null $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 string $name + * @param \UnitEnum|string $name * @param callable $callback * @return mixed */ @@ -319,11 +348,13 @@ public function usingConnection($name, callable $callback) { $previousName = $this->getDefaultConnection(); - $this->setDefaultConnection($name); + $this->setDefaultConnection($name = enum_value($name)); - return tap($callback(), function () use ($previousName) { + try { + return $callback(); + } finally { $this->setDefaultConnection($previousName); - }); + } } /** @@ -341,8 +372,8 @@ protected function refreshPdoConnections($name) ); return $this->connections[$name] - ->setPdo($fresh->getRawPdo()) - ->setReadPdo($fresh->getRawReadPdo()); + ->setPdo($fresh->getRawPdo()) + ->setReadPdo($fresh->getRawReadPdo()); } /** @@ -367,13 +398,13 @@ public function setDefaultConnection($name) } /** - * Get all of the support drivers. + * Get all of the supported drivers. * * @return string[] */ public function supportedDrivers() { - return ['mysql', 'pgsql', 'sqlite', 'sqlsrv']; + return ['mysql', 'mariadb', 'pgsql', 'sqlite', 'sqlsrv']; } /** diff --git a/DatabaseServiceProvider.php b/DatabaseServiceProvider.php index 9a2f47d1b7..8a6115ba1e 100755 --- a/DatabaseServiceProvider.php +++ b/DatabaseServiceProvider.php @@ -4,6 +4,8 @@ use Faker\Factory as FakerFactory; use Faker\Generator as FakerGenerator; +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; @@ -41,7 +43,7 @@ public function register() Model::clearBootedModels(); $this->registerConnectionServices(); - $this->registerEloquentFactory(); + $this->registerFakerGenerator(); $this->registerQueueableEntityResolver(); } @@ -74,18 +76,30 @@ protected function registerConnectionServices() return $app['db']->connection()->getSchemaBuilder(); }); - $this->app->singleton('db.transactions', function ($app) { + $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() { + 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'); diff --git a/DatabaseTransactionRecord.php b/DatabaseTransactionRecord.php index 4736ee9224..08fd471323 100755 --- a/DatabaseTransactionRecord.php +++ b/DatabaseTransactionRecord.php @@ -18,6 +18,13 @@ class DatabaseTransactionRecord */ public $level; + /** + * The parent instance of this transaction. + * + * @var \Illuminate\Database\DatabaseTransactionRecord + */ + public $parent; + /** * The callbacks that should be executed after committing. * @@ -25,17 +32,25 @@ class DatabaseTransactionRecord */ protected $callbacks = []; + /** + * The callbacks that should be executed after rollback. + * + * @var array + */ + protected $callbacksForRollback = []; + /** * Create a new database transaction record instance. * * @param string $connection * @param int $level - * @return void + * @param \Illuminate\Database\DatabaseTransactionRecord|null $parent */ - public function __construct($connection, $level) + public function __construct($connection, $level, ?DatabaseTransactionRecord $parent = null) { $this->connection = $connection; $this->level = $level; + $this->parent = $parent; } /** @@ -49,6 +64,17 @@ 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. * @@ -61,6 +87,18 @@ public function executeCallbacks() } } + /** + * Execute all of the callbacks for rollback. + * + * @return void + */ + public function executeCallbacksForRollback() + { + foreach ($this->callbacksForRollback as $callback) { + $callback(); + } + } + /** * Get all of the callbacks. * @@ -70,4 +108,14 @@ 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 index 8d145188f0..9713c66d82 100755 --- a/DatabaseTransactionsManager.php +++ b/DatabaseTransactionsManager.php @@ -2,30 +2,38 @@ namespace Illuminate\Database; +use Illuminate\Support\Collection; + class DatabaseTransactionsManager { /** - * All of the recorded transactions. + * All of the committed transactions. * - * @var \Illuminate\Support\Collection + * @var \Illuminate\Support\Collection */ - protected $transactions; + protected $committedTransactions; /** - * The database transaction that should be ignored by callbacks. + * All of the pending transactions. * - * @var \Illuminate\Database\DatabaseTransactionRecord + * @var \Illuminate\Support\Collection */ - protected $callbacksShouldIgnore; + protected $pendingTransactions; /** - * Create a new database transactions manager instance. + * The current transaction. * - * @return void + * @var array + */ + protected $currentTransaction = []; + + /** + * Create a new database transactions manager instance. */ public function __construct() { - $this->transactions = collect(); + $this->committedTransactions = new Collection; + $this->pendingTransactions = new Collection; } /** @@ -37,8 +45,76 @@ public function __construct() */ public function begin($connection, $level) { - $this->transactions->push( - new DatabaseTransactionRecord($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 ); } @@ -46,39 +122,78 @@ public function begin($connection, $level) * Rollback the active database transaction. * * @param string $connection - * @param int $level + * @param int $newTransactionLevel * @return void */ - public function rollback($connection, $level) + public function rollback($connection, $newTransactionLevel) { - $this->transactions = $this->transactions->reject( - fn ($transaction) => $transaction->connection == $connection && $transaction->level > $level - )->values(); + if ($newTransactionLevel === 0) { + $this->removeAllTransactionsForConnection($connection); + } else { + $this->pendingTransactions = $this->pendingTransactions->reject( + fn ($transaction) => $transaction->connection == $connection && + $transaction->level > $newTransactionLevel + )->values(); - if ($this->transactions->isEmpty()) { - $this->callbacksShouldIgnore = null; + 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 + ); + } } } /** - * Commit the active database transaction. + * Remove all pending, completed, and current transactions for the given connection name. * * @param string $connection * @return void */ - public function commit($connection) + protected function removeAllTransactionsForConnection($connection) { - [$forThisConnection, $forOtherConnections] = $this->transactions->partition( + 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->transactions = $forOtherConnections->values(); + $this->committedTransactions = $this->committedTransactions->reject( + fn ($transaction) => $transaction->connection == $connection + )->values(); + } - $forThisConnection->map->executeCallbacks(); + /** + * 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 + ); - if ($this->transactions->isEmpty()) { - $this->callbacksShouldIgnore = null; - } + // 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) + ); } /** @@ -97,37 +212,56 @@ public function addCallback($callback) } /** - * Specify that callbacks should ignore the given transaction when determining if they should be executed. + * Register a callback for transaction rollback. * - * @param \Illuminate\Database\DatabaseTransactionRecord $transaction - * @return $this + * @param callable $callback + * @return void */ - public function callbacksShouldIgnore(DatabaseTransactionRecord $transaction) + public function addCallbackForRollback($callback) { - $this->callbacksShouldIgnore = $transaction; - - return $this; + if ($current = $this->callbackApplicableTransactions()->last()) { + return $current->addCallbackForRollback($callback); + } } /** * Get the transactions that are applicable to callbacks. * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ public function callbackApplicableTransactions() { - return $this->transactions->reject(function ($transaction) { - return $transaction === $this->callbacksShouldIgnore; - })->values(); + 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 the transactions. + * Get all of the committed transactions. * * @return \Illuminate\Support\Collection */ - public function getTransactions() + public function getCommittedTransactions() { - return $this->transactions; + return $this->committedTransactions; } } diff --git a/DetectsConcurrencyErrors.php b/DetectsConcurrencyErrors.php index c6c66f4356..34659d64cd 100644 --- a/DetectsConcurrencyErrors.php +++ b/DetectsConcurrencyErrors.php @@ -2,8 +2,8 @@ namespace Illuminate\Database; -use Illuminate\Support\Str; -use PDOException; +use Illuminate\Container\Container; +use Illuminate\Contracts\Database\ConcurrencyErrorDetector as ConcurrencyErrorDetectorContract; use Throwable; trait DetectsConcurrencyErrors @@ -16,22 +16,12 @@ trait DetectsConcurrencyErrors */ protected function causedByConcurrencyError(Throwable $e) { - if ($e instanceof PDOException && ($e->getCode() === 40001 || $e->getCode() === '40001')) { - return true; - } + $container = Container::getInstance(); - $message = $e->getMessage(); + $detector = $container->bound(ConcurrencyErrorDetectorContract::class) + ? $container[ConcurrencyErrorDetectorContract::class] + : new ConcurrencyErrorDetector(); - return Str::contains($message, [ - 'Deadlock found when trying to get lock', - 'deadlock detected', - 'The database file is locked', - 'database is locked', - 'database table is locked', - 'A table in the database is locked', - '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', - ]); + return $detector->causedByConcurrencyError($e); } } diff --git a/DetectsLostConnections.php b/DetectsLostConnections.php index f0c216fe31..ba649afe2a 100644 --- a/DetectsLostConnections.php +++ b/DetectsLostConnections.php @@ -2,7 +2,8 @@ namespace Illuminate\Database; -use Illuminate\Support\Str; +use Illuminate\Container\Container; +use Illuminate\Contracts\Database\LostConnectionDetector as LostConnectionDetectorContract; use Throwable; trait DetectsLostConnections @@ -15,52 +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', - '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', - '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', - 'SSL: Broken pipe', - '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.', - ]); + $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 index 14be425afa..8bd028032e 100644 --- a/Eloquent/BroadcastableModelEventOccurred.php +++ b/Eloquent/BroadcastableModelEventOccurred.php @@ -6,6 +6,7 @@ use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Collection as BaseCollection; class BroadcastableModelEventOccurred implements ShouldBroadcast { @@ -46,12 +47,18 @@ class BroadcastableModelEventOccurred implements ShouldBroadcast */ public $queue; + /** + * Indicates whether the job should be dispatched after all database transactions have committed. + * + * @var bool|null + */ + public $afterCommit; + /** * Create a new event instance. * * @param \Illuminate\Database\Eloquent\Model $model * @param string $event - * @return void */ public function __construct($model, $event) { @@ -67,12 +74,12 @@ public function __construct($model, $event) public function broadcastOn() { $channels = empty($this->channels) - ? ($this->model->broadcastOn($this->event) ?: []) - : $this->channels; + ? ($this->model->broadcastOn($this->event) ?: []) + : $this->channels; - return collect($channels)->map(function ($channel) { - return $channel instanceof Model ? new PrivateChannel($channel) : $channel; - })->all(); + return (new BaseCollection($channels)) + ->map(fn ($channel) => $channel instanceof Model ? new PrivateChannel($channel) : $channel) + ->all(); } /** @@ -85,8 +92,8 @@ public function broadcastAs() $default = class_basename($this->model).ucfirst($this->event); return method_exists($this->model, 'broadcastAs') - ? ($this->model->broadcastAs($this->event) ?: $default) - : $default; + ? ($this->model->broadcastAs($this->event) ?: $default) + : $default; } /** diff --git a/Eloquent/BroadcastsEvents.php b/Eloquent/BroadcastsEvents.php index 79dc02d8ae..c0461ddb0a 100644 --- a/Eloquent/BroadcastsEvents.php +++ b/Eloquent/BroadcastsEvents.php @@ -130,16 +130,16 @@ public function newBroadcastableModelEvent($event) { return tap($this->newBroadcastableEvent($event), function ($event) { $event->connection = property_exists($this, 'broadcastConnection') - ? $this->broadcastConnection - : $this->broadcastConnection(); + ? $this->broadcastConnection + : $this->broadcastConnection(); $event->queue = property_exists($this, 'broadcastQueue') - ? $this->broadcastQueue - : $this->broadcastQueue(); + ? $this->broadcastQueue + : $this->broadcastQueue(); $event->afterCommit = property_exists($this, 'broadcastAfterCommit') - ? $this->broadcastAfterCommit - : $this->broadcastAfterCommit(); + ? $this->broadcastAfterCommit + : $this->broadcastAfterCommit(); }); } @@ -149,7 +149,7 @@ public function newBroadcastableModelEvent($event) * @param string $event * @return \Illuminate\Database\Eloquent\BroadcastableModelEventOccurred */ - protected function newBroadcastableEvent($event) + protected function newBroadcastableEvent(string $event) { return new BroadcastableModelEventOccurred($this, $event); } 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; } @@ -43,10 +49,17 @@ class Builder implements BuilderContract /** * 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. * @@ -95,26 +108,33 @@ class Builder implements BuilderContract 'avg', 'count', 'dd', - 'doesntExist', - 'doesntExistOr', + 'ddrawsql', + 'doesntexist', + 'doesntexistor', 'dump', + 'dumprawsql', 'exists', - 'existsOr', + 'existsor', 'explain', - 'getBindings', - 'getConnection', - 'getGrammar', + 'getbindings', + 'getconnection', + 'getcountforpagination', + 'getgrammar', + 'getrawbindings', 'implode', 'insert', - 'insertGetId', - 'insertOrIgnore', - 'insertUsing', + 'insertgetid', + 'insertorignore', + 'insertusing', + 'insertorignoreusing', 'max', 'min', + 'numericaggregate', 'raw', - 'rawValue', + 'rawvalue', 'sum', - 'toSql', + 'tosql', + 'torawsql', ]; /** @@ -131,11 +151,24 @@ class Builder implements BuilderContract */ 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) { @@ -146,7 +179,7 @@ public function __construct(QueryBuilder $query) * Create and return an un-saved model instance. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model|static + * @return TModel */ public function make(array $attributes = []) { @@ -196,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); @@ -209,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. * @@ -232,7 +280,11 @@ public function whereKey($id) } 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; } @@ -257,7 +309,11 @@ public function whereKeyNot($id) } 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; } @@ -269,10 +325,25 @@ public function whereKeyNot($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 \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean @@ -283,6 +354,11 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' 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()); @@ -294,11 +370,11 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' /** * Add a basic where clause to the query, and return the first result. * - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean - * @return \Illuminate\Database\Eloquent\Model|static|null + * @return TModel|null */ public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') { @@ -308,7 +384,7 @@ public function firstWhere($column, $operator = null, $value = null, $boolean = /** * Add an "or where" clause to the query. * - * @param \Closure|array|string|\Illuminate\Database\Query\Expression $column + * @param (\Closure(static): mixed)|array|string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @return $this @@ -325,7 +401,7 @@ public function orWhere($column, $operator = null, $value = null) /** * Add a basic "where not" clause to the query. * - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @param (\Closure(static): mixed)|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean @@ -339,7 +415,7 @@ public function whereNot($column, $operator = null, $value = null, $boolean = 'a /** * Add an "or where not" clause to the query. * - * @param \Closure|array|string|\Illuminate\Database\Query\Expression $column + * @param (\Closure(static): mixed)|array|string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @return $this @@ -352,7 +428,7 @@ public function orWhereNot($column, $operator = null, $value = null) /** * Add an "order by" clause for a timestamp to the query. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return $this */ public function latest($column = null) @@ -369,7 +445,7 @@ public function latest($column = null) /** * Add an "order by" clause for a timestamp to the query. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return $this */ public function oldest($column = null) @@ -387,7 +463,7 @@ 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) { @@ -404,12 +480,73 @@ public function hydrate(array $items) }, $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 = []) { @@ -423,7 +560,7 @@ public function fromQuery($query, $bindings = []) * * @param mixed $id * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static[]|static|null + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel|null) */ public function find($id, $columns = ['*']) { @@ -434,12 +571,27 @@ 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|string $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function findMany($ids, $columns = ['*']) { @@ -457,9 +609,9 @@ public function findMany($ids, $columns = ['*']) * * @param mixed $id * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static|static[] + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel) * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ public function findOrFail($id, $columns = ['*']) { @@ -491,7 +643,7 @@ public function findOrFail($id, $columns = ['*']) * * @param mixed $id * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|static + * @return ($id is (\Illuminate\Contracts\Support\Arrayable|array) ? \Illuminate\Database\Eloquent\Collection : TModel) */ public function findOrNew($id, $columns = ['*']) { @@ -505,12 +657,18 @@ public function findOrNew($id, $columns = ['*']) /** * Find a model by its primary key or call a callback. * + * @template TValue + * * @param mixed $id - * @param \Closure|array|string $columns - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|static[]|static|mixed + * @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) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -530,7 +688,7 @@ public function findOr($id, $columns = ['*'], Closure $callback = null) * * @param array $attributes * @param array $values - * @return \Illuminate\Database\Eloquent\Model|static + * @return TModel */ public function firstOrNew(array $attributes = [], array $values = []) { @@ -542,21 +700,37 @@ public function firstOrNew(array $attributes = [], array $values = []) } /** - * Get the first record matching the attributes or create it. + * Get the first record matching the attributes. If the record is not found, create it. * * @param array $attributes - * @param array $values - * @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(array_merge($attributes, $values)), function ($instance) { - $instance->save(); - }); + return $this->createOrFirst($attributes, $values); + } + + /** + * Attempt to create the record. If a unique constraint violation occurs, attempt to find the matching record. + * + * @param array $attributes + * @param (\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; + } } /** @@ -564,12 +738,33 @@ 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); + } }); } @@ -577,9 +772,9 @@ public function updateOrCreate(array $attributes, array $values = []) * Execute the query and get the first result or throw an exception. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model|static + * @return TModel * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ public function firstOrFail($columns = ['*']) { @@ -593,11 +788,13 @@ public function firstOrFail($columns = ['*']) /** * Execute the query and get the first result or call a callback. * - * @param \Closure|array|string $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; @@ -616,16 +813,16 @@ public function firstOr($columns = ['*'], Closure $callback = null) * Execute the query and get the first result if it's the sole matching record. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Model + * @return TModel * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException * @throws \Illuminate\Database\MultipleRecordsFoundException */ public function sole($columns = ['*']) { try { return $this->baseSole($columns); - } catch (RecordsNotFoundException $exception) { + } catch (RecordsNotFoundException) { throw (new ModelNotFoundException)->setModel(get_class($this->model)); } } @@ -633,12 +830,14 @@ public function sole($columns = ['*']) /** * Get a single column's value from the first result of a query. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return mixed */ public function value($column) { if ($result = $this->first([$column])) { + $column = $column instanceof Expression ? $column->getValue($this->getGrammar()) : $column; + return $result->{Str::afterLast($column, '.')}; } } @@ -646,27 +845,31 @@ public function value($column) /** * Get a single column's value from the first result of a query if it's the sole matching record. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return mixed * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @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\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return mixed * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @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, '.')}; } @@ -674,7 +877,7 @@ public function valueOrFail($column) * Execute the query as a "select" statement. * * @param array|string $columns - * @return \Illuminate\Database\Eloquent\Collection|static[] + * @return \Illuminate\Database\Eloquent\Collection */ public function get($columns = ['*']) { @@ -687,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|string $columns - * @return \Illuminate\Database\Eloquent\Model[]|static[] + * @return array */ public function getModels($columns = ['*']) { @@ -706,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) { @@ -755,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) { @@ -765,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); } }); @@ -816,16 +1021,46 @@ protected function isNestedUnder($relation, $name) return str_contains($name, '.') && str_starts_with($name, $relation.'.'); } + /** + * Register a closure to be invoked after the query is executed. + * + * @param \Closure $callback + * @return $this + */ + public function afterQuery(Closure $callback) + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks($result) + { + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; + } + + return $result; + } + /** * Get a lazy collection for the given query. * - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection */ public function cursor() { return $this->applyScopes()->query->cursor()->map(function ($record) { - return $this->newModelInstance()->newFromBuilder($record); - }); + $model = $this->newModelInstance()->newFromBuilder($record); + + return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + })->reject(fn ($model) => is_null($model)); } /** @@ -841,28 +1076,34 @@ 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|\Illuminate\Database\Query\Expression $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); + $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}; + }) + ); } /** @@ -872,20 +1113,18 @@ public function pluck($column, $key = null) * @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); - $total = $this->toBase()->getCountForPagination(); + $total = value($total) ?? $this->toBase()->getCountForPagination(); - $perPage = ($perPage instanceof Closure - ? $perPage($total) - : $perPage - ) ?: $this->model->getPerPage(); + $perPage = value($perPage, $total) ?: $this->model->getPerPage(); $results = $total ? $this->forPage($page, $perPage)->get($columns) @@ -915,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(), @@ -951,26 +1190,33 @@ protected function ensureOrderForCursorPagination($shouldReverse = false) $this->enforceOrderBy(); } - if ($shouldReverse) { - $this->query->orders = collect($this->query->orders)->map(function ($order) { - $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; - + $reverseDirection = function ($order) { + if (! isset($order['direction'])) { return $order; - })->toArray(); - } + } + + $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + + return $order; + }; - if ($this->query->unionOrders) { - return collect($this->query->unionOrders); + if ($shouldReverse) { + $this->query->orders = (new BaseCollection($this->query->orders))->map($reverseDirection)->toArray(); + $this->query->unionOrders = (new BaseCollection($this->query->unionOrders))->map($reverseDirection)->toArray(); } - return collect($this->query->orders); + $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 = []) { @@ -979,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) { @@ -992,6 +1249,17 @@ public function forceCreate(array $attributes) }); } + /** + * 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. * @@ -1017,16 +1285,16 @@ public function upsert(array $values, $uniqueBy, $update = null) return 0; } - if (! is_array(reset($values))) { + if (! is_array(array_first($values))) { $values = [$values]; } if (is_null($update)) { - $update = array_keys(reset($values)); + $update = array_keys(array_first($values)); } return $this->toBase()->upsert( - $this->addTimestampsToUpsertValues($values), + $this->addTimestampsToUpsertValues($this->addUniqueIdsToUpsertValues($values)), $uniqueBy, $this->addUpdatedAtToUpsertColumns($update) ); @@ -1058,7 +1326,7 @@ public function touch($column = null) /** * Increment a column's value by a given amount. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param float|int $amount * @param array $extra * @return int @@ -1073,7 +1341,7 @@ public function increment($column, $amount = 1, array $extra = []) /** * Decrement a column's value by a given amount. * - * @param string|\Illuminate\Database\Query\Expression $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param float|int $amount * @param array $extra * @return int @@ -1100,14 +1368,25 @@ 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 = end($segments).'.'.$column; + $qualifiedColumn = array_last($segments).'.'.$column; $values[$qualifiedColumn] = Arr::get($values, $qualifiedColumn, $values[$column]); @@ -1116,6 +1395,29 @@ protected function addUpdatedAtColumn(array $values) return $values; } + /** + * 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. * @@ -1299,7 +1601,8 @@ protected function callScope(callable $scope, array $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(...$parameters) ?? $this; @@ -1358,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); @@ -1389,10 +1692,10 @@ protected function createNestedWhere($whereSlice, $boolean = 'and') } /** - * Set the relationships that should be eager loaded. + * Specify relationships that should be eager loaded. * - * @param string|array $relations - * @param string|\Closure|null $callback + * @param array): mixed)|string>|string $relations + * @param (\Closure(\Illuminate\Database\Eloquent\Relations\Relation<*,*,*>): mixed)|string|null $callback * @return $this */ public function with($relations, $callback = null) @@ -1426,7 +1729,7 @@ public function without($relations) /** * Set the relationships that should be eager loaded while removing any previously added eager loading specifications. * - * @param mixed $relations + * @param array): mixed)|string>|string $relations * @return $this */ public function withOnly($relations) @@ -1440,10 +1743,12 @@ public function withOnly($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() ); @@ -1570,13 +1875,9 @@ protected function createSelectWithConstraint($name) { return [explode(':', $name)[0], static function ($query) use ($name) { $query->select(array_map(static function ($column) use ($query) { - if (str_contains($column, '.')) { - return $column; - } - return $query instanceof BelongsToMany - ? $query->getRelated()->getTable().'.'.$column - : $column; + ? $query->getRelated()->qualifyColumn($column) + : $column; }, explode(',', explode(':', $name)[1]))); }]; } @@ -1608,6 +1909,33 @@ 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. * @@ -1621,6 +1949,33 @@ public function withCasts($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. * @@ -1700,6 +2055,26 @@ 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. * @@ -1713,7 +2088,7 @@ protected function defaultKeyName() /** * Get the model instance being queried. * - * @return \Illuminate\Database\Eloquent\Model|static + * @return TModel */ public function getModel() { @@ -1723,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) { @@ -1738,18 +2115,20 @@ public function setModel(Model $model) /** * Qualify the given column name by the model's table. * - * @param string|\Illuminate\Database\Query\Expression $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\Database\Query\Expression $columns + * @param array|\Illuminate\Contracts\Database\Query\Expression $columns * @return array */ public function qualifyColumns($columns) @@ -1857,7 +2236,7 @@ public function __call($method, $parameters) return $this->callNamedScope($method, $parameters); } - if (in_array($method, $this->passthru)) { + if (in_array(strtolower($method), $this->passthru)) { return $this->toBase()->{$method}(...$parameters); } @@ -1910,13 +2289,11 @@ public static function __callStatic($method, $parameters) protected static function registerMixin($mixin, $replace) { $methods = (new ReflectionClass($mixin))->getMethods( - ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED - ); + ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED + ); foreach ($methods as $method) { if ($replace || ! static::hasGlobalMacro($method->name)) { - $method->setAccessible(true); - static::macro($method->name, $method->invoke($mixin)); } } @@ -1932,6 +2309,19 @@ 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; + } + /** * Force a clone of the underlying query builder when cloning. * @@ -1940,5 +2330,9 @@ public function clone() 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 index 2da92c3346..563545dacb 100644 --- a/Eloquent/Casts/ArrayObject.php +++ b/Eloquent/Casts/ArrayObject.php @@ -4,8 +4,15 @@ use ArrayObject as BaseArrayObject; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Collection; use JsonSerializable; +/** + * @template TKey of array-key + * @template TItem + * + * @extends \ArrayObject + */ class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable { /** @@ -15,7 +22,7 @@ class ArrayObject extends BaseArrayObject implements Arrayable, JsonSerializable */ public function collect() { - return collect($this->getArrayCopy()); + return new Collection($this->getArrayCopy()); } /** diff --git a/Eloquent/Casts/AsArrayObject.php b/Eloquent/Casts/AsArrayObject.php index 34865b15f4..5ee80d0bb4 100644 --- a/Eloquent/Casts/AsArrayObject.php +++ b/Eloquent/Casts/AsArrayObject.php @@ -11,7 +11,7 @@ class AsArrayObject implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Casts\ArrayObject, iterable> */ public static function castUsing(array $arguments) { @@ -23,14 +23,14 @@ public function get($model, $key, $value, $attributes) return; } - $data = json_decode($attributes[$key], true); + $data = Json::decode($attributes[$key]); - return is_array($data) ? new ArrayObject($data) : null; + return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null; } public function set($model, $key, $value, $attributes) { - return [$key => json_encode($value)]; + return [$key => Json::encode($value)]; } public function serialize($model, string $key, $value, array $attributes) 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 index 3456767db9..c6b0ffe4b7 100644 --- a/Eloquent/Casts/AsCollection.php +++ b/Eloquent/Casts/AsCollection.php @@ -5,6 +5,8 @@ use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Support\Collection; +use Illuminate\Support\Str; +use InvalidArgumentException; class AsCollection implements Castable { @@ -12,27 +14,83 @@ class AsCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection, iterable> + * + * @throws \InvalidArgumentException */ public static function castUsing(array $arguments) { - return new class implements CastsAttributes + 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], true); + $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_array($data) ? new Collection($data) : null; + 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)]; + 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 index cd65624650..2122a7b1f9 100644 --- a/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/Eloquent/Casts/AsEncryptedArrayObject.php @@ -12,7 +12,7 @@ class AsEncryptedArrayObject implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Database\Eloquent\Casts\ArrayObject, iterable> */ public static function castUsing(array $arguments) { @@ -21,7 +21,7 @@ public static function castUsing(array $arguments) public function get($model, $key, $value, $attributes) { if (isset($attributes[$key])) { - return new ArrayObject(json_decode(Crypt::decryptString($attributes[$key]), true)); + return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key])), ArrayObject::ARRAY_AS_PROPS); } return null; @@ -30,7 +30,7 @@ public function get($model, $key, $value, $attributes) public function set($model, $key, $value, $attributes) { if (! is_null($value)) { - return [$key => Crypt::encryptString(json_encode($value))]; + return [$key => Crypt::encryptString(Json::encode($value))]; } return null; diff --git a/Eloquent/Casts/AsEncryptedCollection.php b/Eloquent/Casts/AsEncryptedCollection.php index 4d9fee7ece..a9078c2b38 100644 --- a/Eloquent/Casts/AsEncryptedCollection.php +++ b/Eloquent/Casts/AsEncryptedCollection.php @@ -6,6 +6,8 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Str; +use InvalidArgumentException; class AsEncryptedCollection implements Castable { @@ -13,29 +15,81 @@ class AsEncryptedCollection implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Collection, iterable> + * + * @throws \InvalidArgumentException */ public static function castUsing(array $arguments) { - return new class implements CastsAttributes + 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 new Collection(json_decode(Crypt::decryptString($attributes[$key]), true)); + $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.'].'); } - return null; + 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 [$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 index 912659f38d..4f6c787c85 100644 --- a/Eloquent/Casts/AsStringable.php +++ b/Eloquent/Casts/AsStringable.php @@ -4,7 +4,7 @@ use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; -use Illuminate\Support\Str; +use Illuminate\Support\Stringable; class AsStringable implements Castable { @@ -12,7 +12,7 @@ class AsStringable implements Castable * Get the caster class to use when casting from / to this cast target. * * @param array $arguments - * @return object|string + * @return \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Stringable, string|\Stringable> */ public static function castUsing(array $arguments) { @@ -20,7 +20,7 @@ public static function castUsing(array $arguments) { public function get($model, $key, $value, $attributes) { - return isset($value) ? Str::of($value) : null; + return isset($value) ? new Stringable($value) : null; } public function set($model, $key, $value, $attributes) 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 index 3f9fd19e2b..26d13ba3fb 100644 --- a/Eloquent/Casts/Attribute.php +++ b/Eloquent/Casts/Attribute.php @@ -37,9 +37,8 @@ class Attribute * * @param callable|null $get * @param callable|null $set - * @return void */ - public function __construct(callable $get = null, callable $set = null) + public function __construct(?callable $get = null, ?callable $set = null) { $this->get = $get; $this->set = $set; @@ -52,7 +51,7 @@ public function __construct(callable $get = null, callable $set = null) * @param callable|null $set * @return static */ - public static function make(callable $get = null, callable $set = null): static + public static function make(?callable $get = null, ?callable $set = null): static { return new static($get, $set); } 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 @@ +|TModel|TFindDefault + * @return ($key is (\Illuminate\Contracts\Support\Arrayable|array) ? static : TModel|TFindDefault) */ public function find($key, $default = null) { @@ -44,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) @@ -73,7 +105,7 @@ public function load($relations) /** * 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 @@ -110,7 +142,7 @@ public function loadAggregate($relations, $column, $function = null) /** * Load a set of relationship counts onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @return $this */ public function loadCount($relations) @@ -121,7 +153,7 @@ public function loadCount($relations) /** * Load a set of relationship's max column values onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @param string $column * @return $this */ @@ -133,7 +165,7 @@ public function loadMax($relations, $column) /** * Load a set of relationship's min column values onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @param string $column * @return $this */ @@ -145,7 +177,7 @@ public function loadMin($relations, $column) /** * Load a set of relationship's column summations onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @param string $column * @return $this */ @@ -157,7 +189,7 @@ public function loadSum($relations, $column) /** * Load a set of relationship's average column values onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @param string $column * @return $this */ @@ -169,7 +201,7 @@ public function loadAvg($relations, $column) /** * Load a set of related existences onto the collection. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @return $this */ public function loadExists($relations) @@ -180,7 +212,7 @@ public function loadExists($relations) /** * 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) @@ -189,37 +221,66 @@ 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 */ @@ -233,15 +294,13 @@ protected function loadMissingRelation(self $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)->whereNotNull(); + $models = $models->pluck($name)->filter(); if ($models->first() instanceof BaseCollection) { $models = $models->collapse(); @@ -254,19 +313,15 @@ protected function loadMissingRelation(self $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; } @@ -275,19 +330,15 @@ public function loadMorph($relation, $relations) * Load a set of relationship counts onto the mixed relationship collection. * * @param string $relation - * @param array $relations + * @param array): mixed)|string> $relations * @return $this */ public function loadMorphCount($relation, $relations) { $this->pluck($relation) ->filter() - ->groupBy(function ($model) { - return get_class($model); - }) - ->each(function ($models, $className) use ($relations) { - static::make($models)->loadCount($relations[$className] ?? []); - }); + ->groupBy(fn ($model) => get_class($model)) + ->each(fn ($models, $className) => static::make($models)->loadCount($relations[$className] ?? [])); return $this; } @@ -307,14 +358,23 @@ 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()); } /** @@ -324,9 +384,7 @@ public function contains($key, $operator = null, $value = null) */ public function modelKeys() { - return array_map(function ($model) { - return $model->getKey(); - }, $this->items); + return array_map(fn ($model) => $model->getKey(), $this->items); } /** @@ -340,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)); @@ -358,9 +416,7 @@ 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; } /** @@ -378,9 +434,7 @@ public function mapWithKeys(callable $callback) { $result = parent::mapWithKeys($callback); - return $result->contains(function ($item) { - return ! $item instanceof Model; - }) ? $result->toBase() : $result; + return $result->contains(fn ($item) => ! $item instanceof Model) ? $result->toBase() : $result; } /** @@ -403,12 +457,8 @@ public function fresh($with = []) ->get() ->getDictionary(); - return $this->filter(function ($model) use ($freshModels) { - return $model->exists && isset($freshModels[$model->getKey()]); - }) - ->map(function ($model) use ($freshModels) { - return $freshModels[$model->getKey()]; - }); + return $this->filter(fn ($model) => $model->exists && isset($freshModels[$model->getKey()])) + ->map(fn ($model) => $freshModels[$model->getKey()]); } /** @@ -424,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); } } @@ -449,7 +499,7 @@ public function intersect($items) $dictionary = $this->getDictionary($items); foreach ($this->items as $item) { - if (isset($dictionary[$item->getKey()])) { + if (isset($dictionary[$this->getDictionaryKey($item->getKey())])) { $intersect->add($item); } } @@ -462,7 +512,7 @@ public function intersect($items) * * @param (callable(TModel, TKey): mixed)|string|null $key * @param bool $strict - * @return static + * @return static */ public function unique($key = null, $strict = false) { @@ -477,7 +527,7 @@ public function unique($key = null, $strict = false) * Returns only the models from the collection with the specified keys. * * @param array|null $keys - * @return static + * @return static */ public function only($keys) { @@ -485,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)); } @@ -494,11 +544,15 @@ public function only($keys) * Returns all models in the collection except the models with specified keys. * * @param array|null $keys - * @return static + * @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)); } @@ -514,6 +568,28 @@ public function makeHidden($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. * @@ -525,6 +601,28 @@ 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. * @@ -536,6 +634,27 @@ 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. * @@ -549,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; @@ -560,96 +679,134 @@ public function getDictionary($items = null) */ /** - * Get an array with the values of a given key. + * {@inheritDoc} + * + * @return \Illuminate\Support\Collection + */ + #[\Override] + public function countBy($countBy = null) + { + return $this->toBase()->countBy($countBy); + } + + /** + * {@inheritDoc} * - * @param string|array $value - * @param string|null $key * @return \Illuminate\Support\Collection */ - public function pluck($value, $key = null) + #[\Override] + public function collapse() { - return $this->toBase()->pluck($value, $key); + return $this->toBase()->collapse(); } /** - * Get the keys of the collection items. + * {@inheritDoc} * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ - public function keys() + #[\Override] + public function flatten($depth = INF) { - return $this->toBase()->keys(); + return $this->toBase()->flatten($depth); } /** - * Zip the collection together with one or more arrays. + * {@inheritDoc} * - * @template TZipValue + * @return \Illuminate\Support\Collection + */ + #[\Override] + public function flip() + { + return $this->toBase()->flip(); + } + + /** + * {@inheritDoc} * - * @param \Illuminate\Contracts\Support\Arrayable|iterable ...$items - * @return \Illuminate\Support\Collection> + * @return \Illuminate\Support\Collection */ - public function zip($items) + #[\Override] + public function keys() { - return $this->toBase()->zip(...func_get_args()); + return $this->toBase()->keys(); } /** - * Collapse the collection of items into a single array. + * {@inheritDoc} * - * @return \Illuminate\Support\Collection + * @template TPadValue + * + * @return \Illuminate\Support\Collection */ - public function collapse() + #[\Override] + public function pad($size, $value) { - return $this->toBase()->collapse(); + return $this->toBase()->pad($size, $value); } /** - * Get a flattened array of the items in the collection. + * {@inheritDoc} * - * @param int $depth - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection, static> */ - public function flatten($depth = INF) + #[\Override] + public function partition($key, $operator = null, $value = null) { - return $this->toBase()->flatten($depth); + return parent::partition(...func_get_args())->toBase(); } /** - * Flip the items in the collection. + * {@inheritDoc} * - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection */ - public function flip() + #[\Override] + public function pluck($value, $key = null) { - return $this->toBase()->flip(); + return $this->toBase()->pluck($value, $key); } /** - * Pad collection to the specified length with a value. + * {@inheritDoc} * - * @template TPadValue + * @template TZipValue * - * @param int $size - * @param TPadValue $value - * @return \Illuminate\Support\Collection + * @return \Illuminate\Support\Collection> */ - public function pad($size, $value) + #[\Override] + public function zip($items) { - return $this->toBase()->pad($size, $value); + return $this->toBase()->zip(...func_get_args()); } /** * Get the comparison function to detect duplicates. * - * @param bool $strict - * @return callable(TValue, TValue): bool + * @return callable(TModel, TModel): bool */ protected function duplicateComparator($strict) { - return function ($a, $b) { - return $a->is($b); - }; + 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; } /** @@ -685,8 +842,8 @@ public function getQueueableClass() protected function getQueueableModelClass($model) { return method_exists($model, 'getQueueableClassName') - ? $model->getQueueableClassName() - : get_class($model); + ? $model->getQueueableClassName() + : get_class($model); } /** @@ -701,8 +858,8 @@ public function getQueueableIds() } return $this->first() instanceof QueueableEntity - ? $this->map->getQueueableId()->all() - : $this->modelKeys(); + ? $this->map->getQueueableId()->all() + : $this->modelKeys(); } /** @@ -754,7 +911,7 @@ public function getQueueableConnection() /** * Get the Eloquent query builder from the collection. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder * * @throws \LogicException */ @@ -768,9 +925,7 @@ public function toQuery() $class = get_class($model); - if ($this->filter(function ($model) use ($class) { - return ! $model instanceof $class; - })->isNotEmpty()) { + if ($this->reject(fn ($model) => $model instanceof $class)->isNotEmpty()) { throw new LogicException('Unable to create query for collection with mixed types.'); } diff --git a/Eloquent/Concerns/GuardsAttributes.php b/Eloquent/Concerns/GuardsAttributes.php index 491a6caaf8..aa9b2c60c9 100644 --- a/Eloquent/Concerns/GuardsAttributes.php +++ b/Eloquent/Concerns/GuardsAttributes.php @@ -2,19 +2,24 @@ namespace Illuminate\Database\Eloquent\Concerns; +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|bool + * @var array */ protected $guarded = ['*']; @@ -28,10 +33,31 @@ trait GuardsAttributes /** * The actual columns that exist on the database and can be guarded. * - * @var array + * @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. * @@ -63,7 +89,7 @@ public function fillable(array $fillable) */ public function mergeFillable(array $fillable) { - $this->fillable = array_merge($this->fillable, $fillable); + $this->fillable = array_values(array_unique(array_merge($this->fillable, $fillable))); return $this; } @@ -75,9 +101,9 @@ public function mergeFillable(array $fillable) */ public function getGuarded() { - return $this->guarded === false - ? [] - : $this->guarded; + return self::$unguarded === true + ? [] + : $this->guarded; } /** @@ -101,7 +127,7 @@ public function guard(array $guarded) */ public function mergeGuarded(array $guarded) { - $this->guarded = array_merge($this->guarded, $guarded); + $this->guarded = array_values(array_unique(array_merge($this->guarded, $guarded))); return $this; } @@ -140,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) { @@ -202,7 +230,7 @@ public function isGuarded($key) } return $this->getGuarded() == ['*'] || - ! empty(preg_grep('/^'.preg_quote($key).'$/i', $this->getGuarded())) || + ! empty(preg_grep('/^'.preg_quote($key, '/').'$/i', $this->getGuarded())) || ! $this->isGuardableColumn($key); } @@ -214,14 +242,19 @@ public function isGuarded($key) */ 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()); + ->getSchemaBuilder() + ->getColumnListing($this->getTable()); if (empty($columns)) { return true; } + static::$guardableColumns[get_class($this)] = $columns; } @@ -241,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 9fe13c4208..a5717d2c30 100644 --- a/Eloquent/Concerns/HasAttributes.php +++ b/Eloquent/Concerns/HasAttributes.php @@ -3,6 +3,9 @@ namespace Illuminate\Database\Eloquent\Concerns; use BackedEnum; +use Brick\Math\BigDecimal; +use Brick\Math\Exception\MathException as BrickMathException; +use Brick\Math\RoundingMode; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use DateTimeImmutable; @@ -10,11 +13,17 @@ 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; @@ -22,39 +31,54 @@ use Illuminate\Database\LazyLoadingViolationException; use Illuminate\Support\Arr; use Illuminate\Support\Carbon; +use Illuminate\Support\Collection; use Illuminate\Support\Collection as BaseCollection; +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 previous state of the changed model attributes. + * + * @var array + */ + protected $previous = []; + /** * The attributes that should be cast. * @@ -97,12 +121,14 @@ trait HasAttributes 'encrypted:json', 'encrypted:object', 'float', + 'hashed', 'immutable_date', 'immutable_datetime', 'immutable_custom_datetime', 'int', 'integer', 'json', + 'json:unicode', 'object', 'real', 'string', @@ -112,7 +138,7 @@ trait HasAttributes /** * The storage format of the model's date columns. * - * @var string + * @var string|null */ protected $dateFormat; @@ -168,14 +194,32 @@ trait HasAttributes /** * The encrypter instance that is used to encrypt attributes. * - * @var \Illuminate\Contracts\Encryption\Encrypter + * @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() { @@ -210,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; } @@ -231,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) { @@ -259,9 +303,9 @@ 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) { @@ -300,7 +344,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt } if ($this->isEnumCastable($key) && (! ($attributes[$key] ?? null) instanceof Arrayable)) { - $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($attributes[$key]) : null; + $attributes[$key] = isset($attributes[$key]) ? $this->getStorableEnumValue($this->getCasts()[$key], $attributes[$key]) : null; } if ($attributes[$key] instanceof Arrayable) { @@ -314,7 +358,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt /** * Get an attribute array of all arrayable attributes. * - * @return array + * @return array */ protected function getArrayableAttributes() { @@ -328,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) ); } @@ -371,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); @@ -410,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. * @@ -425,11 +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) || - array_key_exists($key, $this->casts) || - $this->hasGetMutator($key) || - $this->hasAttributeMutator($key) || - $this->isClassCastable($key)) { + if ($this->hasAttribute($key)) { return $this->getAttributeValue($key); } @@ -441,8 +502,8 @@ public function getAttribute($key) } return $this->isRelation($key) || $this->relationLoaded($key) - ? $this->getRelationValue($key) - : $this->throwMissingAttributeExceptionIfApplicable($key); + ? $this->getRelationValue($key) + : $this->throwMissingAttributeExceptionIfApplicable($key); } /** @@ -487,7 +548,9 @@ public function getAttributeValue($key) */ protected function getAttributeFromArray($key) { - return $this->getAttributes()[$key] ?? null; + $this->mergeAttributeFromCachedCasts($key); + + return $this->attributes[$key] ?? null; } /** @@ -509,6 +572,10 @@ public function getRelationValue($key) return; } + if ($this->attemptToAutoloadRelation($key)) { + return $this->relations[$key]; + } + if ($this->preventsLazyLoading) { $this->handleLazyLoadingViolation($key); } @@ -532,7 +599,7 @@ public function isRelation($key) } return method_exists($this, $key) || - (static::$relationResolvers[get_class($this)][$key] ?? null); + $this->relationResolver(static::class, $key); } /** @@ -540,6 +607,8 @@ public function isRelation($key) * * @param string $key * @return mixed + * + * @throws \Illuminate\Database\LazyLoadingViolationException */ protected function handleLazyLoadingViolation($key) { @@ -636,6 +705,17 @@ public function hasAttributeGetMutator($key) 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. * @@ -645,6 +725,8 @@ public function hasAttributeGetMutator($key) */ protected function mutateAttribute($key, $value) { + $this->mergeAttributesFromCachedCasts(); + return $this->{'get'.Str::studly($key).'Attribute'}($value); } @@ -661,6 +743,8 @@ protected function mutateAttributeMarkedAttribute($key, $value) return $this->attributeCastCache[$key]; } + $this->mergeAttributesFromCachedCasts(); + $attribute = $this->{Str::camel($key)}(); $value = call_user_func($attribute->get ?: function ($value) { @@ -692,8 +776,8 @@ protected function mutateAttributeForArray($key, $value) $value = $this->mutateAttributeMarkedAttribute($key, $value); $value = $value instanceof DateTimeInterface - ? $this->serializeDate($value) - : $value; + ? $this->serializeDate($value) + : $value; } else { $value = $this->mutateAttribute($key, $value); } @@ -709,11 +793,50 @@ protected function mutateAttributeForArray($key, $value) */ 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. * @@ -757,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)); @@ -794,16 +918,20 @@ protected function castAttribute($key, $value) */ protected function getClassCastableAttributeValue($key, $value) { - if (isset($this->classCastCache[$key])) { + $caster = $this->resolveCasterClass($key); + + $objectCachingDisabled = $caster->withoutObjectCaching ?? false; + + if (isset($this->classCastCache[$key]) && ! $objectCachingDisabled) { return $this->classCastCache[$key]; } else { - $caster = $this->resolveCasterClass($key); - $value = $caster instanceof CastsInboundAttributes ? $value : $caster->get($this, $key, $value, $this->attributes); - if ($caster instanceof CastsInboundAttributes || ! is_object($value)) { + if ($caster instanceof CastsInboundAttributes || + ! is_object($value) || + $objectCachingDisabled) { unset($this->classCastCache[$key]); } else { $this->classCastCache[$key] = $value; @@ -855,6 +983,8 @@ protected function getCastType($key) $convertedCastType = 'immutable_custom_datetime'; } elseif ($this->isDecimalCast($castType)) { $convertedCastType = 'decimal'; + } elseif (class_exists($castType)) { + $convertedCastType = $castType; } else { $convertedCastType = trim(strtolower($castType)); } @@ -891,6 +1021,21 @@ protected function serializeClassCastableAttribute($key, $value) ); } + /** + * 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. * @@ -978,6 +1123,10 @@ public function setAttribute($key, $value) $value = $this->castAttributeAsEncryptedString($key, $value); } + if (! is_null($value) && $this->hasCast($key, 'hashed')) { + $value = $this->castAttributeAsHashedString($key, $value); + } + $this->attributes[$key] = $value; return $this; @@ -1029,6 +1178,8 @@ public function hasAttributeSetMutator($key) */ protected function setMutatedAttributeValue($key, $value) { + $this->mergeAttributesFromCachedCasts(); + return $this->{'set'.Str::studly($key).'Attribute'}($value); } @@ -1041,6 +1192,8 @@ protected function setMutatedAttributeValue($key, $value) */ protected function setAttributeMarkedMutatedAttributeValue($key, $value) { + $this->mergeAttributesFromCachedCasts(); + $attribute = $this->{Str::camel($key)}(); $callback = $attribute->set ?: function ($value) use ($key) { @@ -1059,6 +1212,8 @@ protected function setAttributeMarkedMutatedAttributeValue($key, $value) } else { unset($this->attributeCastCache[$key]); } + + return $this; } /** @@ -1086,7 +1241,7 @@ public function fillJsonAttribute($key, $value) $value = $this->asJson($this->getArrayAttributeWithValue( $path, $key, $value - )); + ), $this->getJsonCastFlags($key)); $this->attributes[$key] = $this->isEncryptedCastable($key) ? $this->castAttributeAsEncryptedString($key, $value) @@ -1110,14 +1265,16 @@ protected function setClassCastableAttribute($key, $value) { $caster = $this->resolveCasterClass($key); - $this->attributes = array_merge( + $this->attributes = array_replace( $this->attributes, $this->normalizeCastClassResponse($key, $caster->set( $this, $key, $value, $this->attributes )) ); - if ($caster instanceof CastsInboundAttributes || ! is_object($value)) { + if ($caster instanceof CastsInboundAttributes || + ! is_object($value) || + ($caster->withoutObjectCaching ?? false)) { unset($this->classCastCache[$key]); } else { $this->classCastCache[$key] = $value; @@ -1128,7 +1285,7 @@ protected function setClassCastableAttribute($key, $value) * Set the value of an enum castable attribute. * * @param string $key - * @param \UnitEnum|string|int $value + * @param \UnitEnum|string|int|null $value * @return void */ protected function setEnumCastableAttribute($key, $value) @@ -1138,10 +1295,10 @@ protected function setEnumCastableAttribute($key, $value) if (! isset($value)) { $this->attributes[$key] = null; } elseif (is_object($value)) { - $this->attributes[$key] = $this->getStorableEnumValue($value); + $this->attributes[$key] = $this->getStorableEnumValue($enumClass, $value); } else { $this->attributes[$key] = $this->getStorableEnumValue( - $this->getEnumCaseFromValue($enumClass, $value) + $enumClass, $this->getEnumCaseFromValue($enumClass, $value) ); } } @@ -1151,26 +1308,31 @@ protected function setEnumCastableAttribute($key, $value) * * @param string $enumClass * @param string|int $value - * @return \UnitEnum|\BackedEnum + * @return \UnitEnum */ protected function getEnumCaseFromValue($enumClass, $value) { return is_subclass_of($enumClass, BackedEnum::class) - ? $enumClass::from($value) - : constant($enumClass.'::'.$value); + ? $enumClass::from($value) + : constant($enumClass.'::'.$value); } /** * Get the storable value from the given enum. * - * @param \UnitEnum|\BackedEnum $value + * @param string $expectedEnum + * @param \UnitEnum $value * @return string|int + * + * @throws \ValueError */ - protected function getStorableEnumValue($value) + protected function getStorableEnumValue($expectedEnum, $value) { - return $value instanceof BackedEnum - ? $value->value - : $value->name; + 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); } /** @@ -1179,7 +1341,7 @@ protected function getStorableEnumValue($value) * @param string $path * @param string $key * @param mixed $value - * @return $this + * @return array */ protected function getArrayAttributeWithValue($path, $key, $value) { @@ -1213,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( @@ -1227,27 +1391,49 @@ 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); } /** @@ -1258,7 +1444,7 @@ public function fromJson($value, $asObject = false) */ public function fromEncryptedString($value) { - return (static::$encrypter ?? Crypt::getFacadeRoot())->decrypt($value, false); + return static::currentEncrypter()->decrypt($value, false); } /** @@ -1268,15 +1454,15 @@ public function fromEncryptedString($value) * @param mixed $value * @return string */ - protected function castAttributeAsEncryptedString($key, $value) + protected function castAttributeAsEncryptedString($key, #[\SensitiveParameter] $value) { - return (static::$encrypter ?? Crypt::getFacadeRoot())->encrypt($value, false); + return static::currentEncrypter()->encrypt($value, false); } /** * Set the encrypter instance that will be used to encrypt attributes. * - * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter + * @param \Illuminate\Contracts\Encryption\Encrypter|null $encrypter * @return void */ public static function encryptUsing($encrypter) @@ -1284,6 +1470,43 @@ 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; + } + /** * Decode the given float. * @@ -1303,13 +1526,19 @@ public function fromFloat($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); + } } /** @@ -1351,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 @@ -1368,7 +1597,7 @@ protected function asDateTime($value) // that is returned back out to the developers after we convert it here. try { $date = Date::createFromFormat($format, $value); - } catch (InvalidArgumentException $e) { + } catch (InvalidArgumentException) { $date = false; } @@ -1426,7 +1655,7 @@ protected function serializeDate(DateTimeInterface $date) /** * Get the attributes that should be converted to dates. * - * @return array + * @return array */ public function getDates() { @@ -1476,7 +1705,7 @@ public function hasCast($key, $types = null) } /** - * Get the casts array. + * Get the attributes that should be cast. * * @return array */ @@ -1489,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. * @@ -1519,7 +1758,7 @@ protected function isDateCastableWithCustomFormat($key) */ protected function isJsonCastable($key) { - return $this->hasCast($key, ['array', 'json', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + return $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); } /** @@ -1582,9 +1821,11 @@ protected function isEnumCastable($key) return false; } - if (function_exists('enum_exists') && enum_exists($castType)) { - return true; + if (is_subclass_of($castType, Castable::class)) { + return false; } + + return enum_exists($castType); } /** @@ -1597,9 +1838,13 @@ protected function isEnumCastable($key) */ protected function isClassDeviable($key) { - return $this->isClassCastable($key) && - method_exists($castType = $this->parseCasterClass($this->getCasts()[$key]), 'increment') && - method_exists($castType, 'decrement'); + if (! $this->isClassCastable($key)) { + return false; + } + + $castType = $this->resolveCasterClass($key); + + return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); } /** @@ -1617,6 +1862,19 @@ protected function isClassSerializable($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. * @@ -1671,6 +1929,17 @@ protected function mergeAttributesFromCachedCasts() $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. * @@ -1679,15 +1948,31 @@ protected function mergeAttributesFromCachedCasts() protected function mergeAttributesFromClassCasts() { foreach ($this->classCastCache as $key => $value) { - $caster = $this->resolveCasterClass($key); + $this->mergeAttributeFromClassCasts($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 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)) + ); } /** @@ -1698,23 +1983,39 @@ protected function mergeAttributesFromClassCasts() protected function mergeAttributesFromAttributeCasts() { foreach ($this->attributeCastCache as $key => $value) { - $attribute = $this->{Str::camel($key)}(); + $this->mergeAttributeFromAttributeCasts($key); + } + } - if ($attribute->get && ! $attribute->set) { - continue; - } + /** + * Merge the cast class attribute back into the model. + * + * @return void + */ + protected function mergeAttributeFromAttributeCasts(string $key): void + { + if (! isset($this->attributeCastCache[$key])) { + return; + } - $callback = $attribute->set ?: function ($value) use ($key) { - $this->attributes[$key] = $value; - }; + $value = $this->attributeCastCache[$key]; - $this->attributes = array_merge( - $this->attributes, - $this->normalizeCastClassResponse( - $key, $callback($value, $this->attributes) - ) - ); + $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) + ) + ); } /** @@ -1732,7 +2033,7 @@ protected function normalizeCastClassResponse($key, $value) /** * Get all of the current attributes on the model. * - * @return array + * @return array */ public function getAttributes() { @@ -1777,7 +2078,7 @@ 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) { @@ -1791,7 +2092,7 @@ public function getOriginal($key = null, $default = null) * * @param string|null $key * @param mixed $default - * @return mixed|array + * @return ($key is null ? array : mixed) */ protected function getOriginalWithoutRewindingModel($key = null, $default = null) { @@ -1801,9 +2102,9 @@ protected function getOriginalWithoutRewindingModel($key = null, $default = null ); } - return collect($this->original)->mapWithKeys(function ($value, $key) { - return [$key => $this->transformModelValue($key, $value)]; - })->all(); + return (new Collection($this->original)) + ->mapWithKeys(fn ($value, $key) => [$key => $this->transformModelValue($key, $value)]) + ->all(); } /** @@ -1811,7 +2112,7 @@ protected function getOriginalWithoutRewindingModel($key = null, $default = null * * @param string|null $key * @param mixed $default - * @return mixed|array + * @return ($key is null ? array : mixed) */ public function getRawOriginal($key = null, $default = null) { @@ -1821,8 +2122,8 @@ public function getRawOriginal($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) { @@ -1835,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. * @@ -1861,7 +2183,7 @@ 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) @@ -1885,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; } @@ -1892,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) @@ -1905,7 +2228,7 @@ public function isDirty($attributes = null) /** * 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) @@ -1920,7 +2243,10 @@ public function isClean($attributes = null) */ public function discardChanges() { - [$this->attributes, $this->changes] = [$this->original, []]; + [$this->attributes, $this->changes, $this->previous] = [$this->original, [], []]; + + $this->classCastCache = []; + $this->attributeCastCache = []; return $this; } @@ -1928,7 +2254,7 @@ public function discardChanges() /** * 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) @@ -1941,8 +2267,8 @@ public function wasChanged($attributes = null) /** * 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) @@ -1969,7 +2295,7 @@ protected function hasChanges($changes, $attributes = null) /** * Get the attributes that have been changed since the last sync. * - * @return array + * @return array */ public function getDirty() { @@ -1984,16 +2310,36 @@ public function getDirty() return $dirty; } + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate() + { + return $this->getDirty(); + } + /** * Get the attributes that were changed when the model was last saved. * - * @return array + * @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. * @@ -2025,13 +2371,23 @@ public function originalIsEquivalent($key) } 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->hasCast($key, static::$primitiveCastTypes)) { return $this->castAttribute($key, $attribute) === $this->castAttribute($key, $original); - } elseif ($this->isClassCastable($key) && in_array($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) { + } 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 && in_array($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) { - return $this->fromEncryptedString($attribute) === $this->fromEncryptedString($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($attribute) && is_numeric($original) @@ -2060,6 +2416,13 @@ protected function transformModelValue($key, $value) // 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); } @@ -2077,14 +2440,14 @@ protected function transformModelValue($key, $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; } @@ -2112,6 +2475,19 @@ 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. * @@ -2120,7 +2496,17 @@ public function setAppends(array $appends) */ public function hasAppended($attribute) { - return in_array($attribute, $this->appends); + return in_array($attribute, $this->getAppends()); + } + + /** + * Remove all appended properties from the model. + * + * @return $this + */ + public function withoutAppends() + { + return $this->setAppends([]); } /** @@ -2149,17 +2535,14 @@ public static function cacheMutatedAttributes($classOrInstance) $class = $reflection->getName(); - static::$getAttributeMutatorCache[$class] = - collect($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance)) - ->mapWithKeys(function ($match) { - return [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]; - })->all(); + static::$getAttributeMutatorCache[$class] = (new Collection($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($classOrInstance))) + ->mapWithKeys(fn ($match) => [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]) + ->all(); - static::$mutatorCache[$class] = collect(static::getMutatorMethods($class)) - ->merge($attributeMutatorMethods) - ->map(function ($match) { - return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); - })->all(); + static::$mutatorCache[$class] = (new Collection(static::getMutatorMethods($class))) + ->merge($attributeMutatorMethods) + ->map(fn ($match) => lcfirst(static::$snakeAttributes ? Str::snake($match) : $match)) + ->all(); } /** @@ -2185,13 +2568,11 @@ protected static function getAttributeMarkedMutatorMethods($class) { $instance = is_object($class) ? $class : new $class; - return collect((new ReflectionClass($instance))->getMethods())->filter(function ($method) use ($instance) { + return (new Collection((new ReflectionClass($instance))->getMethods()))->filter(function ($method) use ($instance) { $returnType = $method->getReturnType(); if ($returnType instanceof ReflectionNamedType && $returnType->getName() === Attribute::class) { - $method->setAccessible(true); - if (is_callable($method->invoke($instance)->get)) { return true; } diff --git a/Eloquent/Concerns/HasEvents.php b/Eloquent/Concerns/HasEvents.php index 7d65cdc258..cc0368e67d 100644 --- a/Eloquent/Concerns/HasEvents.php +++ b/Eloquent/Concerns/HasEvents.php @@ -3,9 +3,13 @@ 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 ReflectionClass; trait HasEvents { @@ -14,7 +18,7 @@ trait HasEvents * * Allows for object-based events for native Eloquent events. * - * @var array + * @var array */ protected $dispatchesEvents = []; @@ -23,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 @@ -70,7 +106,7 @@ protected function registerObserver($class) * Resolve the observer's class name from an object or string. * * @param object|string $class - * @return string + * @return class-string * * @throws \InvalidArgumentException */ @@ -90,7 +126,7 @@ private function resolveObserverClassName($class) /** * Get the observable event names. * - * @return array + * @return string[] */ public function getObservableEvents() { @@ -98,7 +134,7 @@ public function getObservableEvents() [ 'retrieved', 'creating', 'created', 'updating', 'updated', 'saving', 'saved', 'restoring', 'restored', 'replicating', - 'deleting', 'deleted', 'forceDeleted', + 'trashed', 'deleting', 'deleted', 'forceDeleting', 'forceDeleted', ], $this->observables ); @@ -107,7 +143,7 @@ public function getObservableEvents() /** * Set the observable event names. * - * @param array $observables + * @param string[] $observables * @return $this */ public function setObservableEvents(array $observables) @@ -120,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) @@ -133,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) @@ -147,7 +183,7 @@ public function removeObservableEvents($observables) * Register a model event with the dispatcher. * * @param string $event - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ protected static function registerModelEvent($event, $callback) @@ -194,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) { @@ -230,7 +266,7 @@ protected function filterModelEventResults($result) /** * Register a retrieved model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function retrieved($callback) @@ -241,7 +277,7 @@ public static function retrieved($callback) /** * Register a saving model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function saving($callback) @@ -252,7 +288,7 @@ public static function saving($callback) /** * Register a saved model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function saved($callback) @@ -263,7 +299,7 @@ public static function saved($callback) /** * Register an updating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function updating($callback) @@ -274,7 +310,7 @@ public static function updating($callback) /** * Register an updated model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function updated($callback) @@ -285,7 +321,7 @@ public static function updated($callback) /** * Register a creating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function creating($callback) @@ -296,7 +332,7 @@ public static function creating($callback) /** * Register a created model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function created($callback) @@ -307,7 +343,7 @@ public static function created($callback) /** * Register a replicating model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function replicating($callback) @@ -318,7 +354,7 @@ public static function replicating($callback) /** * Register a deleting model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function deleting($callback) @@ -329,7 +365,7 @@ public static function deleting($callback) /** * Register a deleted model event with the dispatcher. * - * @param \Illuminate\Events\QueuedClosure|\Closure|string|array $callback + * @param \Illuminate\Events\QueuedClosure|callable|array|class-string $callback * @return void */ public static function deleted($callback) @@ -354,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() { diff --git a/Eloquent/Concerns/HasGlobalScopes.php b/Eloquent/Concerns/HasGlobalScopes.php index 72afb17889..635ac8d1fe 100644 --- a/Eloquent/Concerns/HasGlobalScopes.php +++ b/Eloquent/Concerns/HasGlobalScopes.php @@ -3,17 +3,51 @@ 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 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 \Illuminate\Database\Eloquent\Scope|\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 @@ -26,9 +60,28 @@ public static function addGlobalScope($scope, $implementation = null) 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 986eb8bb8d..9380eb10e0 100644 --- a/Eloquent/Concerns/HasRelationships.php +++ b/Eloquent/Concerns/HasRelationships.php @@ -4,9 +4,12 @@ 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\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\PendingHasThroughRelationship; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -38,6 +41,20 @@ 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. * @@ -54,6 +71,41 @@ trait HasRelationships */ 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. * @@ -69,13 +121,108 @@ public static function resolveRelationUsing($name, Closure $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 + * @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 + * @return \Illuminate\Database\Eloquent\Relations\HasOne */ public function hasOne($related, $foreignKey = null, $localKey = null) { @@ -85,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) { @@ -105,13 +255,16 @@ 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) { @@ -122,23 +275,31 @@ public function hasOneThrough($related, $through, $firstKey = null, $secondKey = $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) { @@ -148,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|null $type * @param string|null $id * @param string|null $localKey - * @return \Illuminate\Database\Eloquent\Relations\MorphOne + * @return \Illuminate\Database\Eloquent\Relations\MorphOne */ public function morphOne($related, $name, $type = null, $id = null, $localKey = null) { @@ -161,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) { @@ -186,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 + * @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 + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null) { @@ -223,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) { @@ -242,7 +411,7 @@ protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $owne * @param string|null $type * @param string|null $id * @param string|null $ownerKey - * @return \Illuminate\Database\Eloquent\Relations\MorphTo + * @return \Illuminate\Database\Eloquent\Relations\MorphTo<\Illuminate\Database\Eloquent\Model, $this> */ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null) { @@ -259,8 +428,8 @@ public function morphTo($name = null, $type = null, $id = null, $ownerKey = null // 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 is_null($class = $this->getAttributeFromArray($type)) || $class === '' - ? $this->morphEagerTo($name, $type, $id, $ownerKey) - : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); + ? $this->morphEagerTo($name, $type, $id, $ownerKey) + : $this->morphInstanceTo($class, $name, $type, $id, $ownerKey); } /** @@ -269,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) { @@ -286,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) { @@ -303,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) { @@ -334,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 + * @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 + * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function hasMany($related, $foreignKey = null, $localKey = null) { @@ -356,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) { @@ -377,13 +579,16 @@ 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) { @@ -407,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) { @@ -424,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|null $type * @param string|null $id * @param string|null $localKey - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return \Illuminate\Database\Eloquent\Relations\MorphMany */ public function morphMany($related, $name, $type = null, $id = null, $localKey = null) { @@ -440,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) { @@ -465,18 +677,26 @@ protected function newMorphMany(Builder $query, Model $parent, $type, $id, $loca /** * Define a many-to-many relationship. * - * @param string $related - * @param string|null $table + * @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 + * @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. @@ -501,35 +721,52 @@ 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|null $relationName - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @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|null $table * @param string|null $foreignPivotKey @@ -538,12 +775,19 @@ protected function newBelongsToMany(Builder $query, Model $parent, $table, $fore * @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, $relation = null, $inverse = false) - { + 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 @@ -567,17 +811,27 @@ 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(), $relation, $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 @@ -586,20 +840,40 @@ public function morphToMany($related, $name, $table = null, $foreignPivotKey = n * @param string $relatedKey * @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|null $table * @param string|null $foreignPivotKey @@ -607,11 +881,18 @@ protected function newMorphToMany(Builder $query, Model $parent, $name, $table, * @param string|null $parentKey * @param string|null $relatedKey * @param string|null $relation - * @return \Illuminate\Database\Eloquent\Relations\MorphToMany + * @return \Illuminate\Database\Eloquent\Relations\MorphToMany */ - public function morphedByMany($related, $name, $table = null, $foreignPivotKey = null, - $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $relation = 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 @@ -620,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, $relation, true + $related, + $name, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relation, + true, ); } @@ -639,7 +927,7 @@ protected function guessBelongsToManyRelation() ); }); - return ! is_null($caller) ? $caller['function'] : null; + return $caller['function'] ?? null; } /** @@ -655,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(), ]; @@ -696,17 +985,19 @@ public function touches($relation) */ public function touchOwners() { - foreach ($this->getTouchedRelations() 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->touchOwners(); + $this->$relation->touchOwners(); + } elseif ($this->$relation instanceof EloquentCollection) { + $this->$relation->each->touchOwners(); + } } - } + }); } /** @@ -726,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() { @@ -749,14 +1042,16 @@ public function getMorphClass() /** * 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()); } }); } @@ -764,8 +1059,10 @@ protected function newRelatedInstance($class) /** * Create a new model instance for a related "through" model. * - * @param string $class - * @return mixed + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $class + * @return TRelatedModel */ protected function newRelatedThroughInstance($class) { @@ -815,6 +1112,8 @@ public function setRelation($relation, $value) { $this->relations[$relation] = $value; + $this->propagateRelationAutoloadCallbackToRelation($relation, $value); + return $this; } @@ -844,6 +1143,18 @@ 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. * diff --git a/Eloquent/Concerns/HasTimestamps.php b/Eloquent/Concerns/HasTimestamps.php index 2b6dfab654..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 @@ -20,6 +22,21 @@ trait HasTimestamps */ 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. * @@ -155,23 +172,27 @@ public function getUpdatedAtColumn() } /** - * Get the fully qualified "created at" column. + * Get the fully-qualified "created at" column. * * @return string|null */ public function getQualifiedCreatedAtColumn() { - return $this->qualifyColumn($this->getCreatedAtColumn()); + $column = $this->getCreatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; } /** - * Get the fully qualified "updated at" column. + * Get the fully-qualified "updated at" column. * * @return string|null */ public function getQualifiedUpdatedAtColumn() { - return $this->qualifyColumn($this->getUpdatedAtColumn()); + $column = $this->getUpdatedAtColumn(); + + return $column ? $this->qualifyColumn($column) : null; } /** @@ -199,7 +220,11 @@ public static function withoutTimestampsOn($models, $callback) try { return $callback(); } finally { - static::$ignoreTimestampsOn = array_values(array_diff(static::$ignoreTimestampsOn, $models)); + foreach ($models as $model) { + if (($key = array_search($model, static::$ignoreTimestampsOn, true)) !== false) { + unset(static::$ignoreTimestampsOn[$key]); + } + } } } diff --git a/Eloquent/Concerns/HasUlids.php b/Eloquent/Concerns/HasUlids.php index b944c5d6b7..344f97338a 100644 --- a/Eloquent/Concerns/HasUlids.php +++ b/Eloquent/Concerns/HasUlids.php @@ -2,29 +2,14 @@ namespace Illuminate\Database\Eloquent\Concerns; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Str; trait HasUlids { - /** - * Boot the trait. - * - * @return void - */ - public static function bootHasUlids() - { - static::creating(function (self $model) { - foreach ($model->uniqueIds() as $column) { - if (empty($model->{$column})) { - $model->{$column} = $model->newUniqueId(); - } - } - }); - } + use HasUniqueStringIds; /** - * Generate a new ULID for the model. + * Generate a new unique key for the model. * * @return string */ @@ -34,63 +19,13 @@ public function newUniqueId() } /** - * Retrieve the model for a bound value. + * Determine if given key is valid. * - * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation $query * @param mixed $value - * @param string|null $field - * @return \Illuminate\Database\Eloquent\Relations\Relation - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function resolveRouteBindingQuery($query, $value, $field = null) - { - if ($field && in_array($field, $this->uniqueIds()) && ! Str::isUlid($value)) { - throw (new ModelNotFoundException)->setModel(get_class($this), $value); - } - - if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! Str::isUlid($value)) { - throw (new ModelNotFoundException)->setModel(get_class($this), $value); - } - - return parent::resolveRouteBindingQuery($query, $value, $field); - } - - /** - * Get the columns that should receive a unique identifier. - * - * @return array - */ - public function uniqueIds() - { - return [$this->getKeyName()]; - } - - /** - * Get the auto-incrementing key type. - * - * @return string - */ - public function getKeyType() - { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return 'string'; - } - - return $this->keyType; - } - - /** - * Get the value indicating whether the IDs are incrementing. - * * @return bool */ - public function getIncrementing() + protected function isValidUniqueId($value): bool { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return false; - } - - return $this->incrementing; + return Str::isUlid($value); } } diff --git a/Eloquent/Concerns/HasUniqueIds.php b/Eloquent/Concerns/HasUniqueIds.php new file mode 100644 index 0000000000..b67343ac8c --- /dev/null +++ b/Eloquent/Concerns/HasUniqueIds.php @@ -0,0 +1,57 @@ +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 index 96a08b66c4..89d40f829a 100644 --- a/Eloquent/Concerns/HasUuids.php +++ b/Eloquent/Concerns/HasUuids.php @@ -2,95 +2,30 @@ namespace Illuminate\Database\Eloquent\Concerns; -use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Str; trait HasUuids { - /** - * Generate a primary UUID for the model. - * - * @return void - */ - public static function bootHasUuids() - { - static::creating(function (self $model) { - foreach ($model->uniqueIds() as $column) { - if (empty($model->{$column})) { - $model->{$column} = $model->newUniqueId(); - } - } - }); - } + use HasUniqueStringIds; /** - * Generate a new UUID for the model. + * Generate a new unique key for the model. * * @return string */ public function newUniqueId() { - return (string) Str::orderedUuid(); + return (string) Str::uuid7(); } /** - * Get the columns that should receive a unique identifier. + * Determine if given key is valid. * - * @return array - */ - public function uniqueIds() - { - return [$this->getKeyName()]; - } - - /** - * 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\Database\Eloquent\Relations\Relation - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function resolveRouteBindingQuery($query, $value, $field = null) - { - if ($field && in_array($field, $this->uniqueIds()) && ! Str::isUuid($value)) { - throw (new ModelNotFoundException)->setModel(get_class($this), $value); - } - - if (! $field && in_array($this->getRouteKeyName(), $this->uniqueIds()) && ! Str::isUuid($value)) { - throw (new ModelNotFoundException)->setModel(get_class($this), $value); - } - - 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 $this->keyType; - } - - /** - * Get the value indicating whether the IDs are incrementing. - * * @return bool */ - public function getIncrementing() + protected function isValidUniqueId($value): bool { - if (in_array($this->getKeyName(), $this->uniqueIds())) { - return false; - } - - return $this->incrementing; + return Str::isUuid($value); } } diff --git a/Eloquent/Concerns/HasVersion4Uuids.php b/Eloquent/Concerns/HasVersion4Uuids.php new file mode 100644 index 0000000000..eac53c67ba --- /dev/null +++ b/Eloquent/Concerns/HasVersion4Uuids.php @@ -0,0 +1,20 @@ +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. * @@ -41,6 +62,19 @@ public function setHidden(array $hidden) return $this; } + /** + * Merge new hidden attributes with existing hidden attributes on the model. + * + * @param array $hidden + * @return $this + */ + public function mergeHidden(array $hidden) + { + $this->hidden = array_values(array_unique(array_merge($this->hidden, $hidden))); + + return $this; + } + /** * Get the visible attributes for the model. * @@ -64,6 +98,19 @@ public function setVisible(array $visible) return $this; } + /** + * Merge new visible attributes with existing visible attributes on the model. + * + * @param array $visible + * @return $this + */ + public function mergeVisible(array $visible) + { + $this->visible = array_values(array_unique(array_merge($this->visible, $visible))); + + return $this; + } + /** * Make the given, typically hidden, attributes visible. * @@ -77,7 +124,7 @@ public function makeVisible($attributes) $this->hidden = array_diff($this->hidden, $attributes); if (! empty($this->visible)) { - $this->visible = array_merge($this->visible, $attributes); + $this->visible = array_values(array_unique(array_merge($this->visible, $attributes))); } return $this; @@ -103,9 +150,9 @@ public function makeVisibleIf($condition, $attributes) */ public function makeHidden($attributes) { - $this->hidden = array_merge( + $this->hidden = array_values(array_unique(array_merge( $this->hidden, is_array($attributes) ? $attributes : func_get_args() - ); + ))); return $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 0c05bf974a..6072dcff3d 100644 --- a/Eloquent/Concerns/QueriesRelationships.php +++ b/Eloquent/Concerns/QueriesRelationships.php @@ -5,31 +5,38 @@ use BadMethodCallException; use Closure; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; +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 \Illuminate\Database\Eloquent\Relations\Relation|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 (is_string($relation)) { if (str_contains($relation, '.')) { @@ -47,8 +54,8 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', C // 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 @@ -73,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) { @@ -89,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. @@ -104,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) { @@ -117,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); } @@ -130,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) { @@ -141,13 +159,15 @@ 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); } @@ -155,30 +175,32 @@ public function whereHas($relation, Closure $callback = null, $operator = '>=', /** * Add a relationship count / exists condition to the query with where clauses. * - * Also load the relationship with same condition. + * Also load the relationship with the same condition. * * @param string $relation - * @param \Closure|null $callback + * @param (\Closure(\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Database\Eloquent\Relations\Relation<*, *, *>): 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 withWhereHas($relation, Closure $callback = null, $operator = '>=', $count = 1) + public function withWhereHas($relation, ?Closure $callback = null, $operator = '>=', $count = 1) { - return $this->whereHas($relation, $callback, $operator, $count) + 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|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 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); } @@ -186,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); } @@ -198,11 +222,13 @@ 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|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 orWhereDoesntHave($relation, Closure $callback = null) + public function orWhereDoesntHave($relation, ?Closure $callback = null) { return $this->doesntHave($relation, 'or', $callback); } @@ -210,15 +236,17 @@ public function orWhereDoesntHave($relation, Closure $callback = null) /** * Add a polymorphic relationship count / exists condition to the query. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types * @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, string): mixed)|null $callback + * @return $this */ - public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', Closure $callback = null) + public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { if (is_string($relation)) { $relation = $this->getRelationWithoutConstraints($relation); @@ -226,15 +254,28 @@ public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boole $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()->all(); + $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) { + 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); @@ -246,18 +287,23 @@ public function hasMorph($relation, $types, $operator = '>=', $count = 1, $boole } $query->where($this->qualifyColumn($relation->getMorphType()), '=', (new $type)->getMorphClass()) - ->whereHas($belongsTo, $callback, $operator, $count); + ->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. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo $relation - * @param string $type - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @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) { @@ -277,11 +323,11 @@ protected function getBelongsToRelation(MorphTo $relation, $type) /** * 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 \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param string|array $types * @param string $operator - * @param int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ public function orHasMorph($relation, $types, $operator = '>=', $count = 1) { @@ -291,13 +337,15 @@ public function orHasMorph($relation, $types, $operator = '>=', $count = 1) /** * Add a polymorphic relationship count / exists condition to the query. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation + * @param string|array $types * @param string $boolean - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @param (\Closure(\Illuminate\Database\Eloquent\Builder, string): mixed)|null $callback + * @return $this */ - public function doesntHaveMorph($relation, $types, $boolean = 'and', Closure $callback = null) + public function doesntHaveMorph($relation, $types, $boolean = 'and', ?Closure $callback = null) { return $this->hasMorph($relation, $types, '<', 1, $boolean, $callback); } @@ -305,9 +353,9 @@ public function doesntHaveMorph($relation, $types, $boolean = 'and', Closure $ca /** * 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 \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param string|array $types + * @return $this */ public function orDoesntHaveMorph($relation, $types) { @@ -317,14 +365,16 @@ public function orDoesntHaveMorph($relation, $types) /** * Add a polymorphic relationship count / exists condition to the query with where clauses. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|null $callback + * @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 int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ - public function whereHasMorph($relation, $types, Closure $callback = null, $operator = '>=', $count = 1) + public function whereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->hasMorph($relation, $types, $operator, $count, 'and', $callback); } @@ -332,14 +382,16 @@ public function whereHasMorph($relation, $types, Closure $callback = null, $oper /** * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|null $callback + * @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 int $count - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Contracts\Database\Query\Expression|int $count + * @return $this */ - public function orWhereHasMorph($relation, $types, Closure $callback = null, $operator = '>=', $count = 1) + public function orWhereHasMorph($relation, $types, ?Closure $callback = null, $operator = '>=', $count = 1) { return $this->hasMorph($relation, $types, $operator, $count, 'or', $callback); } @@ -347,12 +399,14 @@ public function orWhereHasMorph($relation, $types, Closure $callback = null, $op /** * Add a polymorphic relationship count / exists condition to the query with where clauses. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @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) + public function whereDoesntHaveMorph($relation, $types, ?Closure $callback = null) { return $this->doesntHaveMorph($relation, $types, 'and', $callback); } @@ -360,12 +414,14 @@ public function whereDoesntHaveMorph($relation, $types, Closure $callback = null /** * Add a polymorphic relationship count / exists condition to the query with where clauses and an "or". * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Builder|static + * @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) + public function orWhereDoesntHaveMorph($relation, $types, ?Closure $callback = null) { return $this->doesntHaveMorph($relation, $types, 'or', $callback); } @@ -373,11 +429,13 @@ public function orWhereDoesntHaveMorph($relation, $types, Closure $callback = nu /** * Add a basic where clause to a relationship query. * - * @param string $relation - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @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 \Illuminate\Database\Eloquent\Builder|static + * @return $this */ public function whereRelation($relation, $column, $operator = null, $value = null) { @@ -390,14 +448,35 @@ public function whereRelation($relation, $column, $operator = null, $value = nul }); } + /** + * 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. * - * @param string $relation - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @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 \Illuminate\Database\Eloquent\Builder|static + * @return $this */ public function orWhereRelation($relation, $column, $operator = null, $value = null) { @@ -410,15 +489,61 @@ public function orWhereRelation($relation, $column, $operator = null, $value = n }); } + /** + * 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. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @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 \Illuminate\Database\Eloquent\Builder|static + * @return $this */ public function whereMorphRelation($relation, $types, $column, $operator = null, $value = null) { @@ -430,12 +555,14 @@ public function whereMorphRelation($relation, $types, $column, $operator = null, /** * Add a polymorphic relationship condition to the query with an "or where" clause. * - * @param \Illuminate\Database\Eloquent\Relations\MorphTo|string $relation - * @param string|array $types - * @param \Closure|string|array|\Illuminate\Database\Query\Expression $column + * @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 \Illuminate\Database\Eloquent\Builder|static + * @return $this */ public function orWhereMorphRelation($relation, $types, $column, $operator = null, $value = null) { @@ -444,12 +571,52 @@ public function orWhereMorphRelation($relation, $types, $column, $operator = nul }); } + /** + * 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|string $model - * @return \Illuminate\Database\Eloquent\Builder|static + * @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') { @@ -457,6 +624,10 @@ public function whereMorphedTo($relation, $model, $boolean = 'and') $relation = $this->getRelationWithoutConstraints($relation); } + if (is_null($model)) { + return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean); + } + if (is_string($model)) { $morphMap = Relation::morphMap(); @@ -464,21 +635,33 @@ public function whereMorphedTo($relation, $model, $boolean = 'and') $model = array_search($model, $morphMap, true); } - return $this->where($relation->getMorphType(), $model, null, $boolean); + 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, $model) { - $query->where($relation->getMorphType(), $model->getMorphClass()) - ->where($relation->getForeignKeyName(), $model->getKey()); + 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|string $model - * @return \Illuminate\Database\Eloquent\Builder|static + * @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') { @@ -493,21 +676,31 @@ public function whereNotMorphedTo($relation, $model, $boolean = 'and') $model = array_search($model, $morphMap, true); } - return $this->whereNot($relation->getMorphType(), '<=>', $model, $boolean); + 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, $model) { - $query->where($relation->getMorphType(), '<=>', $model->getMorphClass()) - ->where($relation->getForeignKeyName(), '<=>', $model->getKey()); + 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|string $model - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string|null $model + * @return $this */ public function orWhereMorphedTo($relation, $model) { @@ -517,9 +710,9 @@ public function orWhereMorphedTo($relation, $model) /** * 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|string $model - * @return \Illuminate\Database\Eloquent\Builder|static + * @param \Illuminate\Database\Eloquent\Relations\MorphTo<*, *>|string $relation + * @param \Illuminate\Database\Eloquent\Model|iterable|string $model + * @return $this */ public function orWhereNotMorphedTo($relation, $model) { @@ -529,16 +722,17 @@ public function orWhereNotMorphedTo($relation, $model) /** * Add a "belongs to" relationship where clause to the query. * - * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection<\Illuminate\Database\Eloquent\Model> $related + * @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 Collection) { + if (! $related instanceof EloquentCollection) { $relatedCollection = $related->newCollection([$related]); } else { $relatedCollection = $related; @@ -556,7 +750,7 @@ public function whereBelongsTo($related, $relationshipName = null, $boolean = 'a try { $relationship = $this->model->{$relationshipName}(); - } catch (BadMethodCallException $exception) { + } catch (BadMethodCallException) { throw RelationNotFoundException::make($this->model, $relationshipName); } @@ -574,25 +768,81 @@ public function whereBelongsTo($related, $relationshipName = null, $boolean = 'a } /** - * Add an "BelongsTo" relationship with an "or where" clause to the query. + * Add a "BelongsTo" 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 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 string $column - * @param string $function + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param string|null $function * @return $this */ public function withAggregate($relations, $column, $function = null) @@ -622,15 +872,19 @@ public function withAggregate($relations, $column, $function = null) $relation = $this->getRelationWithoutConstraints($name); if ($function) { - $hashedColumn = $this->getRelationHashedColumn($column, $relation); + if ($this->getQuery()->getGrammar()->isExpression($column)) { + $aggregateColumn = $this->getQuery()->getGrammar()->getValue($column); + } else { + $hashedColumn = $this->getRelationHashedColumn($column, $relation); - $wrappedColumn = $this->getQuery()->getGrammar()->wrap( - $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) - ); + $aggregateColumn = $this->getQuery()->getGrammar()->wrap( + $column === '*' ? $column : $relation->getRelated()->qualifyColumn($hashedColumn) + ); + } - $expression = $function === 'exists' ? $wrappedColumn : sprintf('%s(%s)', $function, $wrappedColumn); + $expression = $function === 'exists' ? $aggregateColumn : sprintf('%s(%s)', $function, $aggregateColumn); } else { - $expression = $column; + $expression = $this->getQuery()->getGrammar()->getValue($column); } // Here, we will grab the relationship sub-query and prepare to add it to the main query @@ -659,7 +913,11 @@ public function withAggregate($relations, $column, $function = null) // 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', '', "$name $function $column") + preg_replace( + '/[^[:alnum:][:space:]_]/u', + '', + sprintf('%s %s %s', $name, $function, strtolower($this->getQuery()->getGrammar()->getValue($column))) + ) ); if ($function === 'exists') { @@ -682,7 +940,7 @@ public function withAggregate($relations, $column, $function = null) * Get the relation hashed column name for the given column and relation. * * @param string $column - * @param \Illuminate\Database\Eloquent\Relations\Relationship $relation + * @param \Illuminate\Database\Eloquent\Relations\Relation<*, *, *> $relation * @return string */ protected function getRelationHashedColumn($column, $relation) @@ -711,7 +969,7 @@ public function withCount($relations) * Add subselect queries to include the max of the relation's column. * * @param string|array $relation - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function withMax($relation, $column) @@ -723,7 +981,7 @@ public function withMax($relation, $column) * Add subselect queries to include the min of the relation's column. * * @param string|array $relation - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function withMin($relation, $column) @@ -735,7 +993,7 @@ public function withMin($relation, $column) * Add subselect queries to include the sum of the relation's column. * * @param string|array $relation - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function withSum($relation, $column) @@ -747,7 +1005,7 @@ public function withSum($relation, $column) * Add subselect queries to include the average of the relation's column. * * @param string|array $relation - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function withAvg($relation, $column) @@ -769,27 +1027,27 @@ public function withExists($relation) /** * 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) { @@ -798,7 +1056,7 @@ public function mergeConstraintsFrom(Builder $from) $wheres = $from->getQuery()->from !== $this->getQuery()->from ? $this->requalifyWhereTables( $from->getQuery()->wheres, - $from->getQuery()->from, + $from->getQuery()->grammar->getValue($from->getQuery()->from), $this->getModel()->getTable() ) : $from->getQuery()->wheres; @@ -822,8 +1080,8 @@ public function mergeConstraintsFrom(Builder $from) */ protected function requalifyWhereTables(array $wheres, string $from, string $to): array { - return collect($wheres)->map(function ($where) use ($from, $to) { - return collect($where)->map(function ($value) use ($from, $to) { + 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; @@ -836,7 +1094,7 @@ protected function requalifyWhereTables(array $wheres, string $from, string $to) * * @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 */ @@ -856,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) { @@ -869,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 instanceof Factory ? $this->factory->create([], $model) : $this->factory)->each(function ($attachable) use ($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 diff --git a/Eloquent/Factories/BelongsToRelationship.php b/Eloquent/Factories/BelongsToRelationship.php index b2fb1b251a..5979183d92 100644 --- a/Eloquent/Factories/BelongsToRelationship.php +++ b/Eloquent/Factories/BelongsToRelationship.php @@ -33,7 +33,6 @@ class BelongsToRelationship * * @param \Illuminate\Database\Eloquent\Factories\Factory|\Illuminate\Database\Eloquent\Model $factory * @param string $relationship - * @return void */ public function __construct($factory, $relationship) { diff --git a/Eloquent/Factories/CrossJoinSequence.php b/Eloquent/Factories/CrossJoinSequence.php index b0efbd0c80..594120b385 100644 --- a/Eloquent/Factories/CrossJoinSequence.php +++ b/Eloquent/Factories/CrossJoinSequence.php @@ -9,8 +9,7 @@ class CrossJoinSequence extends Sequence /** * Create a new cross join sequence instance. * - * @param array $sequences - * @return void + * @param array ...$sequences */ public function __construct(...$sequences) { diff --git a/Eloquent/Factories/Factory.php b/Eloquent/Factories/Factory.php index 389e47baa6..43fb2bcc29 100644 --- a/Eloquent/Factories/Factory.php +++ b/Eloquent/Factories/Factory.php @@ -7,15 +7,20 @@ use Illuminate\Container\Container; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Factories\Attributes\UseModel; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Enumerable; use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\Macroable; +use ReflectionClass; use Throwable; +use UnitEnum; + +use function Illuminate\Support\enum_value; /** * @template TModel of \Illuminate\Database\Eloquent\Model @@ -31,7 +36,7 @@ abstract class Factory /** * The name of the factory's corresponding model. * - * @var class-string<\Illuminate\Database\Eloquent\Model|TModel> + * @var class-string */ protected $model; @@ -84,10 +89,24 @@ abstract class Factory */ 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 string|null + * @var \UnitEnum|string|null */ protected $connection; @@ -103,15 +122,22 @@ abstract class Factory * * @var string */ - protected static $namespace = 'Database\\Factories\\'; + public static $namespace = 'Database\\Factories\\'; /** - * The default model name resolver. + * @deprecated use $modelNameResolvers * - * @var callable + * @var callable(self): class-string */ protected static $modelNameResolver; + /** + * The default model name resolvers. + * + * @var array> + */ + protected static $modelNameResolvers = []; + /** * The factory name resolver. * @@ -119,6 +145,20 @@ abstract class Factory */ 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. * @@ -128,19 +168,23 @@ abstract class Factory * @param \Illuminate\Support\Collection|null $for * @param \Illuminate\Support\Collection|null $afterMaking * @param \Illuminate\Support\Collection|null $afterCreating - * @param string|null $connection + * @param \UnitEnum|string|null $connection * @param \Illuminate\Support\Collection|null $recycle - * @return void + * @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) - { + 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; @@ -150,6 +194,8 @@ public function __construct($count = null, $this->connection = $connection; $this->recycle = $recycle ?? new Collection; $this->faker = $this->withFaker(); + $this->expandRelationships = $expandRelationships ?? self::$expandRelationshipsByDefault; + $this->excludeRelationships = $excludeRelationships; } /** @@ -184,7 +230,7 @@ public static function times(int $count) /** * Configure the factory. * - * @return $this + * @return static */ public function configure() { @@ -213,7 +259,7 @@ public function raw($attributes = [], ?Model $parent = null) * Create a single model and persist it to the database. * * @param (callable(array): array)|array $attributes - * @return \Illuminate\Database\Eloquent\Model|TModel + * @return TModel */ public function createOne($attributes = []) { @@ -224,7 +270,7 @@ public function createOne($attributes = []) * Create a single model and persist it to the database without dispatching any model events. * * @param (callable(array): array)|array $attributes - * @return \Illuminate\Database\Eloquent\Model|TModel + * @return TModel */ public function createOneQuietly($attributes = []) { @@ -234,13 +280,21 @@ public function createOneQuietly($attributes = []) /** * Create a collection of models and persist them to the database. * - * @param iterable> $records - * @return \Illuminate\Database\Eloquent\Collection + * @param int|null|iterable> $records + * @return \Illuminate\Database\Eloquent\Collection */ - public function createMany(iterable $records) + 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( - collect($records)->map(function ($record) { + (new Collection($records))->map(function ($record) { return $this->state($record)->create(); }) ); @@ -249,14 +303,12 @@ public function createMany(iterable $records) /** * Create a collection of models and persist them to the database without dispatching any model events. * - * @param iterable> $records - * @return \Illuminate\Database\Eloquent\Collection + * @param int|null|iterable> $records + * @return \Illuminate\Database\Eloquent\Collection */ - public function createManyQuietly(iterable $records) + public function createManyQuietly(int|iterable|null $records = null) { - return Model::withoutEvents(function () use ($records) { - return $this->createMany($records); - }); + return Model::withoutEvents(fn () => $this->createMany($records)); } /** @@ -264,7 +316,7 @@ public function createManyQuietly(iterable $records) * * @param (callable(array): array)|array $attributes * @param \Illuminate\Database\Eloquent\Model|null $parent - * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|TModel + * @return \Illuminate\Database\Eloquent\Collection|TModel */ public function create($attributes = [], ?Model $parent = null) { @@ -275,9 +327,9 @@ public function create($attributes = [], ?Model $parent = null) $results = $this->make($attributes, $parent); if ($results instanceof Model) { - $this->store(collect([$results])); + $this->store(new Collection([$results])); - $this->callAfterCreating(collect([$results]), $parent); + $this->callAfterCreating(new Collection([$results]), $parent); } else { $this->store($results); @@ -292,13 +344,11 @@ public function create($attributes = [], ?Model $parent = null) * * @param (callable(array): array)|array $attributes * @param \Illuminate\Database\Eloquent\Model|null $parent - * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|TModel + * @return \Illuminate\Database\Eloquent\Collection|TModel */ public function createQuietly($attributes = [], ?Model $parent = null) { - return Model::withoutEvents(function () use ($attributes, $parent) { - return $this->create($attributes, $parent); - }); + return Model::withoutEvents(fn () => $this->create($attributes, $parent)); } /** @@ -306,7 +356,7 @@ public function createQuietly($attributes = [], ?Model $parent = null) * * @param array $attributes * @param \Illuminate\Database\Eloquent\Model|null $parent - * @return \Closure(): (\Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|TModel) + * @return \Closure(): (\Illuminate\Database\Eloquent\Collection|TModel) */ public function lazy(array $attributes = [], ?Model $parent = null) { @@ -316,7 +366,7 @@ public function lazy(array $attributes = [], ?Model $parent = null) /** * Set the connection name on the results and store them. * - * @param \Illuminate\Support\Collection $results + * @param \Illuminate\Support\Collection $results * @return void */ protected function store(Collection $results) @@ -328,6 +378,12 @@ protected function store(Collection $results) $model->save(); + foreach ($model->getRelations() as $name => $items) { + if ($items instanceof Enumerable && $items->isEmpty()) { + $model->unsetRelation($name); + } + } + $this->createChildren($model); }); } @@ -351,7 +407,7 @@ protected function createChildren(Model $model) * Make a single instance of the model. * * @param (callable(array): array)|array $attributes - * @return \Illuminate\Database\Eloquent\Model|TModel + * @return TModel */ public function makeOne($attributes = []) { @@ -363,31 +419,95 @@ public function makeOne($attributes = []) * * @param (callable(array): array)|array $attributes * @param \Illuminate\Database\Eloquent\Model|null $parent - * @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|TModel + * @return \Illuminate\Database\Eloquent\Collection|TModel */ public function make($attributes = [], ?Model $parent = null) { - if (! empty($attributes)) { - return $this->state($attributes)->make([], $parent); + $autoEagerLoadingEnabled = Model::isAutomaticallyEagerLoadingRelationships(); + + if ($autoEagerLoadingEnabled) { + Model::automaticallyEagerLoadRelationships(false); } - if ($this->count === null) { - return tap($this->makeInstance($parent), function ($instance) { - $this->callAfterMaking(collect([$instance])); - }); + 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); - if ($this->count < 1) { - return $this->newModel()->newCollection(); + $this->count = null; + + if (is_numeric($records)) { + $records = array_fill(0, $records, []); } - $instances = $this->newModel()->newCollection(array_map(function () use ($parent) { - return $this->makeInstance($parent); - }, range(1, $this->count))); + 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]); - $this->callAfterMaking($instances); + $model = $madeCollection->first(); - return $instances; + if (isset($this->connection)) { + $model->setConnection($this->connection); + } + + $query = $model->newQueryWithoutScopes(); + + $query->fillAndInsert( + $madeCollection->withoutAppends() + ->setHidden([]) + ->map(static fn (Model $model) => $model->attributesToArray()) + ->all() + ); } /** @@ -446,11 +566,10 @@ protected function getRawAttributes(?Model $parent) */ protected function parentResolvers() { - $model = $this->newModel(); - - return $this->for->map(function (BelongsToRelationship $for) use ($model) { - return $for->recycle($this->recycle)->attributesFor($model); - })->collapse()->all(); + return $this->for + ->map(fn (BelongsToRelationship $for) => $for->recycle($this->recycle)->attributesFor($this->newModel())) + ->collapse() + ->all(); } /** @@ -461,10 +580,15 @@ protected function parentResolvers() */ protected function expandAttributes(array $definition) { - return collect($definition) - ->map($evaluateRelations = function ($attribute) { - if ($attribute instanceof self) { - $attribute = $this->getRandomRecycledModel($attribute->modelName()) + 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(); @@ -477,7 +601,7 @@ protected function expandAttributes(array $definition) $attribute = $attribute($definition); } - $attribute = $evaluateRelations($attribute); + $attribute = $evaluateRelations($attribute, $key); $definition[$key] = $attribute; @@ -489,20 +613,33 @@ protected function expandAttributes(array $definition) /** * Add a new state transformation to the model definition. * - * @param (callable(array, \Illuminate\Database\Eloquent\Model|null): array)|array $state + * @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 : function () use ($state) { - return $state; - }, + 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. * @@ -518,7 +655,7 @@ public function set($key, $value) /** * Add a new sequenced state transformation to the model definition. * - * @param array $sequence + * @param mixed ...$sequence * @return static */ public function sequence(...$sequence) @@ -529,7 +666,7 @@ public function 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 + * @param array ...$sequence * @return static */ public function forEachSequence(...$sequence) @@ -540,7 +677,7 @@ public function forEachSequence(...$sequence) /** * Add a new cross joined sequenced state transformation to the model definition. * - * @param array $sequence + * @param array ...$sequence * @return static */ public function crossJoinSequence(...$sequence) @@ -639,8 +776,10 @@ public function recycle($model) /** * Retrieve a random model of a given type from previously provided models to recycle. * - * @param string $modelClassName - * @return \Illuminate\Database\Eloquent\Model|null + * @template TClass of \Illuminate\Database\Eloquent\Model + * + * @param class-string $modelClassName + * @return TClass|null */ public function getRandomRecycledModel($modelClassName) { @@ -650,7 +789,7 @@ public function getRandomRecycledModel($modelClassName) /** * Add a new "after making" callback to the model definition. * - * @param \Closure(\Illuminate\Database\Eloquent\Model|TModel): mixed $callback + * @param \Closure(TModel): mixed $callback * @return static */ public function afterMaking(Closure $callback) @@ -661,7 +800,7 @@ public function afterMaking(Closure $callback) /** * Add a new "after creating" callback to the model definition. * - * @param \Closure(\Illuminate\Database\Eloquent\Model|TModel): mixed $callback + * @param \Closure(TModel, \Illuminate\Database\Eloquent\Model|null): mixed $callback * @return static */ public function afterCreating(Closure $callback) @@ -669,6 +808,26 @@ 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. * @@ -711,13 +870,34 @@ 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 string $connection + * @param \UnitEnum|string|null $connection * @return static */ - public function connection(string $connection) + public function connection(UnitEnum|string|null $connection) { return $this->newInstance(['connection' => $connection]); } @@ -739,6 +919,8 @@ protected function newInstance(array $arguments = []) 'afterCreating' => $this->afterCreating, 'connection' => $this->connection, 'recycle' => $this->recycle, + 'expandRelationships' => $this->expandRelationships, + 'excludeRelationships' => $this->excludeRelationships, ], $arguments))); } @@ -746,7 +928,7 @@ protected function newInstance(array $arguments = []) * Get a new model instance. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model|TModel + * @return TModel */ public function newModel(array $attributes = []) { @@ -758,13 +940,29 @@ public function newModel(array $attributes = []) /** * Get the name of the model that is generated by the factory. * - * @return class-string<\Illuminate\Database\Eloquent\Model|TModel> + * @return class-string */ public function modelName() { - $resolver = static::$modelNameResolver ?? function (self $factory) { + 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, '', get_class($factory)) + 'Factory', '', Str::replaceFirst(static::$namespace, '', $factory::class) ); $factoryBasename = Str::replaceLast('Factory', '', class_basename($factory)); @@ -772,22 +970,22 @@ public function modelName() $appNamespace = static::appNamespace(); return class_exists($appNamespace.'Models\\'.$namespacedFactoryBasename) - ? $appNamespace.'Models\\'.$namespacedFactoryBasename - : $appNamespace.$factoryBasename; + ? $appNamespace.'Models\\'.$namespacedFactoryBasename + : $appNamespace.$factoryBasename; }; - return $this->model ?? $resolver($this); + return $resolver($this); } /** * Specify the callback that should be invoked to guess model names based on factory names. * - * @param callable(self): class-string<\Illuminate\Database\Eloquent\Model|TModel> $callback + * @param callable(self): class-string $callback * @return void */ public static function guessModelNamesUsing(callable $callback) { - static::$modelNameResolver = $callback; + static::$modelNameResolvers[static::class] = $callback; } /** @@ -804,8 +1002,10 @@ public static function useNamespace(string $namespace) /** * Get a new factory instance for the given model name. * - * @param class-string<\Illuminate\Database\Eloquent\Model> $modelName - * @return \Illuminate\Database\Eloquent\Factories\Factory + * @template TClass of \Illuminate\Database\Eloquent\Model + * + * @param class-string $modelName + * @return \Illuminate\Database\Eloquent\Factories\Factory */ public static function factoryForModel(string $modelName) { @@ -825,21 +1025,47 @@ 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 + * @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. * - * @param class-string<\Illuminate\Database\Eloquent\Model> $modelName - * @return class-string<\Illuminate\Database\Eloquent\Factories\Factory> + * @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) { @@ -865,13 +1091,27 @@ protected static function appNamespace() { try { return Container::getInstance() - ->make(Application::class) - ->getNamespace(); - } catch (Throwable $e) { + ->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. * @@ -885,7 +1125,7 @@ public function __call($method, $parameters) return $this->macroCall($method, $parameters); } - if ($method === 'trashed' && in_array(SoftDeletes::class, class_uses_recursive($this->modelName()))) { + if ($method === 'trashed' && $this->modelName()::isSoftDeletable()) { return $this->state([ $this->newModel()->getDeletedAtColumn() => $parameters[0] ?? Carbon::now()->subDay(), ]); diff --git a/Eloquent/Factories/HasFactory.php b/Eloquent/Factories/HasFactory.php index f10281d141..d2747cc93c 100644 --- a/Eloquent/Factories/HasFactory.php +++ b/Eloquent/Factories/HasFactory.php @@ -2,31 +2,61 @@ namespace Illuminate\Database\Eloquent\Factories; +use Illuminate\Database\Eloquent\Attributes\UseFactory; + +/** + * @template TFactory of \Illuminate\Database\Eloquent\Factories\Factory + */ trait HasFactory { /** * Get a new factory instance for the model. * - * @param callable|array|int|null $count - * @param callable|array $state - * @return \Illuminate\Database\Eloquent\Factories\Factory + * @param (callable(array, 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(get_called_class()); + $factory = static::newFactory() ?? Factory::factoryForModel(static::class); return $factory - ->count(is_numeric($count) ? $count : null) - ->state(is_callable($count) || is_array($count) ? $count : $state); + ->count(is_numeric($count) ? $count : null) + ->state(is_callable($count) || is_array($count) ? $count : $state); } /** * Create a new factory instance for the model. * - * @return \Illuminate\Database\Eloquent\Factories\Factory + * @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 index 3eb62da38a..e23bc99d78 100644 --- a/Eloquent/Factories/Relationship.php +++ b/Eloquent/Factories/Relationship.php @@ -28,7 +28,6 @@ class Relationship * * @param \Illuminate\Database\Eloquent\Factories\Factory $factory * @param string $relationship - * @return void */ public function __construct(Factory $factory, $relationship) { @@ -50,13 +49,15 @@ public function createFor(Model $parent) $this->factory->state([ $relationship->getMorphType() => $relationship->getMorphClass(), $relationship->getForeignKeyName() => $relationship->getParentKey(), - ])->create([], $parent); + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); } elseif ($relationship instanceof HasOneOrMany) { $this->factory->state([ $relationship->getForeignKeyName() => $relationship->getParentKey(), - ])->create([], $parent); + ])->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent); } elseif ($relationship instanceof BelongsToMany) { - $relationship->attach($this->factory->create([], $parent)); + $relationship->attach( + $this->factory->prependState($relationship->getQuery()->pendingAttributes)->create([], $parent) + ); } } diff --git a/Eloquent/Factories/Sequence.php b/Eloquent/Factories/Sequence.php index 064cc4a4e7..4d350d2ad1 100644 --- a/Eloquent/Factories/Sequence.php +++ b/Eloquent/Factories/Sequence.php @@ -30,8 +30,7 @@ class Sequence implements Countable /** * Create a new sequence instance. * - * @param array $sequence - * @return void + * @param mixed ...$sequence */ public function __construct(...$sequence) { @@ -52,11 +51,13 @@ public function count(): int /** * Get the next value in the sequence. * + * @param array $attributes + * @param \Illuminate\Database\Eloquent\Model|null $parent * @return mixed */ - public function __invoke() + public function __invoke($attributes = [], $parent = null) { - return tap(value($this->sequence[$this->index % $this->count], $this), function () { + return tap(value($this->sequence[$this->index % $this->count], $this, $attributes, $parent), function () { $this->index = $this->index + 1; }); } 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 16b49a1b4d..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,9 +24,8 @@ class HigherOrderBuilderProxy /** * Create a new proxy instance. * - * @param \Illuminate\Database\Eloquent\Builder $builder + * @param \Illuminate\Database\Eloquent\Builder<*> $builder * @param string $method - * @return void */ public function __construct(Builder $builder, $method) { diff --git a/Eloquent/InvalidCastException.php b/Eloquent/InvalidCastException.php index 9d00eb3e4d..f37672c0b9 100644 --- a/Eloquent/InvalidCastException.php +++ b/Eloquent/InvalidCastException.php @@ -33,7 +33,6 @@ class InvalidCastException extends RuntimeException * @param object $model * @param string $column * @param string $castType - * @return static */ public function __construct($model, $column, $castType) { diff --git a/Eloquent/MassPrunable.php b/Eloquent/MassPrunable.php index 254ca9bd29..3edb605ace 100644 --- a/Eloquent/MassPrunable.php +++ b/Eloquent/MassPrunable.php @@ -23,10 +23,12 @@ public function pruneAll(int $chunkSize = 1000) $total = 0; + $softDeletable = static::isSoftDeletable(); + do { - $total += $count = in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) - ? $query->forceDelete() - : $query->delete(); + $total += $count = $softDeletable + ? $query->forceDelete() + : $query->delete(); if ($count > 0) { event(new ModelsPruned(static::class, $total)); @@ -39,7 +41,9 @@ public function pruneAll(int $chunkSize = 1000) /** * Get the prunable model query. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder + * + * @throws \LogicException */ public function prunable() { diff --git a/Eloquent/MissingAttributeException.php b/Eloquent/MissingAttributeException.php index 87935c141d..ef05109927 100755 --- a/Eloquent/MissingAttributeException.php +++ b/Eloquent/MissingAttributeException.php @@ -11,7 +11,6 @@ class MissingAttributeException extends OutOfBoundsException * * @param \Illuminate\Database\Eloquent\Model $model * @param string $key - * @return void */ public function __construct($model, $key) { diff --git a/Eloquent/Model.php b/Eloquent/Model.php index e77ad9701e..95bb65369e 100644 --- a/Eloquent/Model.php +++ b/Eloquent/Model.php @@ -3,6 +3,8 @@ namespace Illuminate\Database\Eloquent; use ArrayAccess; +use Closure; +use Exception; use Illuminate\Contracts\Broadcasting\HasBroadcastChannel; use Illuminate\Contracts\Queue\QueueableCollection; use Illuminate\Contracts\Queue\QueueableEntity; @@ -11,6 +13,12 @@ use Illuminate\Contracts\Support\CanBeEscapedWhenCastToString; use Illuminate\Contracts\Support\Jsonable; use Illuminate\Database\ConnectionResolverInterface as Resolver; +use Illuminate\Database\Eloquent\Attributes\Boot; +use Illuminate\Database\Eloquent\Attributes\Connection; +use Illuminate\Database\Eloquent\Attributes\Initialize; +use Illuminate\Database\Eloquent\Attributes\Scope as LocalScope; +use Illuminate\Database\Eloquent\Attributes\Table; +use Illuminate\Database\Eloquent\Attributes\UseEloquentBuilder; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\Concerns\AsPivot; @@ -19,32 +27,44 @@ use Illuminate\Support\Arr; use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Str; +use Illuminate\Support\Stringable as SupportStringable; use Illuminate\Support\Traits\ForwardsCalls; +use JsonException; use JsonSerializable; use LogicException; +use ReflectionClass; +use ReflectionMethod; +use Stringable; -abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToString, HasBroadcastChannel, Jsonable, JsonSerializable, QueueableEntity, UrlRoutable +use function Illuminate\Support\enum_value; + +abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToString, HasBroadcastChannel, Jsonable, JsonSerializable, QueueableEntity, Stringable, UrlRoutable { use Concerns\HasAttributes, Concerns\HasEvents, Concerns\HasGlobalScopes, Concerns\HasRelationships, Concerns\HasTimestamps, + Concerns\HasUniqueIds, Concerns\HidesAttributes, Concerns\GuardsAttributes, + Concerns\PreventsCircularRecursion, + Concerns\TransformsToResource, ForwardsCalls; + /** @use HasCollection<\Illuminate\Database\Eloquent\Collection> */ + use HasCollection; /** * The connection name for the model. * - * @var string|null + * @var \UnitEnum|string|null */ protected $connection; /** * The table associated with the model. * - * @var string + * @var string|null */ protected $table; @@ -105,7 +125,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt 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 */ @@ -128,10 +148,17 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * 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. * @@ -139,6 +166,13 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ 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. * @@ -167,10 +201,17 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ 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|null + * @var (callable(self, string))|null */ protected static $lazyLoadingViolationCallback; @@ -184,7 +225,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling discarded attribute violations. * - * @var callable|null + * @var (callable(self, array))|null */ protected static $discardedAttributeViolationCallback; @@ -198,7 +239,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * The callback that is responsible for handling missing attribute violations. * - * @var callable|null + * @var (callable(self, string))|null */ protected static $missingAttributeViolationCallback; @@ -209,6 +250,48 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt */ 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. * @@ -226,17 +309,14 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt /** * 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); } @@ -244,18 +324,34 @@ 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); } } @@ -293,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]); } /** @@ -324,6 +425,31 @@ 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. * @@ -334,6 +460,19 @@ 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. * @@ -342,7 +481,8 @@ protected static function booted() public static function clearBootedModels() { static::$booted = []; - + static::$bootedCallbacks = []; + static::$classAttributes = []; static::$globalScopes = []; } @@ -385,7 +525,14 @@ public static function isIgnoringTouch($class = null) { $class = $class ?: static::class; - if (! get_class_vars($class)['timestamps'] || ! $class::UPDATED_AT) { + if (! $class::UPDATED_AT) { + return true; + } + + $timestamps = static::resolveClassAttribute(Table::class, 'timestamps', $class) + ?? get_class_vars($class)['timestamps']; + + if (! $timestamps) { return true; } @@ -422,10 +569,21 @@ 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|null $callback + * @param (callable(self, string))|null $callback * @return void */ public static function handleLazyLoadingViolationUsing(?callable $callback) @@ -447,7 +605,7 @@ public static function preventSilentlyDiscardingAttributes($value = true) /** * Register a callback that is responsible for handling discarded attribute violations. * - * @param callable|null $callback + * @param (callable(self, array))|null $callback * @return void */ public static function handleDiscardedAttributeViolationUsing(?callable $callback) @@ -467,9 +625,9 @@ public static function preventAccessingMissingAttributes($value = true) } /** - * Register a callback that is responsible for handling lazy loading violations. + * Register a callback that is responsible for handling missing attribute violations. * - * @param callable|null $callback + * @param (callable(self, string))|null $callback * @return void */ public static function handleMissingAttributeViolationUsing(?callable $callback) @@ -499,7 +657,7 @@ public static function withoutBroadcasting(callable $callback) /** * Fill the model with an array of attributes. * - * @param array $attributes + * @param array $attributes * @return $this * * @throws \Illuminate\Database\Eloquent\MassAssignmentException @@ -549,7 +707,7 @@ 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) @@ -580,15 +738,15 @@ public function qualifyColumn($column) */ public function qualifyColumns($columns) { - return collect($columns)->map(function ($column) { - return $this->qualifyColumn($column); - })->all(); + 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 */ @@ -617,8 +775,8 @@ public function newInstance($attributes = [], $exists = false) /** * 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) @@ -627,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); @@ -637,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\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function onWriteConnection() { @@ -679,7 +833,7 @@ public static function all($columns = ['*']) * Begin querying a model with eager loading. * * @param array|string $relations - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function with($relations) { @@ -745,7 +899,7 @@ public function loadMissing($relations) * * @param array|string $relations * @param string $column - * @param string $function + * @param string|null $function * @return $this */ public function loadAggregate($relations, $column, $function = null) @@ -833,7 +987,7 @@ public function loadExists($relations) * @param string $relation * @param array $relations * @param string $column - * @param string $function + * @param string|null $function * @return $this */ public function loadMorphAggregate($relation, $relations, $column, $function = null) @@ -950,10 +1104,8 @@ 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) @@ -966,7 +1118,11 @@ protected function incrementOrDecrement($column, $amount, $extra, $method) return false; } - return tap($this->setKeysForSaveQuery($query)->{$method}($column, $amount, $extra), function () use ($column) { + if ($this->isClassDeviable($column)) { + $amount = (clone $this)->setAttribute($column, $amount)->getAttributeFromArray($column); + } + + return tap($this->setKeysForSaveQuery($this->newQueryWithoutScopes())->{$method}($column, $amount, $extra), function () use ($column) { $this->syncChanges(); $this->fireModelEvent('updated', false); @@ -978,8 +1134,8 @@ protected function incrementOrDecrement($column, $amount, $extra, $method) /** * Update the model in the database. * - * @param array $attributes - * @param array $options + * @param array $attributes + * @param array $options * @return bool */ public function update(array $attributes = [], array $options = []) @@ -994,8 +1150,8 @@ public function update(array $attributes = [], array $options = []) /** * 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 @@ -1012,8 +1168,8 @@ public function updateOrFail(array $attributes = [], array $options = []) /** * Update the model in the database without raising any events. * - * @param array $attributes - * @param array $options + * @param array $attributes + * @param array $options * @return bool */ public function updateQuietly(array $attributes = [], array $options = []) @@ -1035,9 +1191,9 @@ public function updateQuietly(array $attributes = [], array $options = []) */ protected function incrementQuietly($column, $amount = 1, array $extra = []) { - return static::withoutEvents(function () use ($column, $amount, $extra) { - return $this->incrementOrDecrement($column, $amount, $extra, 'increment'); - }); + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'increment') + ); } /** @@ -1050,9 +1206,9 @@ protected function incrementQuietly($column, $amount = 1, array $extra = []) */ protected function decrementQuietly($column, $amount = 1, array $extra = []) { - return static::withoutEvents(function () use ($column, $amount, $extra) { - return $this->incrementOrDecrement($column, $amount, $extra, 'decrement'); - }); + return static::withoutEvents( + fn () => $this->incrementOrDecrement($column, $amount, $extra, 'decrement') + ); } /** @@ -1062,25 +1218,38 @@ protected function decrementQuietly($column, $amount = 1, array $extra = []) */ public function push() { - 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]; + return $this->withoutRecursion(function () { + if (! $this->save()) { + return false; + } - foreach (array_filter($models) as $model) { - if (! $model->push()) { - 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()); } /** @@ -1153,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)); } /** @@ -1178,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) @@ -1200,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); @@ -1216,8 +1383,8 @@ protected function performUpdate(Builder $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 setKeysForSelectQuery($query) { @@ -1239,8 +1406,8 @@ protected function getKeyForSelectQuery() /** * 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($query) { @@ -1262,11 +1429,15 @@ protected function getKeyForSaveQuery() /** * 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; } @@ -1313,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) @@ -1426,9 +1597,7 @@ public function deleteOrFail() return false; } - return $this->getConnection()->transaction(function () { - return $this->delete(); - }); + return $this->getConnection()->transaction(fn () => $this->delete()); } /** @@ -1443,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. * @@ -1458,7 +1640,7 @@ protected function performDeleteOnModel() /** * Begin querying the model. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public static function query() { @@ -1468,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() { @@ -1478,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() { @@ -1490,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() { @@ -1500,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) { @@ -1515,7 +1697,7 @@ 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() { @@ -1528,7 +1710,7 @@ public function newQueryWithoutScopes() * 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) { @@ -1539,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 @@ -1604,7 +1794,8 @@ public function newPivot(self $parent, array $attributes, $table, $exists, $usin */ public function hasNamedScope($scope) { - return method_exists($this, 'scope'.ucfirst($scope)); + return method_exists($this, 'scope'.ucfirst($scope)) || + static::isScopeMethodWithAttribute($scope); } /** @@ -1616,9 +1807,26 @@ public function hasNamedScope($scope) */ 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) !== []; + } + /** * Convert the model instance to an array. * @@ -1626,7 +1834,10 @@ public function callNamedScope($scope, array $parameters = []) */ public function toArray() { - return array_merge($this->attributesToArray(), $this->relationsToArray()); + return $this->withoutRecursion( + fn () => array_merge($this->attributesToArray(), $this->relationsToArray()), + fn () => $this->attributesToArray(), + ); } /** @@ -1639,15 +1850,28 @@ public function toArray() */ public function toJson($options = 0) { - $json = json_encode($this->jsonSerialize(), $options); - - if (json_last_error() !== JSON_ERROR_NONE) { - 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. * @@ -1694,10 +1918,10 @@ public function refresh() ->attributes ); - $this->load(collect($this->relations)->reject(function ($relation) { - return $relation instanceof Pivot - || (is_object($relation) && in_array(AsPivot::class, class_uses_recursive($relation), true)); - })->keys()->all()); + $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(); @@ -1710,12 +1934,14 @@ public function refresh() * @param array|null $except * @return static */ - public function replicate(array $except = null) + public function replicate(?array $except = null) { $defaults = array_values(array_filter([ $this->getKeyName(), $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), + ...$this->uniqueIds(), + 'laravel_through_key', ])); $attributes = Arr::except( @@ -1737,7 +1963,7 @@ public function replicate(array $except = null) * @param array|null $except * @return static */ - public function replicateQuietly(array $except = null) + public function replicateQuietly(?array $except = null) { return static::withoutEvents(fn () => $this->replicate($except)); } @@ -1784,13 +2010,13 @@ public function getConnection() */ public function getConnectionName() { - return $this->connection; + return enum_value($this->connection); } /** * Set the connection associated with the model. * - * @param string|null $name + * @param \UnitEnum|string|null $name * @return $this */ public function setConnection($name) @@ -1803,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) @@ -1814,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() { @@ -1971,29 +2197,31 @@ 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 $entityValue) { - $relations[] = $key.'.'.$entityValue; + if ($relation instanceof QueueableEntity) { + foreach ($relation->getQueueableRelations() as $entityValue) { + $relations[] = $key.'.'.$entityValue; + } } } - } - return array_unique($relations); + return array_unique($relations); + }, []); } /** @@ -2082,7 +2310,7 @@ public function resolveSoftDeletableChildRouteBinding($childType, $value, $field * @param string $childType * @param mixed $value * @param string|null $field - * @return \Illuminate\Database\Eloquent\Relations\Relation + * @return \Illuminate\Database\Eloquent\Relations\Relation<\Illuminate\Database\Eloquent\Model, $this, *> */ protected function resolveChildRouteBindingQuery($childType, $value, $field) { @@ -2092,7 +2320,7 @@ protected function resolveChildRouteBindingQuery($childType, $value, $field) if ($relationship instanceof HasManyThrough || $relationship instanceof BelongsToMany) { - $field = $relationship->getRelated()->getTable().'.'.$field; + $field = $relationship->getRelated()->qualifyColumn($field); } return $relationship instanceof Model @@ -2114,10 +2342,10 @@ protected function childRouteBindingRelationshipName($childType) /** * Retrieve the model for a bound value. * - * @param \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Relations\Relation $query + * @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\Database\Eloquent\Relations\Relation + * @return \Illuminate\Contracts\Database\Eloquent\Builder */ public function resolveRouteBindingQuery($query, $value, $field = null) { @@ -2157,6 +2385,30 @@ 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. * @@ -2167,6 +2419,16 @@ 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. * @@ -2207,6 +2469,45 @@ 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. * @@ -2238,10 +2539,14 @@ public function __set($key, $value) */ public function offsetExists($offset): bool { + $shouldPrevent = static::$modelsShouldPreventAccessingMissingAttributes; + + static::$modelsShouldPreventAccessingMissingAttributes = false; + try { return ! is_null($this->getAttribute($offset)); - } catch (MissingAttributeException) { - return false; + } finally { + static::$modelsShouldPreventAccessingMissingAttributes = $shouldPrevent; } } @@ -2276,7 +2581,12 @@ public function offsetSet($offset, $value): void */ 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] + ); } /** @@ -2310,14 +2620,19 @@ 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 = (static::$relationResolvers[get_class($this)][$method] ?? null)) { + 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); } @@ -2330,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); } @@ -2369,8 +2688,20 @@ public function __sleep() $this->classCastCache = []; $this->attributeCastCache = []; + $this->relationAutoloadCallback = null; + $this->relationAutoloadContext = null; + + $keys = get_object_vars($this); - return array_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); } /** @@ -2381,7 +2712,11 @@ public function __sleep() 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/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 index b4ce1b0340..15423dd638 100644 --- a/Eloquent/Prunable.php +++ b/Eloquent/Prunable.php @@ -2,8 +2,10 @@ namespace Illuminate\Database\Eloquent; +use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Database\Events\ModelsPruned; use LogicException; +use Throwable; trait Prunable { @@ -12,18 +14,32 @@ trait Prunable * * @param int $chunkSize * @return int + * + * @throws \Throwable */ public function pruneAll(int $chunkSize = 1000) { $total = 0; $this->prunable() - ->when(in_array(SoftDeletes::class, class_uses_recursive(get_class($this))), function ($query) { + ->when(static::isSoftDeletable(), function ($query) { $query->withTrashed(); })->chunkById($chunkSize, function ($models) use (&$total) { - $models->each->prune(); + $models->each(function ($model) use (&$total) { + try { + $model->prune(); + + $total++; + } catch (Throwable $e) { + $handler = app(ExceptionHandler::class); - $total += $models->count(); + if ($handler) { + $handler->report($e); + } else { + throw $e; + } + } + }); event(new ModelsPruned(static::class, $total)); }); @@ -34,7 +50,9 @@ public function pruneAll(int $chunkSize = 1000) /** * Get the prunable model query. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder + * + * @throws \LogicException */ public function prunable() { @@ -50,9 +68,9 @@ public function prune() { $this->pruning(); - return in_array(SoftDeletes::class, class_uses_recursive(get_class($this))) - ? $this->forceDelete() - : $this->delete(); + return static::isSoftDeletable() + ? $this->forceDelete() + : $this->delete(); } /** diff --git a/Eloquent/Relations/BelongsTo.php b/Eloquent/Relations/BelongsTo.php index d7bfa0f60a..a550eac730 100755 --- a/Eloquent/Relations/BelongsTo.php +++ b/Eloquent/Relations/BelongsTo.php @@ -3,12 +3,20 @@ namespace Illuminate\Database\Eloquent\Relations; 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 ComparesRelatedModels, @@ -18,7 +26,7 @@ class BelongsTo extends Relation /** * The child model instance of the relation. * - * @var \Illuminate\Database\Eloquent\Model + * @var TDeclaringModel */ protected $child; @@ -46,12 +54,11 @@ class BelongsTo extends Relation /** * 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) { @@ -67,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); } @@ -92,24 +95,19 @@ 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); @@ -119,7 +117,7 @@ public function addEagerConstraints(array $models) /** * Gather the keys from an array of related models. * - * @param array $models + * @param array $models * @return array */ protected function getEagerModelKeys(array $models) @@ -130,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; } } @@ -140,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) { @@ -156,27 +148,16 @@ 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) { - $attribute = $this->getDictionaryKey($result->getAttribute($owner)); + $attribute = $this->getDictionaryKey($this->getRelatedKeyFrom($result)); $dictionary[$attribute] = $result; } @@ -185,10 +166,10 @@ public function match(array $models, Collection $results, $relation) // 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) { - $attribute = $this->getDictionaryKey($model->{$foreign}); + $attribute = $this->getDictionaryKey($this->getForeignKeyFrom($model)); - if (isset($dictionary[$attribute])) { - $model->setRelation($relation, $dictionary[$attribute]); + if (isset($dictionary[$attribute ?? ''])) { + $model->setRelation($relation, $dictionary[$attribute ?? '']); } } @@ -198,8 +179,8 @@ public function match(array $models, Collection $results, $relation) /** * Associate the model instance to the given parent. * - * @param \Illuminate\Database\Eloquent\Model|int|string|null $model - * @return \Illuminate\Database\Eloquent\Model + * @param TRelatedModel|int|string|null $model + * @return TDeclaringModel */ public function associate($model) { @@ -219,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() { @@ -231,7 +212,7 @@ public function dissociate() /** * Alias of "dissociate" method. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function disassociate() { @@ -239,13 +220,18 @@ public function disassociate() } /** - * Add the constraints for a relationship query. + * 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) { @@ -260,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 = ['*']) { @@ -292,8 +278,8 @@ protected function relationHasIncrementingId() /** * 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) { @@ -303,7 +289,7 @@ protected function newRelatedInstanceFor(Model $parent) /** * Get the child of the relationship. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function getChild() { @@ -321,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 */ @@ -337,7 +323,7 @@ public function getQualifiedForeignKeyName() */ public function getParentKey() { - return $this->child->{$this->foreignKey}; + return $this->getForeignKeyFrom($this->child); } /** @@ -351,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 */ @@ -361,16 +347,29 @@ public function getQualifiedOwnerKeyName() } /** - * Get the value of the model's associated key. + * Get the value of the model's foreign key. * - * @param \Illuminate\Database\Eloquent\Model $model - * @return mixed + * @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 c90c87bd26..2a7e011ddf 100755 --- a/Eloquent/Relations/BelongsToMany.php +++ b/Eloquent/Relations/BelongsToMany.php @@ -5,15 +5,29 @@ 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 InteractsWithDictionary, InteractsWithPivotTable; @@ -63,7 +77,7 @@ class BelongsToMany extends Relation /** * The pivot table columns to retrieve. * - * @var array + * @var array */ protected $pivotColumns = []; @@ -105,47 +119,53 @@ 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'; /** * 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|null $relationName - * @return void */ - public function __construct(Builder $query, Model $parent, $table, $foreignPivotKey, - $relatedPivotKey, $parentKey, $relatedKey, $relationName = null) - { + public function __construct( + Builder $query, + Model $parent, + $table, + $foreignPivotKey, + $relatedPivotKey, + $parentKey, + $relatedKey, + $relationName = null, + ) { $this->parentKey = $parentKey; $this->relatedKey = $relatedKey; $this->relationName = $relationName; @@ -198,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) @@ -232,12 +252,7 @@ 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); @@ -249,13 +264,7 @@ public function addEagerConstraints(array $models) ); } - /** - * 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) { @@ -265,15 +274,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); @@ -296,20 +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'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) { + $isAssociative = Arr::isAssoc($results->all()); + + foreach ($results as $key => $result) { $value = $this->getDictionaryKey($result->{$this->accessor}->{$this->foreignPivotKey}); - $dictionary[$value][] = $result; + if ($isAssociative) { + $dictionary[$value][$key] = $result; + } else { + $dictionary[$value][] = $result; + } } return $dictionary; @@ -318,7 +326,7 @@ protected function buildDictionary(Collection $results) /** * Get the class being used for pivot models. * - * @return string + * @return class-string */ public function getPivotClass() { @@ -328,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) { @@ -341,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) { @@ -354,7 +370,7 @@ public function as($accessor) /** * Set a where clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean @@ -370,7 +386,7 @@ public function wherePivot($column, $operator = null, $value = null, $boolean = /** * Set a "where between" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param array $values * @param string $boolean * @param bool $not @@ -384,7 +400,7 @@ public function wherePivotBetween($column, array $values, $boolean = 'and', $not /** * Set a "or where between" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param array $values * @return $this */ @@ -396,7 +412,7 @@ public function orWherePivotBetween($column, array $values) /** * Set a "where pivot not between" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param array $values * @param string $boolean * @return $this @@ -409,7 +425,7 @@ public function wherePivotNotBetween($column, array $values, $boolean = 'and') /** * Set a "or where not between" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param array $values * @return $this */ @@ -421,7 +437,7 @@ public function orWherePivotNotBetween($column, array $values) /** * Set a "where in" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $values * @param string $boolean * @param bool $not @@ -437,7 +453,7 @@ public function wherePivotIn($column, $values, $boolean = 'and', $not = false) /** * Set an "or where" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @return $this @@ -452,7 +468,7 @@ 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 * @@ -492,7 +508,7 @@ public function orWherePivotIn($column, $values) /** * Set a "where not in" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $values * @param string $boolean * @return $this @@ -517,7 +533,7 @@ public function orWherePivotNotIn($column, $values) /** * Set a "where null" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param string $boolean * @param bool $not * @return $this @@ -532,7 +548,7 @@ public function wherePivotNull($column, $boolean = 'and', $not = false) /** * Set a "where not null" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param string $boolean * @return $this */ @@ -544,7 +560,7 @@ public function wherePivotNotNull($column, $boolean = 'and') /** * Set a "or where null" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param bool $not * @return $this */ @@ -556,7 +572,7 @@ public function orWherePivotNull($column, $not = false) /** * Set a "or where not null" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @return $this */ public function orWherePivotNotNull($column) @@ -567,7 +583,7 @@ public function orWherePivotNotNull($column) /** * Add an "order by" clause for a pivot table column. * - * @param string $column + * @param string|\Illuminate\Contracts\Database\Query\Expression $column * @param string $direction * @return $this */ @@ -576,12 +592,27 @@ 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 = ['*']) { @@ -597,7 +628,7 @@ public function findOrNew($id, $columns = ['*']) * * @param array $attributes * @param array $values - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel&object{pivot: TPivotModel} */ public function firstOrNew(array $attributes = [], array $values = []) { @@ -609,21 +640,25 @@ 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 + * @param (\Closure(): array)|array $values * @param array $joining * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel&object{pivot: TPivotModel} */ - public function firstOrCreate(array $attributes = [], array $values = [], array $joining = [], $touch = true) + public function firstOrCreate(array $attributes = [], Closure|array $values = [], array $joining = [], $touch = true) { if (is_null($instance = (clone $this)->where($attributes)->first())) { if (is_null($instance = $this->related->where($attributes)->first())) { - $instance = $this->create(array_merge($attributes, $values), $joining, $touch); + $instance = $this->createOrFirst($attributes, $values, $joining, $touch); } else { - $this->attach($instance, $joining, $touch); + try { + $this->getQuery()->withSavepointIfNeeded(fn () => $this->attach($instance, $joining, $touch)); + } catch (UniqueConstraintViolationException) { + // Nothing to do, the model was already attached... + } } } @@ -631,29 +666,51 @@ public function firstOrCreate(array $attributes = [], array $values = [], array } /** - * 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 + * @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 = (clone $this)->where($attributes)->first())) { - if (is_null($instance = $this->related->where($attributes)->first())) { - return $this->create(array_merge($attributes, $values), $joining, $touch); - } else { - $this->attach($instance, $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]); + } + }); } /** @@ -661,7 +718,11 @@ 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 = ['*']) { @@ -674,12 +735,29 @@ public function find($id, $columns = ['*']) )->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 \Illuminate\Contracts\Support\Arrayable|array $ids * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function findMany($ids, $columns = ['*']) { @@ -689,8 +767,8 @@ public function findMany($ids, $columns = ['*']) return $this->getRelated()->newCollection(); } - return $this->whereIn( - $this->getRelated()->getQualifiedKeyName(), $this->parseIds($ids) + return $this->whereKey( + $this->parseIds($ids) )->get($columns); } @@ -699,9 +777,13 @@ 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<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ public function findOrFail($id, $columns = ['*']) { @@ -723,12 +805,18 @@ public function findOrFail($id, $columns = ['*']) /** * Find a related model by its primary key or call a callback. * + * @template TValue + * * @param mixed $id - * @param \Closure|array $columns - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|mixed + * @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) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -758,7 +846,7 @@ public function findOr($id, $columns = ['*'], Closure $callback = null) * @param mixed $operator * @param mixed $value * @param string $boolean - * @return \Illuminate\Database\Eloquent\Model|static + * @return (TRelatedModel&object{pivot: TPivotModel})|null */ public function firstWhere($column, $operator = null, $value = null, $boolean = 'and') { @@ -769,11 +857,11 @@ public function firstWhere($column, $operator = null, $value = null, $boolean = * Execute the query and get the first result. * * @param array $columns - * @return mixed + * @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; } @@ -782,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<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException */ public function firstOrFail($columns = ['*']) { @@ -798,11 +886,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 (TRelatedModel&object{pivot: TPivotModel})|TValue */ - public function firstOr($columns = ['*'], Closure $callback = null) + public function firstOr($columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -817,24 +907,15 @@ public function firstOr($columns = ['*'], Closure $callback = null) return $callback(); } - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @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 @@ -857,7 +938,9 @@ public function get($columns = ['*']) $models = $builder->eagerLoadRelations($models); } - return $this->related->newCollection($models); + return $this->query->applyAfterQueryCallbacks( + $this->related->newCollection($models) + ); } /** @@ -869,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()); @@ -884,11 +967,14 @@ protected function shouldSelect(array $columns = ['*']) */ protected function aliasedPivotColumns() { - $defaults = [$this->foreignPivotKey, $this->relatedPivotKey]; - - return collect(array_merge($defaults, $this->pivotColumns))->map(function ($column) { - return $this->qualifyPivotColumn($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(); } /** @@ -898,7 +984,7 @@ protected function aliasedPivotColumns() * @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) { @@ -916,7 +1002,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', * @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) { @@ -934,7 +1020,7 @@ public function simplePaginate($perPage = null, $columns = ['*'], $pageName = 'p * @param array $columns * @param string $cursorName * @param string|null $cursor - * @return \Illuminate\Contracts\Pagination\CursorPaginator + * @return \Illuminate\Contracts\Pagination\CursorPaginator */ public function cursorPaginate($perPage = null, $columns = ['*'], $cursorName = 'cursor', $cursor = null) { @@ -972,19 +1058,66 @@ public function chunk($count, callable $callback) */ public function chunkById($count, callable $callback, $column = null, $alias = null) { - $this->prepareQueryBuilder(); + 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); + } + + /** + * 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 ??= $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); } /** @@ -1009,7 +1142,7 @@ public function each(callable $callback, $count = 1000) * Query lazily, by chunks of the given size. * * @param int $chunkSize - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection */ public function lazy($chunkSize = 1000) { @@ -1026,7 +1159,7 @@ public function lazy($chunkSize = 1000) * @param int $chunkSize * @param string|null $column * @param string|null $alias - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection */ public function lazyById($chunkSize = 1000, $column = null, $alias = null) { @@ -1043,10 +1176,33 @@ public function lazyById($chunkSize = 1000, $column = null, $alias = null) }); } + /** + * 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 + * @return \Illuminate\Support\LazyCollection */ public function cursor() { @@ -1060,7 +1216,7 @@ public function cursor() /** * Prepare the query builder for query execution. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ protected function prepareQueryBuilder() { @@ -1070,7 +1226,7 @@ protected function prepareQueryBuilder() /** * Hydrate the pivot table relationship on the models. * - * @param array $models + * @param array $models * @return void */ protected function hydratePivotRelation(array $models) @@ -1088,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) @@ -1154,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(), @@ -1164,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() { @@ -1181,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 + * @return TRelatedModel&object{pivot: TPivotModel} */ public function save(Model $model, array $pivotAttributes = [], $touch = true) { @@ -1198,10 +1356,10 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) /** * Save a new model without raising any events 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 + * @return TRelatedModel&object{pivot: TPivotModel} */ public function saveQuietly(Model $model, array $pivotAttributes = [], $touch = true) { @@ -1213,9 +1371,11 @@ public function saveQuietly(Model $model, array $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 = []) { @@ -1231,9 +1391,11 @@ public function saveMany($models, array $pivotAttributes = []) /** * Save an array of new models without raising any events 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 saveManyQuietly($models, array $pivotAttributes = []) { @@ -1248,10 +1410,12 @@ public function saveManyQuietly($models, array $pivotAttributes = []) * @param array $attributes * @param array $joining * @param bool $touch - * @return \Illuminate\Database\Eloquent\Model + * @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 @@ -1269,7 +1433,7 @@ public function create(array $attributes = [], array $joining = [], $touch = tru * * @param iterable $records * @param array $joinings - * @return array + * @return array */ public function createMany(iterable $records, array $joinings = []) { @@ -1284,14 +1448,7 @@ public function createMany(iterable $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) { @@ -1306,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 = ['*']) { @@ -1324,6 +1481,42 @@ public function getRelationExistenceQueryForSelfJoin(Builder $query, Builder $pa return parent::getRelationExistenceQuery($query, $parentQuery, $columns); } + /** + * 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->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. * @@ -1337,18 +1530,23 @@ public function getExistenceCompareKey() /** * 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; } /** @@ -1358,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; } /** @@ -1368,7 +1566,7 @@ public function createdAt() */ public function updatedAt() { - return $this->pivotUpdatedAt ?: $this->parent->getUpdatedAtColumn(); + return $this->pivotUpdatedAt ?? $this->parent->getUpdatedAtColumn() ?? Model::UPDATED_AT; } /** @@ -1382,7 +1580,7 @@ public function getForeignPivotKeyName() } /** - * Get the fully qualified foreign key for the relation. + * Get the fully-qualified foreign key for the relation. * * @return string */ @@ -1402,7 +1600,7 @@ public function getRelatedPivotKeyName() } /** - * Get the fully qualified "related key" for the relation. + * Get the fully-qualified "related key" for the relation. * * @return string */ @@ -1422,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 */ @@ -1442,7 +1640,7 @@ public function getRelatedKeyName() } /** - * Get the fully qualified related key name for the relation. + * Get the fully-qualified related key name for the relation. * * @return string */ @@ -1474,7 +1672,7 @@ public function getRelationName() /** * Get the name of the pivot accessor for this relationship. * - * @return string + * @return TAccessor */ public function getPivotAccessor() { @@ -1494,13 +1692,17 @@ public function getPivotColumns() /** * Qualify the given column name by the pivot table. * - * @param string $column - * @return string + * @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; + ? $column + : $this->table.'.'.$column; } } diff --git a/Eloquent/Relations/Concerns/AsPivot.php b/Eloquent/Relations/Concerns/AsPivot.php index e6f9f23015..610592e5a3 100644 --- a/Eloquent/Relations/Concerns/AsPivot.php +++ b/Eloquent/Relations/Concerns/AsPivot.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphPivot; use Illuminate\Support\Str; trait AsPivot @@ -14,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. * @@ -86,8 +94,8 @@ public static function fromRawAttributes(Model $parent, $attributes, $table, $ex /** * 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 setKeysForSelectQuery($query) { @@ -107,8 +115,8 @@ protected function setKeysForSelectQuery($query) /** * 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($query) { @@ -142,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() { @@ -159,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(); } /** @@ -214,6 +222,19 @@ 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. * @@ -222,7 +243,8 @@ public function setPivotKeys($foreignKey, $relatedKey) */ 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); } /** @@ -271,7 +293,7 @@ public function getQueueableId() * Get a new query to restore one or more models by their queueable IDs. * * @param int[]|string[]|string $ids - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function newQueryForRestoration($ids) { @@ -294,7 +316,7 @@ public function newQueryForRestoration($ids) * Get a new query to restore multiple models by their queueable IDs. * * @param int[]|string[] $ids - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ protected function newQueryForCollectionRestoration(array $ids) { @@ -326,6 +348,7 @@ protected function newQueryForCollectionRestoration(array $ids) 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 index 25b9a5834b..800999f86c 100644 --- a/Eloquent/Relations/Concerns/CanBeOneOfMany.php +++ b/Eloquent/Relations/Concerns/CanBeOneOfMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use InvalidArgumentException; trait CanBeOneOfMany @@ -27,14 +28,14 @@ trait CanBeOneOfMany /** * The one of many inner join subselect query builder instance. * - * @var \Illuminate\Database\Eloquent\Builder|null + * @var \Illuminate\Database\Eloquent\Builder<*>|null */ protected $oneOfManySubQuery; /** * Add constraints for inner join subselect for one of many relationships. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder<*> $query * @param string|null $column * @param string|null $aggregate * @return void @@ -96,11 +97,16 @@ public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null) $subQuery = $this->newOneOfManySubQuery( $this->getOneOfManySubQuerySelectColumns(), - $column, $aggregate + array_merge([$column], $previous['columns'] ?? []), + $aggregate, ); if (isset($previous)) { - $this->addOneOfManyJoinSubQuery($subQuery, $previous['subQuery'], $previous['column']); + $this->addOneOfManyJoinSubQuery( + $subQuery, + $previous['subQuery'], + $previous['columns'], + ); } if (isset($closure)) { @@ -112,12 +118,16 @@ public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null) } if (array_key_last($columns) == $column) { - $this->addOneOfManyJoinSubQuery($this->query, $subQuery, $column); + $this->addOneOfManyJoinSubQuery( + $this->query, + $subQuery, + array_merge([$column], $previous['columns'] ?? []), + ); } $previous = [ 'subQuery' => $subQuery, - 'column' => $column, + 'columns' => array_merge([$column], $previous['columns'] ?? []), ]; } @@ -141,7 +151,7 @@ public function ofMany($column = 'id', $aggregate = 'MAX', $relation = null) */ public function latestOfMany($column = 'id', $relation = null) { - return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { return [$column => 'MAX']; })->all(), 'MAX', $relation); } @@ -155,7 +165,7 @@ public function latestOfMany($column = 'id', $relation = null) */ public function oldestOfMany($column = 'id', $relation = null) { - return $this->ofMany(collect(Arr::wrap($column))->mapWithKeys(function ($column) { + return $this->ofMany(Collection::wrap($column)->mapWithKeys(function ($column) { return [$column => 'MIN']; })->all(), 'MIN', $relation); } @@ -177,11 +187,11 @@ protected function getDefaultOneOfManyJoinAlias($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 string|null $column + * @param array|null $columns * @param string|null $aggregate - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder<*> */ - protected function newOneOfManySubQuery($groupBy, $column = null, $aggregate = null) + protected function newOneOfManySubQuery($groupBy, $columns = null, $aggregate = null) { $subQuery = $this->query->getModel() ->newQuery() @@ -191,11 +201,21 @@ protected function newOneOfManySubQuery($groupBy, $column = null, $aggregate = n $subQuery->groupBy($this->qualifyRelatedColumn($group)); } - if (! is_null($column)) { - $subQuery->selectRaw($aggregate.'('.$subQuery->getQuery()->grammar->wrap($subQuery->qualifyColumn($column)).') as '.$subQuery->getQuery()->grammar->wrap($column.'_aggregate')); + 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, $groupBy, $column, $aggregate); + $this->addOneOfManySubQueryConstraints($subQuery, column: null, aggregate: $aggregate); return $subQuery; } @@ -203,9 +223,9 @@ protected function newOneOfManySubQuery($groupBy, $column = null, $aggregate = n /** * 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 string $on + * @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) @@ -214,9 +234,11 @@ protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, $subQuery->applyBeforeQueryCallbacks(); $parent->joinSub($subQuery, $this->relationName, function ($join) use ($on) { - $join->on($this->qualifySubSelectColumn($on.'_aggregate'), '=', $this->qualifyRelatedColumn($on)); + foreach ($on as $onColumn) { + $join->on($this->qualifySubSelectColumn($onColumn.'_aggregate'), '=', $this->qualifyRelatedColumn($onColumn)); + } - $this->addOneOfManyJoinSubQueryConstraints($join, $on); + $this->addOneOfManyJoinSubQueryConstraints($join); }); }); } @@ -224,7 +246,7 @@ protected function addOneOfManyJoinSubQuery(Builder $parent, Builder $subQuery, /** * Merge the relationship query joins to the given query builder. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder<*> $query * @return void */ protected function mergeOneOfManyJoinsTo(Builder $query) @@ -237,7 +259,7 @@ protected function mergeOneOfManyJoinsTo(Builder $query) /** * Get the query builder that will contain the relationship constraints. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder<*> */ protected function getRelationQuery() { @@ -249,7 +271,7 @@ protected function getRelationQuery() /** * Get the one of many inner join subselect builder instance. * - * @return \Illuminate\Database\Eloquent\Builder|void + * @return \Illuminate\Database\Eloquent\Builder<*>|void */ public function getOneOfManySubQuery() { @@ -275,7 +297,7 @@ public function qualifySubSelectColumn($column) */ protected function qualifyRelatedColumn($column) { - return str_contains($column, '.') ? $column : $this->query->getModel()->getTable().'.'.$column; + return $this->query->getModel()->qualifyColumn($column); } /** diff --git a/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/Eloquent/Relations/Concerns/ComparesRelatedModels.php index ca06698875..3dccf13107 100644 --- a/Eloquent/Relations/Concerns/ComparesRelatedModels.php +++ b/Eloquent/Relations/Concerns/ComparesRelatedModels.php @@ -22,8 +22,8 @@ public function is($model) if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) { return $this->query - ->whereKey($model->getKey()) - ->exists(); + ->whereKey($model->getKey()) + ->exists(); } return $match; diff --git a/Eloquent/Relations/Concerns/InteractsWithDictionary.php b/Eloquent/Relations/Concerns/InteractsWithDictionary.php index 91b3bf5bd4..d94432e9a4 100644 --- a/Eloquent/Relations/Concerns/InteractsWithDictionary.php +++ b/Eloquent/Relations/Concerns/InteractsWithDictionary.php @@ -2,10 +2,11 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; -use BackedEnum; -use Doctrine\Instantiator\Exception\InvalidArgumentException; +use InvalidArgumentException; use UnitEnum; +use function Illuminate\Support\enum_value; + trait InteractsWithDictionary { /** @@ -14,7 +15,7 @@ trait InteractsWithDictionary * @param mixed $attribute * @return mixed * - * @throws \Doctrine\Instantiator\Exception\InvalidArgumentException + * @throws \InvalidArgumentException */ protected function getDictionaryKey($attribute) { @@ -23,9 +24,8 @@ protected function getDictionaryKey($attribute) return $attribute->__toString(); } - if (function_exists('enum_exists') && - $attribute instanceof UnitEnum) { - return $attribute instanceof BackedEnum ? $attribute->value : $attribute->name; + if ($attribute instanceof UnitEnum) { + return enum_value($attribute); } throw new InvalidArgumentException('Model attribute value is an object but does not have a __toString method.'); diff --git a/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/Eloquent/Relations/Concerns/InteractsWithPivotTable.php index 2241719b7b..dd324d09a6 100644 --- a/Eloquent/Relations/Concerns/InteractsWithPivotTable.php +++ b/Eloquent/Relations/Concerns/InteractsWithPivotTable.php @@ -2,7 +2,8 @@ namespace Illuminate\Database\Eloquent\Relations\Concerns; -use Illuminate\Database\Eloquent\Collection; +use BackedEnum; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Collection as BaseCollection; @@ -65,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) { @@ -76,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 \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array|int|string $ids * @param bool $detaching - * @return array + * @return array{attached: array, detached: array, updated: array} */ public function sync($ids, $detaching = true) { @@ -86,13 +87,17 @@ 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->getCurrentlyAttachedPivots() - ->pluck($this->relatedPivotKey)->all(); - - $records = $this->formatRecordsList($this->parseIds($ids)); + ->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 @@ -101,7 +106,7 @@ public function sync($ids, $detaching = true) $detach = array_diff($current, array_keys($records)); if (count($detach) > 0) { - $this->detach($detach); + $this->detach($detach, false); $changes['detached'] = $this->castKeys($detach); } @@ -129,14 +134,14 @@ public function sync($ids, $detaching = true) /** * 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 $ids + * @param \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model|array|int|string $ids * @param array $values * @param bool $detaching - * @return array + * @return array{attached: array, detached: array, updated: array} */ public function syncWithPivotValues($ids, array $values, bool $detaching = true) { - return $this->sync(collect($this->parseIds($ids))->mapWithKeys(function ($id) use ($values) { + return $this->sync((new BaseCollection($this->parseIds($ids)))->mapWithKeys(function ($id) use ($values) { return [$id => $values]; }), $detaching); } @@ -149,11 +154,15 @@ public function syncWithPivotValues($ids, array $values, bool $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(); } @@ -202,10 +211,7 @@ protected function attachNew(array $records, array $current, $touch = true) */ public function updateExistingPivot($id, array $attributes, $touch = true) { - if ($this->using && - empty($this->pivotWheres) && - empty($this->pivotWhereIns) && - empty($this->pivotWhereNulls)) { + if ($this->using) { return $this->updateExistingPivotUsingCustomClass($id, $attributes, $touch); } @@ -213,7 +219,7 @@ public function updateExistingPivot($id, array $attributes, $touch = true) $attributes = $this->addTimestampsToAttachment($attributes, true); } - $updated = $this->newPivotStatementForId($this->parseId($id))->update( + $updated = $this->newPivotStatementForId($id)->update( $this->castAttributes($attributes) ); @@ -234,10 +240,7 @@ public function updateExistingPivot($id, array $attributes, $touch = true) */ protected function updateExistingPivotUsingCustomClass($id, array $attributes, $touch) { - $pivot = $this->getCurrentlyAttachedPivots() - ->where($this->foreignPivotKey, $this->parent->{$this->parentKey}) - ->where($this->relatedPivotKey, $this->parseId($id)) - ->first(); + $pivot = $this->getCurrentlyAttachedPivotsForIds($id)->first(); $updated = $pivot ? $pivot->fill($attributes)->isDirty() : false; @@ -255,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 * @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 )); } @@ -281,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) { @@ -351,8 +354,8 @@ 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]; } /** @@ -396,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())) { @@ -430,11 +433,7 @@ public function hasPivotColumn($column) */ public function detach($ids = null, $touch = true) { - if ($this->using && - ! empty($ids) && - empty($this->pivotWheres) && - empty($this->pivotWhereIns) && - empty($this->pivotWhereNulls)) { + if ($this->using) { $results = $this->detachUsingCustomClass($ids); } else { $query = $this->newPivotQuery(); @@ -475,11 +474,10 @@ protected function detachUsingCustomClass($ids) { $results = 0; - foreach ($this->parseIds($ids) as $id) { - $results += $this->newPivot([ - $this->foreignPivotKey => $this->parent->{$this->parentKey}, - $this->relatedPivotKey => $id, - ], true)->delete(); + $records = $this->getCurrentlyAttachedPivotsForIds($ids); + + foreach ($records as $record) { + $results += $record->delete(); } return $results; @@ -492,13 +490,31 @@ protected function detachUsingCustomClass($ids) */ protected function getCurrentlyAttachedPivots() { - return $this->newPivotQuery()->get()->map(function ($record) { - $class = $this->using ?: Pivot::class; - - $pivot = $class::fromRawAttributes($this->parent, (array) $record, $this->getTable(), true); + return $this->getCurrentlyAttachedPivotsForIds(); + } - return $pivot->setPivotKeys($this->foreignPivotKey, $this->relatedPivotKey); - }); + /** + * 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); + }); } /** @@ -510,11 +526,15 @@ protected function getCurrentlyAttachedPivots() */ 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); } /** @@ -546,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)); } /** @@ -576,7 +596,7 @@ public function newPivotQuery() /** * Set the columns on the pivot table to retrieve. * - * @param array|mixed $columns + * @param mixed $columns * @return $this */ public function withPivot($columns) @@ -600,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; @@ -658,8 +680,8 @@ protected function castKey($key) protected function castAttributes($attributes) { return $this->using - ? $this->newPivot()->fill($attributes)->getAttributes() - : $attributes; + ? $this->newPivot()->fill($attributes)->getAttributes() + : $attributes; } /** 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 b005d4ff12..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 e5a5ce8330..1b94f1c593 100644 --- a/Eloquent/Relations/HasManyThrough.php +++ b/Eloquent/Relations/HasManyThrough.php @@ -2,178 +2,40 @@ namespace Illuminate\Database\Eloquent\Relations; -use Closure; -use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; -use Illuminate\Database\Eloquent\SoftDeletes; -class HasManyThrough extends Relation +/** + * @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 { use InteractsWithDictionary; /** - * The "through" parent model instance. + * Convert the relationship to a "has one through" relationship. * - * @var \Illuminate\Database\Eloquent\Model + * @return \Illuminate\Database\Eloquent\Relations\HasOneThrough */ - 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; - - /** - * 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) - { - $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); - } - } - - /** - * Set the join clause on the query. - * - * @param \Illuminate\Database\Eloquent\Builder|null $query - * @return void - */ - protected function performJoin(Builder $query = null) + public function one() { - $query = $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()); - }); - } + 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(), + )); } - /** - * 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)); - } - - /** - * Indicate that trashed "through" parents should be included in the query. - * - * @return $this - */ - public function withTrashedParents() - { - $this->query->withoutGlobalScope('SoftDeletableHasManyThrough'); - - return $this; - } - - /** - * 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->whereInEager( - $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) { @@ -183,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); @@ -199,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 = $this->getDictionaryKey($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]) ); @@ -209,566 +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; - } - - /** - * 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 \Illuminate\Database\Eloquent\Model|static - */ - 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 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<\Illuminate\Database\Eloquent\Model> - */ - 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. - * - * @param \Closure|array $columns - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Model|static|mixed - */ - 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 \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|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 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 \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> - */ - 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. - * - * @param mixed $id - * @param \Closure|array $columns - * @param \Closure|null $callback - * @return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|mixed - */ - 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(); - } - - /** - * 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|null $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|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->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 ??= $this->getRelated()->getQualifiedKeyName(); - - $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; - } - } - }); - } - - /** - * 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); - } - - /** - * 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 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 ed85f1e910..911d4e26c7 100755 --- a/Eloquent/Relations/HasOne.php +++ b/Eloquent/Relations/HasOne.php @@ -4,22 +4,24 @@ use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; 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\CanBeOneOfMany; use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; use Illuminate\Database\Query\JoinClause; +/** + * @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 ComparesRelatedModels, CanBeOneOfMany, SupportsDefaultModels; - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { if (is_null($this->getParentKey())) { @@ -29,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) { @@ -45,29 +41,13 @@ 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->matchOne($models, $results, $relation); } - /** - * Add the constraints for an internal relationship existence query. - * - * 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 - */ + /** @inheritDoc */ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { if ($this->isOneOfMany()) { @@ -80,7 +60,7 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Add constraints for inner join subselect for one of many relationships. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $query * @param string|null $column * @param string|null $aggregate * @return void @@ -114,21 +94,22 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) /** * 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 \Illuminate\Database\Eloquent\Model $model - * @return mixed + * @param TRelatedModel $model + * @return int|string */ protected function getRelatedKeyFrom(Model $model) { diff --git a/Eloquent/Relations/HasOneOrMany.php b/Eloquent/Relations/HasOneOrMany.php index fb4a354f92..ba32c0e3b3 100755 --- a/Eloquent/Relations/HasOneOrMany.php +++ b/Eloquent/Relations/HasOneOrMany.php @@ -2,14 +2,25 @@ namespace Illuminate\Database\Eloquent\Relations; +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; + use InteractsWithDictionary, SupportsInverseRelations; /** * The foreign key of the parent model. @@ -28,11 +39,10 @@ abstract class HasOneOrMany extends Relation /** * 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) { @@ -46,12 +56,13 @@ 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); }); } @@ -59,7 +70,7 @@ public function make(array $attributes = []) * Create and return an un-saved instance of the related models. * * @param iterable $records - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function makeMany($records) { @@ -88,12 +99,7 @@ public function addConstraints() } } - /** - * 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->localKey); @@ -109,12 +115,12 @@ public function addEagerConstraints(array $models) /** * 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'); } @@ -122,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'); } @@ -135,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); @@ -149,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 = $this->getDictionaryKey($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); } } @@ -177,16 +190,28 @@ 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 [$this->getDictionaryKey($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; } /** @@ -194,7 +219,7 @@ protected function buildDictionary(Collection $results) * * @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 = ['*']) { @@ -212,7 +237,7 @@ 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 = []) { @@ -226,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(array_merge($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) { @@ -273,8 +337,8 @@ public function save(Model $model) /** * Attach a model instance without raising any events to the parent model. * - * @param \Illuminate\Database\Eloquent\Model $model - * @return \Illuminate\Database\Eloquent\Model|false + * @param TRelatedModel $model + * @return TRelatedModel|false */ public function saveQuietly(Model $model) { @@ -286,8 +350,8 @@ public function saveQuietly(Model $model) /** * Attach a collection of models to the parent instance. * - * @param iterable $models - * @return iterable + * @param iterable $models + * @return iterable */ public function saveMany($models) { @@ -301,8 +365,8 @@ public function saveMany($models) /** * Attach a collection of models to the parent instance without raising any events to the parent model. * - * @param iterable $models - * @return iterable + * @param iterable $models + * @return iterable */ public function saveManyQuietly($models) { @@ -315,7 +379,7 @@ public function saveManyQuietly($models) * Create a new instance of the related model. * * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function create(array $attributes = []) { @@ -323,27 +387,51 @@ 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 \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function forceCreate(array $attributes = []) { $attributes[$this->getForeignKeyName()] = $this->getParentKey(); - return $this->related->forceCreate($attributes); + 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 iterable $records - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function createMany(iterable $records) { @@ -356,25 +444,67 @@ public function createMany(iterable $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) { @@ -387,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 = ['*']) { @@ -403,6 +533,34 @@ public function getRelationExistenceQueryForSelfRelation(Builder $query, Builder ); } + /** + * 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->parent->exists) { + $this->query->limit($value); + } else { + $this->query->groupLimit($value, $this->getExistenceCompareKey()); + } + + return $this; + } + /** * Get the key for comparing against the parent key in "has" query. * @@ -424,7 +582,7 @@ public function getParentKey() } /** - * Get the fully qualified parent key name. + * Get the fully-qualified parent key name. * * @return string */ @@ -442,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 ed9c7baa4d..27c3a9da5f 100644 --- a/Eloquent/Relations/HasOneThrough.php +++ b/Eloquent/Relations/HasOneThrough.php @@ -2,32 +2,38 @@ namespace Illuminate\Database\Eloquent\Relations; -use Illuminate\Database\Eloquent\Collection; +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\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 InteractsWithDictionary, 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) { @@ -37,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); @@ -53,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 = $this->getDictionaryKey($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) ); @@ -64,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 282ba2e860..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,25 +53,13 @@ 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); } - /** - * Create a new instance of the related model. Allow mass-assignment. - * - * @param array $attributes - * @return \Illuminate\Database\Eloquent\Model - */ + /** @inheritDoc */ public function forceCreate(array $attributes = []) { $attributes[$this->getMorphType()] = $this->morphClass; diff --git a/Eloquent/Relations/MorphOne.php b/Eloquent/Relations/MorphOne.php index fc8f4dc8ca..fa3632efb3 100755 --- a/Eloquent/Relations/MorphOne.php +++ b/Eloquent/Relations/MorphOne.php @@ -4,22 +4,24 @@ use Illuminate\Contracts\Database\Eloquent\SupportsPartialRelations; 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\CanBeOneOfMany; use Illuminate\Database\Eloquent\Relations\Concerns\ComparesRelatedModels; use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; use Illuminate\Database\Query\JoinClause; +/** + * @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 CanBeOneOfMany, ComparesRelatedModels, SupportsDefaultModels; - /** - * Get the results of the relationship. - * - * @return mixed - */ + /** @inheritDoc */ public function getResults() { if (is_null($this->getParentKey())) { @@ -29,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) { @@ -45,27 +41,13 @@ 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->matchOne($models, $results, $relation); } - /** - * Get the 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 ($this->isOneOfMany()) { @@ -78,7 +60,7 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Add constraints for inner join subselect for one of many relationships. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder $query * @param string|null $column * @param string|null $aggregate * @return void @@ -114,21 +96,24 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) /** * 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 \Illuminate\Database\Eloquent\Model $model - * @return mixed + * @param TRelatedModel $model + * @return int|string */ protected function getRelatedKeyFrom(Model $model) { diff --git a/Eloquent/Relations/MorphOneOrMany.php b/Eloquent/Relations/MorphOneOrMany.php index 6e2297fcc8..7c32befcdf 100755 --- a/Eloquent/Relations/MorphOneOrMany.php +++ b/Eloquent/Relations/MorphOneOrMany.php @@ -4,7 +4,15 @@ 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) { @@ -54,12 +61,7 @@ public function addConstraints() } } - /** - * Set the constraints for an eager load of the relation. - * - * @param array $models - * @return void - */ + /** @inheritDoc */ public function addEagerConstraints(array $models) { parent::addEagerConstraints($models); @@ -67,10 +69,24 @@ public function addEagerConstraints(array $models) $this->getRelationQuery()->where($this->morphType, $this->morphClass); } + /** + * 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(); + $attributes[$this->getMorphType()] = $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,16 +94,40 @@ 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( @@ -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 5ca8b48bed..01aea33950 100644 --- a/Eloquent/Relations/MorphPivot.php +++ b/Eloquent/Relations/MorphPivot.php @@ -18,15 +18,15 @@ 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($query) { @@ -38,8 +38,8 @@ protected function setKeysForSaveQuery($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 setKeysForSelectQuery($query) { @@ -68,6 +68,8 @@ public function delete() $query->where($this->morphType, $this->morphClass); return tap($query->delete(), function () { + $this->exists = false; + $this->fireModelEvent('deleted', false); }); } @@ -98,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) @@ -131,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) { @@ -146,16 +148,16 @@ public function 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 + * @return \Illuminate\Database\Eloquent\Builder */ protected function newQueryForCollectionRestoration(array $ids) { @@ -172,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 262741f30c..556fcf9cd9 100644 --- a/Eloquent/Relations/MorphTo.php +++ b/Eloquent/Relations/MorphTo.php @@ -4,10 +4,17 @@ use BadMethodCallException; 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; @@ -19,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; @@ -64,13 +78,12 @@ class MorphTo extends BelongsTo /** * 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) { @@ -79,31 +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}) { $morphTypeKey = $this->getDictionaryKey($model->{$this->morphType}); $foreignKeyKey = $this->getDictionaryKey($model->{$this->foreignKey}); - $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; + if ($isAssociative) { + $this->dictionary[$morphTypeKey][$foreignKeyKey][$key] = $model; + } else { + $this->dictionary[$morphTypeKey][$foreignKeyKey][] = $model; + } } } } @@ -113,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() { @@ -128,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) { @@ -137,14 +152,14 @@ protected function getResultsByType($type) $ownerKey = $this->ownerKey ?? $instance->getKeyName(); $query = $this->replayMacros($instance->newQuery()) - ->mergeConstraintsFrom($this->getQuery()) - ->with(array_merge( - $this->getQuery()->getEagerLoads(), - (array) ($this->morphableEagerLoads[get_class($instance)] ?? []) - )) - ->withCount( - (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) - ); + ->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); @@ -153,7 +168,7 @@ protected function getResultsByType($type) $whereIn = $this->whereInMethod($instance, $ownerKey); return $query->{$whereIn}( - $instance->getTable().'.'.$ownerKey, $this->gatherKeysByType($type, $instance->getKeyType()) + $instance->qualifyColumn($ownerKey), $this->gatherKeysByType($type, $instance->getKeyType()) )->get(); } @@ -167,17 +182,17 @@ protected function getResultsByType($type) protected function gatherKeysByType($type, $keyType) { return $keyType !== 'string' - ? array_keys($this->dictionary[$type]) - : array_map(function ($modelId) { - return (string) $modelId; - }, array_filter(array_keys($this->dictionary[$type]))); + ? 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) { @@ -190,15 +205,9 @@ public function createModelByType($type) }); } - /** - * 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; } @@ -207,10 +216,10 @@ 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) ? $this->getDictionaryKey($result->{$this->ownerKey}) : $result->getKey(); @@ -226,15 +235,16 @@ 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->ownerKey + : $model->getKeyName(); } $this->parent->setAttribute( @@ -251,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); @@ -262,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(); @@ -309,7 +313,7 @@ public function getDictionary() * Specify which relations to load for a given morph type. * * @param array $with - * @return \Illuminate\Database\Eloquent\Relations\MorphTo + * @return $this */ public function morphWith(array $with) { @@ -324,7 +328,7 @@ public function morphWith(array $with) * Specify which relationship counts to load for a given morph type. * * @param array $withCount - * @return \Illuminate\Database\Eloquent\Relations\MorphTo + * @return $this */ public function morphWithCount(array $withCount) { @@ -339,7 +343,7 @@ public function morphWithCount(array $withCount) * Specify constraints on the query for a given morph type. * * @param array $callbacks - * @return \Illuminate\Database\Eloquent\Relations\MorphTo + * @return $this */ public function constrain(array $callbacks) { @@ -350,11 +354,62 @@ public function constrain(array $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) { @@ -365,6 +420,17 @@ 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. * @@ -387,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 c2d5745582..dd0680c980 100644 --- a/Eloquent/Relations/MorphToMany.php +++ b/Eloquent/Relations/MorphToMany.php @@ -5,7 +5,16 @@ 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,8 +43,8 @@ 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 @@ -44,11 +53,19 @@ class MorphToMany extends BelongsToMany * @param string $relatedKey * @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(); @@ -73,12 +90,7 @@ 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) { parent::addEagerConstraints($models); @@ -100,14 +112,7 @@ 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( @@ -116,17 +121,18 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, } /** - * Get the pivot models that are currently attached. + * Get the pivot models that are currently attached, filtered by related model keys. * - * @return \Illuminate\Support\Collection + * @param mixed $ids + * @return \Illuminate\Support\Collection */ - protected function getCurrentlyAttachedPivots() + protected function getCurrentlyAttachedPivotsForIds($ids = null) { - return parent::getCurrentlyAttachedPivots()->map(function ($record) { + return parent::getCurrentlyAttachedPivotsForIds($ids)->map(function ($record) { return $record instanceof MorphPivot - ? $record->setMorphType($this->morphType) - ->setMorphClass($this->morphClass) - : $record; + ? $record->setMorphType($this->morphType) + ->setMorphClass($this->morphClass) + : $record; }); } @@ -145,18 +151,22 @@ public function newPivotQuery() * * @param array $attributes * @param bool $exists - * @return \Illuminate\Database\Eloquent\Relations\Pivot + * @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; } @@ -170,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->qualifyPivotColumn($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(); } /** @@ -188,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 7fea6b70d9..5f0ced5081 100755 --- a/Eloquent/Relations/Relation.php +++ b/Eloquent/Relations/Relation.php @@ -5,14 +5,22 @@ use Closure; use Illuminate\Contracts\Database\Eloquent\Builder as BuilderContract; 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\MultipleRecordsFoundException; use Illuminate\Database\Query\Expression; +use Illuminate\Support\Collection as BaseCollection; use Illuminate\Support\Traits\ForwardsCalls; use Illuminate\Support\Traits\Macroable; +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * @template TResult + * + * @mixin \Illuminate\Database\Eloquent\Builder + */ abstract class Relation implements BuilderContract { use ForwardsCalls, Macroable { @@ -22,21 +30,21 @@ abstract class Relation implements BuilderContract /** * 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; @@ -55,9 +63,9 @@ abstract class Relation implements BuilderContract protected static $constraints = true; /** - * An array to map class names to their morph names in the database. + * An array to map morph names to their class names in the database. * - * @var array + * @var array> */ public static $morphMap = []; @@ -78,9 +86,8 @@ abstract class Relation implements BuilderContract /** * 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) { @@ -94,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) { @@ -123,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); @@ -131,53 +140,53 @@ 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->eagerKeysWereEmpty - ? $this->query->getModel()->newCollection() - : $this->get(); + ? $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 \Illuminate\Database\Eloquent\Model + * @return TRelatedModel * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\Illuminate\Database\Eloquent\Model> + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException * @throws \Illuminate\Database\MultipleRecordsFoundException */ public function sole($columns = ['*']) { - $result = $this->take(2)->get($columns); + $result = $this->limit(2)->get($columns); $count = $result->count(); @@ -196,7 +205,7 @@ public function sole($columns = ['*']) * Execute the query as a "select" statement. * * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection + * @return \Illuminate\Database\Eloquent\Collection */ public function get($columns = ['*']) { @@ -233,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) { @@ -249,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 = ['*']) { @@ -275,13 +284,13 @@ public function getRelationCountHash($incrementJoinCount = true) /** * Get all of the primary keys for an array of models. * - * @param array $models + * @param array $models * @param string|null $key - * @return array + * @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(); } @@ -289,7 +298,7 @@ protected function getKeys(array $models, $key = null) /** * Get the query builder that will contain the relationship constraints. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ protected function getRelationQuery() { @@ -299,7 +308,7 @@ protected function getRelationQuery() /** * Get the underlying query for the relation. * - * @return \Illuminate\Database\Eloquent\Builder + * @return \Illuminate\Database\Eloquent\Builder */ public function getQuery() { @@ -329,7 +338,7 @@ public function toBase() /** * Get the parent model of the relation. * - * @return \Illuminate\Database\Eloquent\Model + * @return TDeclaringModel */ public function getParent() { @@ -337,7 +346,7 @@ public function getParent() } /** - * Get the fully qualified parent key name. + * Get the fully-qualified parent key name. * * @return string */ @@ -349,7 +358,7 @@ public function getQualifiedParentKeyName() /** * Get the related model of the relation. * - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel */ public function getRelated() { @@ -392,10 +401,10 @@ public function relatedUpdatedAt() * @param string $whereIn * @param string $key * @param array $modelKeys - * @param \Illuminate\Database\Eloquent\Builder $query + * @param \Illuminate\Database\Eloquent\Builder|null $query * @return void */ - protected function whereInEager(string $whereIn, string $key, array $modelKeys, $query = null) + protected function whereInEager(string $whereIn, string $key, array $modelKeys, ?Builder $query = null) { ($query ?? $this->query)->{$whereIn}($key, $modelKeys); @@ -414,9 +423,9 @@ protected function whereInEager(string $whereIn, string $key, array $modelKeys, protected function whereInMethod(Model $model, $key) { return $model->getKeyName() === last(explode('.', $key)) - && in_array($model->getKeyType(), ['int', 'integer']) - ? 'whereIntegerInRaw' - : 'whereIn'; + && in_array($model->getKeyType(), ['int', 'integer']) + ? 'whereIntegerInRaw' + : 'whereIn'; } /** @@ -443,7 +452,7 @@ public static function requiresMorphMap() /** * Define the morph map for polymorphic relations and require all morphed models to be explicitly mapped. * - * @param array $map + * @param array> $map * @param bool $merge * @return array */ @@ -457,17 +466,18 @@ public static function enforceMorphMap(array $map, $merge = true) /** * Set or get the morph map for polymorphic relations. * - * @param array|null $map + * @param array>|null $map * @param bool $merge - * @return array + * @return array> */ - public static function morphMap(array $map = null, $merge = true) + 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; @@ -476,10 +486,10 @@ 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) || ! array_is_list($models)) { return $models; @@ -494,13 +504,24 @@ 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 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. * 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 ec66883419..bdb4972743 100644 --- a/Eloquent/SoftDeletes.php +++ b/Eloquent/SoftDeletes.php @@ -2,10 +2,15 @@ namespace Illuminate\Database\Eloquent; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Collection as BaseCollection; + /** - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withTrashed(bool $withTrashed = true) - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyTrashed() - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutTrashed() + * @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 { @@ -45,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) { @@ -56,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. * @@ -103,7 +160,7 @@ protected function runSoftDelete() /** * Restore a soft-deleted model instance. * - * @return bool|null + * @return bool */ public function restore() { @@ -131,7 +188,7 @@ public function restore() /** * Restore a soft-deleted model instance without raising any events. * - * @return bool|null + * @return bool */ public function restoreQuietly() { @@ -151,7 +208,7 @@ public function trashed() /** * 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) @@ -162,7 +219,7 @@ public static function softDeleted($callback) /** * Register a "restoring" model event callback with the dispatcher. * - * @param \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback * @return void */ public static function restoring($callback) @@ -173,7 +230,7 @@ public static function restoring($callback) /** * 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) @@ -181,10 +238,21 @@ 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 \Closure|string $callback + * @param \Illuminate\Events\QueuedClosure|callable|class-string $callback * @return void */ public static function forceDeleted($callback) @@ -213,7 +281,7 @@ public function getDeletedAtColumn() } /** - * 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 7528964c13..d1ef0d22b9 100644 --- a/Eloquent/SoftDeletingScope.php +++ b/Eloquent/SoftDeletingScope.php @@ -9,13 +9,15 @@ class SoftDeletingScope implements Scope * * @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 @@ +connectionName = $connectionName; - $this->connections = $connections; + public function __construct( + public $connectionName, + public $connections, + ) { } } diff --git a/Events/DatabaseRefreshed.php b/Events/DatabaseRefreshed.php index 5b1fb45856..66879b6aae 100644 --- a/Events/DatabaseRefreshed.php +++ b/Events/DatabaseRefreshed.php @@ -6,5 +6,15 @@ class DatabaseRefreshed implements MigrationEventContract { - // + /** + * Create a new event instance. + * + * @param string|null $database + * @param bool $seeding + */ + public function __construct( + public ?string $database = null, + public bool $seeding = false, + ) { + } } diff --git a/Events/MigrationEvent.php b/Events/MigrationEvent.php index 157303d2e2..83f10871a1 100644 --- a/Events/MigrationEvent.php +++ b/Events/MigrationEvent.php @@ -26,7 +26,6 @@ abstract class MigrationEvent implements MigrationEventContract * * @param \Illuminate\Database\Migrations\Migration $migration * @param string $method - * @return void */ public function __construct(Migration $migration, $method) { 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($method) - { - $this->method = $method; + 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/ModelPruningFinished.php b/Events/ModelPruningFinished.php new file mode 100644 index 0000000000..e27c687e84 --- /dev/null +++ b/Events/ModelPruningFinished.php @@ -0,0 +1,16 @@ + $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 index ca8bee9e0f..2d9605e5fe 100644 --- a/Events/ModelsPruned.php +++ b/Events/ModelsPruned.php @@ -4,30 +4,15 @@ class ModelsPruned { - /** - * The class name of the model that was pruned. - * - * @var string - */ - public $model; - - /** - * The number of pruned records. - * - * @var int - */ - public $count; - /** * Create a new event instance. * - * @param string $model - * @param int $count - * @return void + * @param string $model The class name of the model that was pruned. + * @param int $count The number of pruned records. */ - public function __construct($model, $count) - { - $this->model = $model; - $this->count = $count; + public function __construct( + public $model, + public $count, + ) { } } diff --git a/Events/NoPendingMigrations.php b/Events/NoPendingMigrations.php index 100f786670..ab9eb6b620 100644 --- a/Events/NoPendingMigrations.php +++ b/Events/NoPendingMigrations.php @@ -2,23 +2,17 @@ namespace Illuminate\Database\Events; -class NoPendingMigrations -{ - /** - * The migration method that was called. - * - * @var string - */ - public $method; +use Illuminate\Contracts\Database\Events\MigrationEvent; +class NoPendingMigrations implements MigrationEvent +{ /** * Create a new event instance. * - * @param string $method - * @return void + * @param string $method The migration method that was called. */ - public function __construct($method) - { - $this->method = $method; + public function __construct( + public $method, + ) { } } diff --git a/Events/QueryExecuted.php b/Events/QueryExecuted.php index 833a21e6f9..d9209dfd59 100644 --- a/Events/QueryExecuted.php +++ b/Events/QueryExecuted.php @@ -39,6 +39,13 @@ class QueryExecuted */ public $connectionName; + /** + * The PDO read / write type for the executed query. + * + * @var null|'read'|'write' + */ + public $readWriteType; + /** * Create a new event instance. * @@ -46,14 +53,28 @@ class QueryExecuted * @param array $bindings * @param float|null $time * @param \Illuminate\Database\Connection $connection - * @return void + * @param null|'read'|'write' $readWriteType */ - public function __construct($sql, $bindings, $time, $connection) + public function __construct($sql, $bindings, $time, $connection, $readWriteType = null) { $this->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 index 1cbbfff96e..416462027c 100644 --- a/Events/SchemaDumped.php +++ b/Events/SchemaDumped.php @@ -30,7 +30,6 @@ class SchemaDumped * * @param \Illuminate\Database\Connection $connection * @param string $path - * @return void */ public function __construct($connection, $path) { diff --git a/Events/SchemaLoaded.php b/Events/SchemaLoaded.php index 061a079a96..d86ae53074 100644 --- a/Events/SchemaLoaded.php +++ b/Events/SchemaLoaded.php @@ -30,7 +30,6 @@ class SchemaLoaded * * @param \Illuminate\Database\Connection $connection * @param string $path - * @return void */ public function __construct($connection, $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/Grammar.php b/Grammar.php index 697f3253cc..1d437f0566 100755 --- a/Grammar.php +++ b/Grammar.php @@ -2,7 +2,8 @@ namespace Illuminate\Database; -use Illuminate\Database\Query\Expression; +use Illuminate\Contracts\Database\Query\Expression; +use Illuminate\Support\Collection; use Illuminate\Support\Traits\Macroable; use RuntimeException; @@ -11,46 +12,76 @@ abstract class Grammar use Macroable; /** - * The grammar table prefix. + * The connection used for escaping values. * - * @var string + * @var \Illuminate\Database\Connection */ - protected $tablePrefix = ''; + protected $connection; + + /** + * Create a new grammar instance. + * + * @param \Illuminate\Database\Connection $connection + */ + public function __construct(Connection $connection) + { + $this->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); @@ -60,7 +91,7 @@ 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 @@ -77,35 +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->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('.'); } @@ -151,23 +190,23 @@ protected function isJsonSelector($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)); } /** @@ -184,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) @@ -196,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. * @@ -208,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 mixed + * @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; } /** @@ -231,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 index 1bcd40c95a..f0a90f6c95 100644 --- a/LazyLoadingViolationException.php +++ b/LazyLoadingViolationException.php @@ -25,7 +25,6 @@ class LazyLoadingViolationException extends RuntimeException * * @param object $model * @param string $relation - * @return static */ public function __construct($model, $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/MariaDbConnection.php b/MariaDbConnection.php new file mode 100755 index 0000000000..c4040b6c34 --- /dev/null +++ b/MariaDbConnection.php @@ -0,0 +1,98 @@ +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 d61f442324..037106c735 100755 --- a/MigrationServiceProvider.php +++ b/MigrationServiceProvider.php @@ -59,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); }); @@ -80,6 +82,8 @@ protected function registerMigrator() return new Migrator($repository, $app['db'], $app['files'], $app['events']); }); + + $this->app->bind(Migrator::class, fn ($app) => $app['migrator']); } /** @@ -128,7 +132,9 @@ protected function registerMigrateCommand() */ protected function registerMigrateFreshCommand() { - $this->app->singleton(FreshCommand::class); + $this->app->singleton(FreshCommand::class, function ($app) { + return new FreshCommand($app['migrator']); + }); } /** @@ -216,7 +222,7 @@ protected function registerMigrateStatusCommand() public function provides() { return array_merge([ - 'migrator', 'migration.repository', 'migration.creator', + 'migrator', 'migration.repository', 'migration.creator', Migrator::class, ], array_values($this->commands)); } } diff --git a/Migrations/DatabaseMigrationRepository.php b/Migrations/DatabaseMigrationRepository.php index bd8ee49631..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,36 +42,38 @@ 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 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 $batchNumber - * @return array + * @param int $batch + * @return array{id: int, migration: string, batch: int}[] */ public function getMigrationsByBatch($batch) { @@ -86,7 +87,7 @@ public function getMigrationsByBatch($batch) /** * Get the last migration batch. * - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getLast() { @@ -98,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(); } /** @@ -125,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) diff --git a/Migrations/Migration.php b/Migrations/Migration.php index a58f7848a7..35c8d43be3 100755 --- a/Migrations/Migration.php +++ b/Migrations/Migration.php @@ -27,4 +27,14 @@ 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 d8f1ce9d0b..7b9da51390 100755 --- a/Migrations/MigrationCreator.php +++ b/Migrations/MigrationCreator.php @@ -26,7 +26,7 @@ class MigrationCreator /** * The registered post create hooks. * - * @var array + * @var (\Closure(string, string): void)[] */ protected $postCreate = []; @@ -35,7 +35,6 @@ class MigrationCreator * * @param \Illuminate\Filesystem\Filesystem $files * @param string $customStubPath - * @return void */ public function __construct(Filesystem $files, $customStubPath) { @@ -83,7 +82,7 @@ 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 $migrationPath + * @param string|null $migrationPath * @return void * * @throws \InvalidArgumentException @@ -114,16 +113,16 @@ protected function getStub($table, $create) { if (is_null($table)) { $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.stub') - ? $customPath - : $this->stubPath().'/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'; + ? $customPath + : $this->stubPath().'/migration.create.stub'; } else { $stub = $this->files->exists($customPath = $this->customStubPath.'/migration.update.stub') - ? $customPath - : $this->stubPath().'/migration.update.stub'; + ? $customPath + : $this->stubPath().'/migration.update.stub'; } return $this->files->get($stub); @@ -155,7 +154,7 @@ protected function populateStub($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) { @@ -191,7 +190,7 @@ protected function firePostCreateHooks($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 a3ba965717..49cc08e21a 100755 --- a/Migrations/MigrationRepositoryInterface.php +++ b/Migrations/MigrationRepositoryInterface.php @@ -7,7 +7,7 @@ interface MigrationRepositoryInterface /** * Get the completed migrations. * - * @return array + * @return string[] */ public function getRan(); @@ -15,7 +15,7 @@ public function getRan(); * Get the list of migrations. * * @param int $steps - * @return array + * @return array{id: int, migration: string, batch: int}[] */ public function getMigrations($steps); @@ -23,21 +23,21 @@ public function getMigrations($steps); * Get the list of the migrations by batch. * * @param int $batch - * @return array + * @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(); @@ -53,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); 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. * @@ -73,6 +87,13 @@ class Migrator */ protected $output; + /** + * The pending migrations to skip. + * + * @var list + */ + protected static $withoutMigrations = []; + /** * Create a new migrator instance. * @@ -80,13 +101,13 @@ class Migrator * @param \Illuminate\Database\ConnectionResolverInterface $resolver * @param \Illuminate\Filesystem\Filesystem $files * @param \Illuminate\Contracts\Events\Dispatcher|null $dispatcher - * @return void */ - public function __construct(MigrationRepositoryInterface $repository, - Resolver $resolver, - Filesystem $files, - Dispatcher $dispatcher = null) - { + public function __construct( + MigrationRepositoryInterface $repository, + Resolver $resolver, + Filesystem $files, + ?Dispatcher $dispatcher = null, + ) { $this->files = $files; $this->events = $dispatcher; $this->resolver = $resolver; @@ -96,9 +117,9 @@ 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 = []) { @@ -122,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 = []) @@ -163,7 +200,7 @@ public function runPending(array $migrations, array $options = []) $step = $options['step'] ?? false; - $this->fireMigrationEvent(new MigrationsStarted('up')); + $this->fireMigrationEvent(new MigrationsStarted('up', $options)); $this->write(Info::class, 'Running migrations.'); @@ -178,11 +215,9 @@ public function runPending(array $migrations, array $options = []) } } - $this->fireMigrationEvent(new MigrationsEnded('up')); + $this->fireMigrationEvent(new MigrationsEnded('up', $options)); - if ($this->output) { - $this->output->writeln(''); - } + $this->output?->writeln(''); } /** @@ -206,20 +241,30 @@ protected function runUp($file, $batch, $pretend) return $this->pretendToRun($migration, 'up'); } - $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); + $shouldRunMigration = $migration instanceof Migration + ? $migration->shouldRun() + : true; - // 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); + if (! $shouldRunMigration) { + $this->fireMigrationEvent(new MigrationSkipped($name)); + + $this->write(Task::class, $name, fn () => MigrationResult::Skipped->value); + } else { + $this->write(Task::class, $name, fn () => $this->runMigration($migration, 'up')); + + // 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 = []) { @@ -237,17 +282,15 @@ public function rollback($paths = [], array $options = []) } return tap($this->rollbackMigrations($migrations, $paths, $options), function () { - if ($this->output) { - $this->output->writeln(''); - } + $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) { @@ -266,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) { @@ -276,7 +319,7 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) $this->requireFiles($files = $this->getMigrationFiles($paths)); - $this->fireMigrationEvent(new MigrationsStarted('down')); + $this->fireMigrationEvent(new MigrationsStarted('down', $options)); $this->write(Info::class, 'Rolling back migrations.'); @@ -300,7 +343,7 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) ); } - $this->fireMigrationEvent(new MigrationsEnded('down')); + $this->fireMigrationEvent(new MigrationsEnded('down', $options)); return $rolledBack; } @@ -308,7 +351,7 @@ protected function rollbackMigrations(array $migrations, $paths, array $options) /** * Rolls all of the currently applied migrations back. * - * @param array|string $paths + * @param string[]|string $paths * @param bool $pretend * @return array */ @@ -325,18 +368,16 @@ public function reset($paths = [], $pretend = false) return []; } - return tap($this->resetMigrations($migrations, $paths, $pretend), function () { - if ($this->output) { - $this->output->writeln(''); - } + 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 */ @@ -345,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') @@ -408,8 +447,8 @@ protected function runMigration($migration, $method) $this->getSchemaGrammar($connection)->supportsSchemaTransactions() && $migration->withinTransaction - ? $connection->transaction($callback) - : $callback(); + ? $connection->transaction($callback) + : $callback(); } /** @@ -421,27 +460,20 @@ protected function runMigration($migration, $method) */ protected function pretendToRun($migration, $method) { - try { - $name = get_class($migration); - - $reflectionClass = new ReflectionClass($migration); + $name = get_class($migration); - if ($reflectionClass->isAnonymous()) { - $name = $this->getMigrationName($reflectionClass->getFileName()); - } + $reflectionClass = new ReflectionClass($migration); - $this->write(TwoColumnDetail::class, $name); - $this->write(BulletList::class, collect($this->getQueries($migration, $method))->map(function ($query) { - return $query['query']; - })); - } catch (SchemaException $e) { - $name = get_class($migration); - - $this->write(Error::class, sprintf( - '[%s] failed to dump queries. This may be due to changing database columns using Doctrine, which is not supported while pretending to run migrations.', - $name, - )); + 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']) + ); } /** @@ -515,9 +547,15 @@ protected function resolvePath(string $path) return new $class; } - $migration = $this->files->getRequire($path); + $migration = static::$requiredPathCache[$path] ??= $this->files->getRequire($path); + + if (is_object($migration)) { + return method_exists($migration, '__construct') + ? $this->files->getRequire($path) + : clone $migration; + } - return is_object($migration) ? $migration : new $class; + return new $class; } /** @@ -535,23 +573,23 @@ protected function getMigrationClass(string $migrationName): string * 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_ends_with($path, '.php') ? [$path] : $this->files->glob($path.'/*_*.php'); - })->filter()->values()->keyBy(function ($file) { - return $this->getMigrationName($file); - })->sortBy(function ($file, $key) { - return $key; - })->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) @@ -586,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. * @@ -606,8 +655,10 @@ public function getConnection() /** * Execute the given callback using the given connection as the default connection. * + * @template TReturn + * * @param string $name - * @param callable $callback + * @param (callable(): TReturn) $callback * @return mixed */ public function usingConnection($name, callable $callback) @@ -616,9 +667,11 @@ public function usingConnection($name, callable $callback) $this->setConnection($name); - return tap($callback(), function () use ($previousConnection) { + try { + return $callback(); + } finally { $this->setConnection($previousConnection); - }); + } } /** @@ -646,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; } /** @@ -703,7 +775,7 @@ public function hasRunAnyMigrations() */ public function deleteRepository() { - return $this->repository->deleteRepository(); + $this->repository->deleteRepository(); } /** @@ -733,7 +805,7 @@ public function setOutput(OutputInterface $output) * Write to the console's output. * * @param string $component - * @param array|string $arguments + * @param array|string ...$arguments * @return void */ protected function write($component, ...$arguments) @@ -757,8 +829,6 @@ protected function write($component, ...$arguments) */ public function fireMigrationEvent($event) { - if ($this->events) { - $this->events->dispatch($event); - } + $this->events?->dispatch($event); } } diff --git a/Migrations/stubs/migration.create.stub b/Migrations/stubs/migration.create.stub index 0e0ec22b85..168c622236 100755 --- a/Migrations/stubs/migration.create.stub +++ b/Migrations/stubs/migration.create.stub @@ -8,10 +8,8 @@ return new class extends Migration { /** * Run the migrations. - * - * @return void */ - public function up() + public function up(): void { Schema::create('{{ table }}', function (Blueprint $table) { $table->id(); @@ -21,10 +19,8 @@ return new class extends Migration /** * Reverse the migrations. - * - * @return void */ - public function down() + public function down(): void { Schema::dropIfExists('{{ table }}'); } diff --git a/Migrations/stubs/migration.stub b/Migrations/stubs/migration.stub index 41dd1c8e6a..88fa2f36b0 100755 --- a/Migrations/stubs/migration.stub +++ b/Migrations/stubs/migration.stub @@ -8,20 +8,16 @@ return new class extends Migration { /** * Run the migrations. - * - * @return void */ - public function up() + public function up(): void { // } /** * Reverse the migrations. - * - * @return void */ - public function down() + public function down(): void { // } diff --git a/Migrations/stubs/migration.update.stub b/Migrations/stubs/migration.update.stub index d31a2c022b..c9ff2c4b73 100755 --- a/Migrations/stubs/migration.update.stub +++ b/Migrations/stubs/migration.update.stub @@ -8,10 +8,8 @@ return new class extends Migration { /** * Run the migrations. - * - * @return void */ - public function up() + public function up(): void { Schema::table('{{ table }}', function (Blueprint $table) { // @@ -20,10 +18,8 @@ return new class extends Migration /** * Reverse the migrations. - * - * @return void */ - public function down() + public function down(): void { Schema::table('{{ table }}', function (Blueprint $table) { // diff --git a/MultipleRecordsFoundException.php b/MultipleRecordsFoundException.php index b14a8598fb..baeee22119 100755 --- a/MultipleRecordsFoundException.php +++ b/MultipleRecordsFoundException.php @@ -19,7 +19,6 @@ class MultipleRecordsFoundException extends RuntimeException * @param int $count * @param int $code * @param \Throwable|null $previous - * @return void */ public function __construct($count, $code = 0, $previous = null) { diff --git a/MySqlConnection.php b/MySqlConnection.php index 54e3d473d5..ab541c9aa5 100755 --- a/MySqlConnection.php +++ b/MySqlConnection.php @@ -2,17 +2,96 @@ namespace Illuminate\Database; -use Illuminate\Database\PDO\MySqlDriver; +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. * @@ -23,6 +102,18 @@ 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. * @@ -30,7 +121,7 @@ public function isMaria() */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -54,7 +145,7 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** @@ -64,7 +155,7 @@ protected function getDefaultSchemaGrammar() * @param callable|null $processFactory * @return \Illuminate\Database\Schema\MySqlSchemaState */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new MySqlSchemaState($this, $files, $processFactory); } @@ -78,14 +169,4 @@ protected function getDefaultPostProcessor() { return new MySqlProcessor; } - - /** - * Get the Doctrine DBAL driver. - * - * @return \Illuminate\Database\PDO\MySqlDriver - */ - protected function getDoctrineDriver() - { - return new MySqlDriver; - } } diff --git a/PDO/Concerns/ConnectsToDatabase.php b/PDO/Concerns/ConnectsToDatabase.php deleted file mode 100644 index d2a8d6006d..0000000000 --- a/PDO/Concerns/ConnectsToDatabase.php +++ /dev/null @@ -1,30 +0,0 @@ -connection = $connection; - } - - /** - * Execute an SQL statement. - * - * @param string $statement - * @return int - */ - public function exec(string $statement): int - { - try { - $result = $this->connection->exec($statement); - - \assert($result !== false); - - return $result; - } catch (PDOException $exception) { - throw Exception::new($exception); - } - } - - /** - * Prepare a new SQL statement. - * - * @param string $sql - * @return \Doctrine\DBAL\Driver\Statement - */ - public function prepare(string $sql): StatementInterface - { - try { - return $this->createStatement( - $this->connection->prepare($sql) - ); - } catch (PDOException $exception) { - throw Exception::new($exception); - } - } - - /** - * Execute a new query against the connection. - * - * @param string $sql - * @return \Doctrine\DBAL\Driver\Result - */ - public function query(string $sql): ResultInterface - { - try { - $stmt = $this->connection->query($sql); - - \assert($stmt instanceof PDOStatement); - - return new Result($stmt); - } catch (PDOException $exception) { - throw Exception::new($exception); - } - } - - /** - * Get the last insert ID. - * - * @param string|null $name - * @return mixed - */ - public function lastInsertId($name = null) - { - try { - if ($name === null) { - return $this->connection->lastInsertId(); - } - - return $this->connection->lastInsertId($name); - } catch (PDOException $exception) { - throw Exception::new($exception); - } - } - - /** - * Create a new statement instance. - * - * @param \PDOStatement $stmt - * @return \Doctrine\DBAL\Driver\PDO\Statement - */ - protected function createStatement(PDOStatement $stmt): Statement - { - return new Statement($stmt); - } - - /** - * Begin a new database transaction. - * - * @return void - */ - public function beginTransaction() - { - return $this->connection->beginTransaction(); - } - - /** - * Commit a database transaction. - * - * @return void - */ - public function commit() - { - return $this->connection->commit(); - } - - /** - * Rollback a database transaction. - * - * @return void - */ - public function rollBack() - { - return $this->connection->rollBack(); - } - - /** - * Wrap quotes around the given input. - * - * @param string $input - * @param string $type - * @return string - */ - public function quote($input, $type = ParameterType::STRING) - { - return $this->connection->quote($input, $type); - } - - /** - * Get the server version for the connection. - * - * @return string - */ - public function getServerVersion() - { - return $this->connection->getAttribute(PDO::ATTR_SERVER_VERSION); - } - - /** - * Get the wrapped PDO connection. - * - * @return \PDO - */ - public function getWrappedConnection(): PDO - { - return $this->connection; - } -} diff --git a/PDO/MySqlDriver.php b/PDO/MySqlDriver.php deleted file mode 100644 index 54ac375361..0000000000 --- a/PDO/MySqlDriver.php +++ /dev/null @@ -1,19 +0,0 @@ -connection = $connection; - } - - /** - * Prepare a new SQL statement. - * - * @param string $sql - * @return \Doctrine\DBAL\Driver\Statement - */ - public function prepare(string $sql): StatementInterface - { - return new Statement( - $this->connection->prepare($sql) - ); - } - - /** - * Execute a new query against the connection. - * - * @param string $sql - * @return \Doctrine\DBAL\Driver\Result - */ - public function query(string $sql): Result - { - return $this->connection->query($sql); - } - - /** - * Execute an SQL statement. - * - * @param string $statement - * @return int - */ - public function exec(string $statement): int - { - return $this->connection->exec($statement); - } - - /** - * Get the last insert ID. - * - * @param string|null $name - * @return mixed - */ - public function lastInsertId($name = null) - { - if ($name === null) { - return $this->connection->lastInsertId($name); - } - - return $this->prepare('SELECT CONVERT(VARCHAR(MAX), current_value) FROM sys.sequences WHERE name = ?') - ->execute([$name]) - ->fetchOne(); - } - - /** - * Begin a new database transaction. - * - * @return void - */ - public function beginTransaction() - { - return $this->connection->beginTransaction(); - } - - /** - * Commit a database transaction. - * - * @return void - */ - public function commit() - { - return $this->connection->commit(); - } - - /** - * Rollback a database transaction. - * - * @return void - */ - public function rollBack() - { - return $this->connection->rollBack(); - } - - /** - * Wrap quotes around the given input. - * - * @param string $value - * @param int $type - * @return string - */ - public function quote($value, $type = ParameterType::STRING) - { - $val = $this->connection->quote($value, $type); - - // Fix for a driver version terminating all values with null byte... - if (\is_string($val) && str_contains($val, "\0")) { - $val = \substr($val, 0, -1); - } - - return $val; - } - - /** - * Get the server version for the connection. - * - * @return string - */ - public function getServerVersion() - { - return $this->connection->getServerVersion(); - } - - /** - * Get the wrapped PDO connection. - * - * @return \PDO - */ - public function getWrappedConnection(): PDO - { - return $this->connection->getWrappedConnection(); - } -} diff --git a/PDO/SqlServerDriver.php b/PDO/SqlServerDriver.php deleted file mode 100644 index 1b0d9574e7..0000000000 --- a/PDO/SqlServerDriver.php +++ /dev/null @@ -1,32 +0,0 @@ -getCode(); + } + /** * Get the default query grammar instance. * @@ -19,7 +62,7 @@ class PostgresConnection extends Connection */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -43,7 +86,7 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** @@ -53,7 +96,7 @@ protected function getDefaultSchemaGrammar() * @param callable|null $processFactory * @return \Illuminate\Database\Schema\PostgresSchemaState */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new PostgresSchemaState($this, $files, $processFactory); } @@ -67,14 +110,4 @@ protected function getDefaultPostProcessor() { return new PostgresProcessor; } - - /** - * Get the Doctrine DBAL driver. - * - * @return \Illuminate\Database\PDO\PostgresDriver - */ - protected function getDoctrineDriver() - { - return new PostgresDriver; - } } diff --git a/Query/Builder.php b/Query/Builder.php index 4d9a607fde..442affeddf 100755 --- a/Query/Builder.php +++ b/Query/Builder.php @@ -3,16 +3,20 @@ namespace Illuminate\Database\Query; use BackedEnum; -use Carbon\CarbonPeriod; use Closure; +use DatePeriod; use DateTimeInterface; 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\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\Pagination\Paginator; @@ -25,10 +29,14 @@ use InvalidArgumentException; use LogicException; use RuntimeException; +use UnitEnum; + +use function Illuminate\Support\enum_value; class Builder implements BuilderContract { - use BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { + /** @use \Illuminate\Database\Concerns\BuildsQueries<\stdClass> */ + use BuildsWhereDateClauses, BuildsQueries, ExplainsQueries, ForwardsCalls, Macroable { __call as macroCall; } @@ -56,7 +64,17 @@ class Builder implements BuilderContract /** * 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' => [], @@ -73,14 +91,17 @@ class Builder implements BuilderContract /** * 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; @@ -96,14 +117,21 @@ class Builder implements BuilderContract /** * 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; @@ -117,73 +145,87 @@ class Builder implements BuilderContract /** * 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; + /** + * The query execution timeout in seconds. + * + * @var int|null + */ + public $timeout; + /** * The callbacks that should be invoked before the query is executed. * @@ -191,6 +233,13 @@ class Builder implements BuilderContract */ public $beforeQueryCallbacks = []; + /** + * The callbacks that should be invoked after retrieving data from the database. + * + * @var array + */ + protected $afterQueryCallbacks = []; + /** * All of the available clause operators. * @@ -222,17 +271,20 @@ class Builder implements BuilderContract 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|null $grammar - * @param \Illuminate\Database\Query\Processors\Processor|null $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(); @@ -241,7 +293,7 @@ public function __construct(ConnectionInterface $connection, /** * Set the columns to be selected. * - * @param array|mixed $columns + * @param mixed $columns * @return $this */ public function select($columns = ['*']) @@ -265,7 +317,7 @@ public function select($columns = ['*']) /** * Add a subselect expression to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as * @return $this * @@ -280,11 +332,24 @@ 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 $this */ public function selectRaw($expression, array $bindings = []) @@ -301,7 +366,7 @@ public function selectRaw($expression, array $bindings = []) /** * Makes "from" fetch from a subquery. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as * @return $this * @@ -315,7 +380,7 @@ public function fromSub($query, $as) } /** - * Add a raw from clause to the query. + * Add a raw "from" clause to the query. * * @param string $expression * @param mixed $bindings @@ -333,7 +398,7 @@ public function fromRaw($expression, $bindings = []) /** * Creates a subquery and parse it. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @return array */ protected function createSub($query) @@ -396,7 +461,7 @@ protected function prependDatabaseNameIfCrossDatabaseQuery($query) /** * Add a new select column to the query. * - * @param array|mixed $column + * @param mixed $column * @return $this */ public function addSelect($column) @@ -411,6 +476,10 @@ public function addSelect($column) $this->selectSub($column, $as); } else { + if (is_array($this->columns) && in_array($column, $this->columns, true)) { + continue; + } + $this->columns[] = $column; } } @@ -418,6 +487,39 @@ public function addSelect($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. * @@ -439,7 +541,7 @@ public function distinct() /** * Set the table which the query is targeting. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|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 */ @@ -455,12 +557,51 @@ public function from($table, $as = null) } /** - * 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 * @return $this @@ -497,10 +638,10 @@ 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 $this */ @@ -510,13 +651,13 @@ 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|\Illuminate\Database\Eloquent\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 $this @@ -534,13 +675,43 @@ public function joinSub($query, $as, $first, $operator = null, $second = null, $ 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 + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this */ public function leftJoin($table, $first, $operator = null, $second = null) @@ -551,10 +722,10 @@ 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 + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this */ public function leftJoinWhere($table, $first, $operator, $second) @@ -565,11 +736,11 @@ public function leftJoinWhere($table, $first, $operator, $second) /** * Add a subquery left join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\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 * @return $this */ public function leftJoinSub($query, $as, $first, $operator = null, $second = null) @@ -580,10 +751,10 @@ 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 + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this */ public function rightJoin($table, $first, $operator = null, $second = null) @@ -594,10 +765,10 @@ 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 + * @param \Illuminate\Contracts\Database\Query\Expression|string $second * @return $this */ public function rightJoinWhere($table, $first, $operator, $second) @@ -608,11 +779,11 @@ public function rightJoinWhere($table, $first, $operator, $second) /** * Add a subquery right join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\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 * @return $this */ public function rightJoinSub($query, $as, $first, $operator = null, $second = null) @@ -623,10 +794,10 @@ 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 + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @return $this */ public function crossJoin($table, $first = null, $operator = null, $second = null) @@ -643,7 +814,7 @@ public function crossJoin($table, $first = null, $operator = null, $second = nul /** * Add a subquery cross join to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @param string $as * @return $this */ @@ -661,11 +832,53 @@ public function crossJoinSub($query, $as) } /** - * Get a new join clause. + * 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) @@ -674,7 +887,19 @@ 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 @@ -692,9 +917,9 @@ public function mergeWheres($wheres, $bindings) } /** - * Add a basic where clause to the query. + * Add a basic "where" clause to the query. * - * @param \Closure|string|array $column + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean @@ -702,6 +927,14 @@ public function mergeWheres($wheres, $bindings) */ public function where($column, $operator = null, $value = null, $boolean = 'and') { + 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. @@ -743,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); } @@ -751,15 +984,19 @@ 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)) { @@ -778,7 +1015,7 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' 'type', 'column', 'operator', 'value', 'boolean' ); - if (! $value instanceof Expression) { + if (! $value instanceof ExpressionContract) { $this->addBinding($this->flattenValue($value), 'where'); } @@ -786,7 +1023,7 @@ public function where($column, $operator = null, $value = null, $boolean = 'and' } /** - * Add an array of where clauses to the query. + * Add an array of "where" clauses to the query. * * @param array $column * @param string $boolean @@ -798,7 +1035,7 @@ 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); } @@ -839,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, ['=', '<=>', '<>', '!=']); } /** @@ -869,7 +1106,7 @@ protected function isBitwiseOperator($operator) /** * Add an "or where" clause to the query. * - * @param \Closure|string|array $column + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @return $this @@ -886,7 +1123,7 @@ public function orWhere($column, $operator = null, $value = null) /** * Add a basic "where not" clause to the query. * - * @param \Closure|string|array $column + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @param string $boolean @@ -906,7 +1143,7 @@ public function whereNot($column, $operator = null, $value = null, $boolean = 'a /** * Add an "or where not" clause to the query. * - * @param \Closure|string|array $column + * @param \Closure|string|array|\Illuminate\Contracts\Database\Query\Expression $column * @param mixed $operator * @param mixed $value * @return $this @@ -919,7 +1156,7 @@ public function orWhereNot($column, $operator = null, $value = null) /** * 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 @@ -956,7 +1193,7 @@ 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 $this @@ -967,9 +1204,78 @@ 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 \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 @@ -984,7 +1290,7 @@ 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 @@ -995,14 +1301,81 @@ 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 \Illuminate\Contracts\Database\Query\Expression|string $column * @param mixed $values * @param string $boolean * @param bool $not * @return $this + * + * @throws \InvalidArgumentException */ public function whereIn($column, $values, $boolean = 'and', $not = false) { @@ -1028,6 +1401,10 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) $this->wheres[] = compact('type', 'column', 'values', 'boolean'); + 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. @@ -1039,7 +1416,7 @@ public function whereIn($column, $values, $boolean = 'and', $not = false) /** * Add an "or where in" clause to the query. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param mixed $values * @return $this */ @@ -1051,7 +1428,7 @@ public function orWhereIn($column, $values) /** * Add a "where not in" clause to the query. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param mixed $values * @param string $boolean * @return $this @@ -1064,7 +1441,7 @@ public function whereNotIn($column, $values, $boolean = 'and') /** * Add an "or where not in" clause to the query. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param mixed $values * @return $this */ @@ -1090,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'); @@ -1139,7 +1518,7 @@ public function orWhereIntegerNotInRaw($column, $values) /** * Add a "where null" clause to the query. * - * @param string|array $columns + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $columns * @param string $boolean * @param bool $not * @return $this @@ -1158,7 +1537,7 @@ public function whereNull($columns, $boolean = 'and', $not = false) /** * Add an "or where null" clause to the query. * - * @param string|array $column + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $column * @return $this */ public function orWhereNull($column) @@ -1169,7 +1548,7 @@ public function orWhereNull($column) /** * Add a "where not null" clause to the query. * - * @param string|array $columns + * @param string|array|\Illuminate\Contracts\Database\Query\Expression $columns * @param string $boolean * @return $this */ @@ -1179,10 +1558,9 @@ public function whereNotNull($columns, $boolean = 'and') } /** - * Add a where between statement to the query. + * Add a "where between" statement to the query. * - * @param string|\Illuminate\Database\Query\Expression $column - * @param iterable $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 @@ -1191,8 +1569,15 @@ public function whereBetween($column, iterable $values, $boolean = 'and', $not = { $type = 'between'; - if ($values instanceof CarbonPeriod) { - $values = $values->toArray(); + 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'); @@ -1203,10 +1588,9 @@ public function whereBetween($column, iterable $values, $boolean = 'and', $not = } /** - * Add a where between statement using columns to the query. + * Add a "where between" statement using columns to the query. * - * @param string $column - * @param array $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string $boolean * @param bool $not * @return $this @@ -1215,16 +1599,22 @@ public function whereBetweenColumns($column, array $values, $boolean = 'and', $n { $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 an or where between statement to the query. + * Add an "or where between" statement to the query. * - * @param string $column - * @param iterable $values + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orWhereBetween($column, iterable $values) @@ -1233,10 +1623,9 @@ public function orWhereBetween($column, iterable $values) } /** - * Add an or where between statement using columns to the query. + * Add an "or where between" statement using columns to the query. * - * @param string $column - * @param array $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orWhereBetweenColumns($column, array $values) @@ -1245,10 +1634,9 @@ public function orWhereBetweenColumns($column, array $values) } /** - * Add a where not between statement to the query. + * Add a "where not between" statement to the query. * - * @param string $column - * @param iterable $values + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @param string $boolean * @return $this */ @@ -1258,10 +1646,9 @@ public function whereNotBetween($column, iterable $values, $boolean = 'and') } /** - * Add a where not between statement using columns to the query. + * Add a "where not between" statement using columns to the query. * - * @param string $column - * @param array $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string $boolean * @return $this */ @@ -1271,10 +1658,9 @@ public function whereNotBetweenColumns($column, array $values, $boolean = 'and') } /** - * Add an or where not between statement to the query. + * Add an "or where not between" statement to the query. * - * @param string $column - * @param iterable $values + * @param \Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orWhereNotBetween($column, iterable $values) @@ -1283,10 +1669,9 @@ public function orWhereNotBetween($column, iterable $values) } /** - * Add an or where not between statement using columns to the query. + * Add an "or where not between" statement using columns to the query. * - * @param string $column - * @param array $values + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orWhereNotBetweenColumns($column, array $values) @@ -1294,10 +1679,67 @@ public function orWhereNotBetweenColumns($column, array $values) 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 string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function orWhereNotNull($column) @@ -1308,8 +1750,8 @@ public function orWhereNotNull($column) /** * Add a "where date" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator * @param \DateTimeInterface|string|null $value * @param string $boolean * @return $this @@ -1320,6 +1762,13 @@ public function whereDate($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) { @@ -1332,8 +1781,8 @@ public function whereDate($column, $operator, $value = null, $boolean = 'and') /** * Add an "or where date" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator * @param \DateTimeInterface|string|null $value * @return $this */ @@ -1349,8 +1798,8 @@ public function orWhereDate($column, $operator, $value = null) /** * Add a "where time" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator * @param \DateTimeInterface|string|null $value * @param string $boolean * @return $this @@ -1361,6 +1810,13 @@ public function whereTime($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) { @@ -1373,8 +1829,8 @@ 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 \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|null $operator * @param \DateTimeInterface|string|null $value * @return $this */ @@ -1390,8 +1846,8 @@ public function orWhereTime($column, $operator, $value = null) /** * Add a "where day" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @param string $boolean * @return $this @@ -1402,13 +1858,20 @@ 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 Expression) { + if (! $value instanceof ExpressionContract) { $value = sprintf('%02d', $value); } @@ -1418,8 +1881,8 @@ public function whereDay($column, $operator, $value = null, $boolean = 'and') /** * Add an "or where day" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @return $this */ @@ -1435,8 +1898,8 @@ public function orWhereDay($column, $operator, $value = null) /** * Add a "where month" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @param string $boolean * @return $this @@ -1447,13 +1910,20 @@ 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 Expression) { + if (! $value instanceof ExpressionContract) { $value = sprintf('%02d', $value); } @@ -1463,8 +1933,8 @@ public function whereMonth($column, $operator, $value = null, $boolean = 'and') /** * Add an "or where month" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @return $this */ @@ -1480,8 +1950,8 @@ public function orWhereMonth($column, $operator, $value = null) /** * Add a "where year" statement to the query. * - * @param string $column - * @param string $operator + * @param \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @param string $boolean * @return $this @@ -1492,6 +1962,13 @@ 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) { @@ -1504,8 +1981,8 @@ 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 \Illuminate\Contracts\Database\Query\Expression|string $column + * @param \DateTimeInterface|string|int|null $operator * @param \DateTimeInterface|string|int|null $value * @return $this */ @@ -1522,7 +1999,7 @@ public function orWhereYear($column, $operator, $value = null) * 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 @@ -1532,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'); } @@ -1540,9 +2017,8 @@ 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 $this */ @@ -1586,20 +2062,24 @@ public function addNestedWhereQuery($query, $boolean = 'and') /** * Add a full sub-select to the query. * - * @param string $column + * @param \Illuminate\Contracts\Database\Query\Expression|string $column * @param string $operator - * @param \Closure $callback + * @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. - $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' @@ -1611,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 \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. - $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 \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 \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 + * @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 @@ -1738,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)); } @@ -1782,6 +2265,65 @@ public function orWhereJsonDoesntContain($column, $value) return $this->whereJsonDoesntContain($column, $value, 'or'); } + /** + * Add a "where JSON overlaps" clause to the query. + * + * @param string $column + * @param mixed $value + * @param string $boolean + * @param bool $not + * @return $this + */ + public function whereJsonOverlaps($column, $value, $boolean = 'and', $not = false) + { + $type = 'JsonOverlaps'; + + $this->wheres[] = compact('type', 'column', 'value', 'boolean', 'not'); + + if (! $value instanceof ExpressionContract) { + $this->addBinding($this->grammar->prepareBindingForJsonContains($value)); + } + + return $this; + } + + /** + * Add an "or where JSON overlaps" clause to the query. + * + * @param string $column + * @param mixed $value + * @return $this + */ + public function orWhereJsonOverlaps($column, $value) + { + return $this->whereJsonOverlaps($column, $value, 'or'); + } + + /** + * Add a "where JSON not overlap" clause to the query. + * + * @param string $column + * @param mixed $value + * @param string $boolean + * @return $this + */ + public function whereJsonDoesntOverlap($column, $value, $boolean = 'and') + { + return $this->whereJsonOverlaps($column, $value, $boolean, true); + } + + /** + * Add an "or where JSON not overlap" clause to the query. + * + * @param string $column + * @param mixed $value + * @return $this + */ + public function orWhereJsonDoesntOverlap($column, $value) + { + return $this->whereJsonDoesntOverlap($column, $value, 'or'); + } + /** * Add a clause that determines if a JSON path exists to the query. * @@ -1850,9 +2392,16 @@ public function whereJsonLength($column, $operator, $value = null, $boolean = 'a $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 Expression) { + if (! $value instanceof ExpressionContract) { $this->addBinding((int) $this->flattenValue($value)); } @@ -1920,7 +2469,7 @@ public function dynamicWhere($method, $parameters) } /** - * Add a single dynamic where clause statement to the query. + * Add a single dynamic "where" clause statement to the query. * * @param string $segment * @param string $connector @@ -1960,7 +2509,7 @@ public function whereFullText($columns, $value, array $options = [], $boolean = } /** - * Add a "or where fulltext" clause to the query. + * Add an "or where fulltext" clause to the query. * * @param string|string[] $columns * @param string $value @@ -1968,13 +2517,114 @@ public function whereFullText($columns, $value, array $options = [], $boolean = */ public function orWhereFullText($columns, $value, array $options = []) { - return $this->whereFulltext($columns, $value, $options, 'or'); + 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|string ...$groups + * @param array|\Illuminate\Contracts\Database\Query\Expression|string ...$groups * @return $this */ public function groupBy(...$groups) @@ -1990,10 +2640,9 @@ public function groupBy(...$groups) } /** - * Add a raw groupBy clause to the query. + * Add a raw "groupBy" clause to the query. * * @param string $sql - * @param array $bindings * @return $this */ public function groupByRaw($sql, array $bindings = []) @@ -2008,9 +2657,9 @@ public function groupByRaw($sql, array $bindings = []) /** * Add a "having" clause to the query. * - * @param \Closure|string $column - * @param string|int|float|null $operator - * @param string|int|float|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 */ @@ -2018,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. @@ -2042,7 +2699,7 @@ public function having($column, $operator = null, $value = null, $boolean = 'and $this->havings[] = compact('type', 'column', 'operator', 'value', 'boolean'); - if (! $value instanceof Expression) { + if (! $value instanceof ExpressionContract) { $this->addBinding($this->flattenValue($value), 'having'); } @@ -2052,9 +2709,9 @@ public function having($column, $operator = null, $value = null, $boolean = 'and /** * Add an "or having" clause to the query. * - * @param \Closure|string $column - * @param string|int|float|null $operator - * @param string|int|float|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 * @return $this */ public function orHaving($column, $operator = null, $value = null) @@ -2067,9 +2724,8 @@ public function orHaving($column, $operator = null, $value = null) } /** - * Add a nested having statement to the query. + * Add a nested "having" statement to the query. * - * @param \Closure $callback * @param string $boolean * @return $this */ @@ -2103,7 +2759,7 @@ public function addNestedHavingQuery($query, $boolean = 'and') /** * Add a "having null" clause to the query. * - * @param string|array $columns + * @param array|string $columns * @param string $boolean * @param bool $not * @return $this @@ -2133,7 +2789,7 @@ public function orHavingNull($column) /** * Add a "having not null" clause to the query. * - * @param string|array $columns + * @param array|string $columns * @param string $boolean * @return $this */ @@ -2154,10 +2810,9 @@ public function orHavingNotNull($column) } /** - * Add a "having between " clause to the query. + * Add a "having between" clause to the query. * * @param string $column - * @param iterable $values * @param string $boolean * @param bool $not * @return $this @@ -2166,8 +2821,8 @@ public function havingBetween($column, iterable $values, $boolean = 'and', $not { $type = 'between'; - if ($values instanceof CarbonPeriod) { - $values = $values->toArray(); + if ($values instanceof DatePeriod) { + $values = $this->resolveDatePeriodBounds($values); } $this->havings[] = compact('type', 'column', 'values', 'boolean', 'not'); @@ -2178,10 +2833,69 @@ public function havingBetween($column, iterable $values, $boolean = 'and', $not } /** - * 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 */ @@ -2197,10 +2911,9 @@ 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 $this */ public function orHavingRaw($sql, array $bindings = []) @@ -2211,7 +2924,7 @@ public function orHavingRaw($sql, array $bindings = []) /** * Add an "order by" clause to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Expression|string $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|\Illuminate\Contracts\Database\Query\Expression|string $column * @param string $direction * @return $this * @@ -2244,7 +2957,7 @@ public function orderBy($column, $direction = 'asc') /** * Add a descending "order by" clause to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Expression|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) @@ -2255,7 +2968,7 @@ public function orderByDesc($column) /** * Add an "order by" clause for a timestamp to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function latest($column = 'created_at') @@ -2266,7 +2979,7 @@ public function latest($column = 'created_at') /** * Add an "order by" clause for a timestamp to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string $column * @return $this */ public function oldest($column = 'created_at') @@ -2274,10 +2987,43 @@ 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 = '') @@ -2357,6 +3103,22 @@ public function limit($value) 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; + } + /** * Set the limit and offset for a given page. * @@ -2381,12 +3143,14 @@ public function forPageBeforeId($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, 'desc') - ->limit($perPage); + ->limit($perPage); } /** @@ -2401,18 +3165,20 @@ 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') - ->limit($perPage); + ->limit($perPage); } /** * Remove all existing orders and optionally add a new order. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Query\Expression|string|null $column + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Contracts\Database\Query\Expression|string|null $column * @param string $direction * @return $this */ @@ -2430,6 +3196,17 @@ public function reorder($column = null, $direction = 'asc') 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'); + } + /** * Get an array with all orders with a given column removed. * @@ -2438,17 +3215,16 @@ public function reorder($column = null, $direction = 'asc') */ 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 \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $query * @param bool $all * @return $this */ @@ -2466,9 +3242,9 @@ public function union($query, $all = false) } /** - * Add a union all statement to the query. + * Add a "union all" statement to the query. * - * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*> $query * @return $this */ public function unionAll($query) @@ -2496,7 +3272,7 @@ public function lock($value = true) /** * Lock the selected rows in the table for updating. * - * @return \Illuminate\Database\Query\Builder + * @return $this */ public function lockForUpdate() { @@ -2506,17 +3282,35 @@ public function lockForUpdate() /** * Share lock the selected rows in the table. * - * @return \Illuminate\Database\Query\Builder + * @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; + } + /** * Register a closure to be invoked before the query is executed. * - * @param callable $callback * @return $this */ public function beforeQuery(callable $callback) @@ -2531,13 +3325,40 @@ public function beforeQuery(callable $callback) * * @return void */ - public function applyBeforeQueryCallbacks() + public function applyBeforeQueryCallbacks() + { + foreach ($this->beforeQueryCallbacks as $callback) { + $callback($this); + } + + $this->beforeQueryCallbacks = []; + } + + /** + * Register a closure to be invoked after the query is executed. + * + * @return $this + */ + public function afterQuery(Closure $callback) + { + $this->afterQueryCallbacks[] = $callback; + + return $this; + } + + /** + * Invoke the "after query" modification callbacks. + * + * @param mixed $result + * @return mixed + */ + public function applyAfterQueryCallbacks($result) { - foreach ($this->beforeQueryCallbacks as $callback) { - $callback($this); + foreach ($this->afterQueryCallbacks as $afterQueryCallback) { + $result = $afterQueryCallback($result) ?: $result; } - $this->beforeQueryCallbacks = []; + return $result; } /** @@ -2552,12 +3373,24 @@ public function toSql() 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|string $id - * @param array|string $columns - * @return mixed|static + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns + * @return \stdClass|null */ public function find($id, $columns = ['*']) { @@ -2567,12 +3400,14 @@ public function find($id, $columns = ['*']) /** * Execute a query for a single record by ID or call a callback. * + * @template TValue + * * @param mixed $id - * @param \Closure|array|string $columns - * @param \Closure|null $callback - * @return mixed|static + * @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) + public function findOr($id, $columns = ['*'], ?Closure $callback = null) { if ($columns instanceof Closure) { $callback = $columns; @@ -2597,21 +3432,19 @@ 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. * - * @param string $expression - * @param array $bindings * @return mixed */ public function rawValue(string $expression, array $bindings = []) { $result = (array) $this->selectRaw($expression, $bindings)->first(); - return count($result) > 0 ? reset($result) : null; + return count($result) > 0 ? array_first($result) : null; } /** @@ -2627,20 +3460,28 @@ public function soleValue($column) { $result = (array) $this->sole([$column]); - return reset($result); + 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 + ); } /** @@ -2651,28 +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|\Closure $perPage - * @param array|string $columns + * @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(); + $total = value($total) ?? $this->getCountForPagination(); - $perPage = $perPage instanceof Closure ? $perPage($total) : $perPage; + $perPage = value($perPage, $total); - $results = $total ? $this->forPage($page, $perPage)->get($columns) : collect(); + $results = $total ? $this->forPage($page, $perPage)->get($columns) : new Collection; return $this->paginator($results, $total, $perPage, $page, [ 'path' => Paginator::resolveCurrentPath(), @@ -2686,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|string $columns + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns * @param string $pageName * @param int|null $page * @return \Illuminate\Contracts\Pagination\Paginator @@ -2709,7 +3577,7 @@ public function simplePaginate($perPage = 15, $columns = ['*'], $pageName = 'pag * This is more efficient on larger data-sets, etc. * * @param int|null $perPage - * @param array|string $columns + * @param string|\Illuminate\Contracts\Database\Query\Expression|array $columns * @param string $cursorName * @param \Illuminate\Pagination\Cursor|string|null $cursor * @return \Illuminate\Contracts\Pagination\CursorPaginator @@ -2727,24 +3595,37 @@ public function cursorPaginate($perPage = 15, $columns = ['*'], $cursorName = 'c */ protected function ensureOrderForCursorPagination($shouldReverse = false) { - $this->enforceOrderBy(); - - return collect($this->orders ?? $this->unionOrders ?? [])->filter(function ($order) { - return Arr::has($order, 'direction'); - })->when($shouldReverse, function (Collection $orders) { - return $orders->map(function ($order) { - $order['direction'] = $order['direction'] === 'asc' ? 'desc' : 'asc'; + if (empty($this->orders) && empty($this->unionOrders)) { + $this->enforceOrderBy(); + } + $reverseDirection = function ($order) { + if (! isset($order['direction'])) { return $order; - }); - })->values(); + } + + $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 = ['*']) { @@ -2765,8 +3646,8 @@ public function getCountForPagination($columns = ['*']) /** * Run a pagination count query. * - * @param array $columns - * @return array + * @param array $columns + * @return array */ protected function runPaginationCountQuery($columns = ['*']) { @@ -2784,12 +3665,12 @@ protected function runPaginationCountQuery($columns = ['*']) ->get()->all(); } - $without = $this->unions ? ['orders', 'limit', 'offset'] : ['columns', 'orders', 'limit', 'offset']; + $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(); } /** @@ -2800,27 +3681,28 @@ protected function runPaginationCountQuery($columns = ['*']) protected function cloneForPaginationCount() { return $this->cloneWithout(['orders', 'limit', 'offset']) - ->cloneWithoutBindings(['order']); + ->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 lazy collection for the given query. * - * @return \Illuminate\Support\LazyCollection + * @return \Illuminate\Support\LazyCollection */ public function cursor() { @@ -2828,11 +3710,13 @@ public function cursor() $this->columns = ['*']; } - return new LazyCollection(function () { + return (new LazyCollection(function () { yield from $this->connection->cursor( - $this->toSql(), $this->getBindings(), ! $this->useWritePdo + $this->toSql(), $this->getBindings(), ! $this->useWritePdo, $this->fetchUsing ); - }); + }))->map(function ($item) { + return $this->applyAfterQueryCallbacks(new Collection([$item]))->first(); + })->reject(fn ($item) => is_null($item)); } /** @@ -2852,26 +3736,27 @@ protected function enforceOrderBy() /** * 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 @@ -2881,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) + ); } /** @@ -2898,9 +3785,13 @@ protected function stripTableForPluck($column) return $column; } - $separator = str_contains(strtolower($column), ' as ') ? ' as ' : '\.'; + $columnString = $column instanceof ExpressionContract + ? $this->grammar->getValue($column) + : $column; - return last(preg_split('~'.$separator.'~i', $column)); + $separator = str_contains(strtolower($columnString), ' as ') ? ' as ' : '\.'; + + return last(preg_split('~'.$separator.'~i', $columnString)); } /** @@ -2925,7 +3816,7 @@ protected function pluckFromObjectColumn($queryResult, $column, $key) } } - return collect($results); + return new Collection($results); } /** @@ -2950,7 +3841,7 @@ protected function pluckFromArrayColumn($queryResult, $column, $key) } } - return collect($results); + return new Collection($results); } /** @@ -3003,7 +3894,6 @@ public function doesntExist() /** * Execute the given callback if no rows exist for the current query. * - * @param \Closure $callback * @return mixed */ public function existsOr(Closure $callback) @@ -3014,7 +3904,6 @@ public function existsOr(Closure $callback) /** * Execute the given callback if rows exist for the current query. * - * @param \Closure $callback * @return mixed */ public function doesntExistOr(Closure $callback) @@ -3025,8 +3914,8 @@ public function doesntExistOr(Closure $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 = '*') { @@ -3036,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) @@ -3047,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) @@ -3058,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) @@ -3071,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) @@ -3082,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) @@ -3100,9 +3989,9 @@ public function average($column) public function aggregate($function, $columns = ['*']) { $results = $this->cloneWithout($this->unions || $this->havings ? [] : ['columns']) - ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) - ->setAggregate($function, $columns) - ->get($columns); + ->cloneWithoutBindings($this->unions || $this->havings ? [] : ['select']) + ->setAggregate($function, $columns) + ->get($columns); if (! $results->isEmpty()) { return array_change_key_case((array) $results[0])['aggregate']; @@ -3135,14 +4024,15 @@ public function numericAggregate($function, $columns = ['*']) // 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 ! str_contains((string) $result, '.') - ? (int) $result : (float) $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) @@ -3158,34 +4048,9 @@ protected function setAggregate($function, $columns) return $this; } - /** - * Execute the given callback while selecting the given columns. - * - * After running the callback, the columns are reset to the original value. - * - * @param array $columns - * @param callable $callback - * @return mixed - */ - protected function onceWithColumns($columns, $callback) - { - $original = $this->columns; - - if (is_null($original)) { - $this->columns = $columns; - } - - $result = $callback(); - - $this->columns = $original; - - return $result; - } - /** * Insert new records into the database. * - * @param array $values * @return bool */ public function insert(array $values) @@ -3197,7 +4062,7 @@ public function insert(array $values) return true; } - if (! is_array(reset($values))) { + if (! is_array(array_first($values))) { $values = [$values]; } @@ -3226,8 +4091,7 @@ public function insert(array $values) /** * Insert new records into the database while ignoring errors. * - * @param array $values - * @return int + * @return int<0, max> */ public function insertOrIgnore(array $values) { @@ -3235,11 +4099,12 @@ public function insertOrIgnore(array $values) return 0; } - if (! is_array(reset($values))) { + if (! is_array(array_first($values))) { $values = [$values]; } else { foreach ($values as $key => $value) { ksort($value); + $values[$key] = $value; } } @@ -3252,10 +4117,51 @@ public function insertOrIgnore(array $values) ); } + /** + * Insert new records into the database while ignoring specific conflicts and returning specified columns. + * + * @param non-empty-string|non-empty-array $uniqueBy + * @param non-empty-array $returning + * @return \Illuminate\Support\Collection + */ + public function insertOrIgnoreReturning(array $values, array|string $uniqueBy, array $returning = ['*']) + { + if (empty($values)) { + return new Collection; + } + + if ($uniqueBy === [] || $uniqueBy === '') { + throw new InvalidArgumentException('The unique columns must not be empty.'); + } + + 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); + + $values[$key] = $value; + } + } + + $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 */ @@ -3273,8 +4179,7 @@ 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|\Illuminate\Database\Eloquent\Builder|string $query + * @param \Closure|\Illuminate\Database\Query\Builder|\Illuminate\Database\Eloquent\Builder<*>|string $query * @return int */ public function insertUsing(array $columns, $query) @@ -3290,27 +4195,59 @@ public function insertUsing(array $columns, $query) } /** - * Update records 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) { $this->applyBeforeQueryCallbacks(); - $sql = $this->grammar->compileUpdate($this, $values); + $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) + $this->grammar->prepareBindingsForUpdate($this->bindings, $values->map(fn ($value) => $value['bindings'])->all()) )); } /** * Update records in a PostgreSQL database using the update from syntax. * - * @param array $values * @return int + * + * @throws \LogicException */ public function updateFrom(array $values) { @@ -3330,13 +4267,17 @@ public function updateFrom(array $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)); } @@ -3350,20 +4291,22 @@ public function updateOrInsert(array $attributes, array $values = []) /** * Insert new records or update the existing ones. * - * @param array $values - * @param array|string $uniqueBy - * @param array|null $update + * @param non-empty-string|non-empty-array $uniqueBy * @return int */ - public function upsert(array $values, $uniqueBy, $update = null) + 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(reset($values))) { + if (! is_array(array_first($values))) { $values = [$values]; } else { foreach ($values as $key => $value) { @@ -3374,16 +4317,16 @@ public function upsert(array $values, $uniqueBy, $update = null) } if (is_null($update)) { - $update = array_keys(reset($values)); + $update = array_keys(array_first($values)); } $this->applyBeforeQueryCallbacks(); $bindings = $this->cleanBindings(array_merge( Arr::flatten($values, 1), - collect($update)->reject(function ($value, $key) { - return is_int($key); - })->all() + (new Collection($update)) + ->reject(fn ($value, $key) => is_int($key)) + ->all() )); return $this->connection->affectingStatement( @@ -3397,8 +4340,7 @@ public function upsert(array $values, $uniqueBy, $update = null) * * @param string $column * @param float|int $amount - * @param array $extra - * @return int + * @return int<0, max> * * @throws \InvalidArgumentException */ @@ -3408,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)); } /** @@ -3420,8 +4382,7 @@ 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 */ @@ -3431,11 +4392,31 @@ 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)); } /** @@ -3463,7 +4444,7 @@ public function delete($id = null) } /** - * Run a truncate statement on the table. + * Run a "truncate" statement on the table. * * @return void */ @@ -3496,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() { @@ -3520,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() { @@ -3530,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 @@ -3551,7 +4590,7 @@ public function setBindings(array $bindings, $type = 'where') * Add a binding to the query. * * @param mixed $value - * @param string $type + * @param "select"|"from"|"join"|"where"|"groupBy"|"having"|"order"|"union"|"unionOrder" $type * @return $this * * @throws \InvalidArgumentException @@ -3564,7 +4603,7 @@ public function addBinding($value, $type = 'where') if (is_array($value)) { $this->bindings[$type] = array_values(array_map( - [$this, 'castBinding'], + $this->castBinding(...), array_merge($this->bindings[$type], $value), )); } else { @@ -3582,17 +4621,13 @@ public function addBinding($value, $type = 'where') */ public function castBinding($value) { - if (function_exists('enum_exists') && $value instanceof BackedEnum) { - return $value->value; - } - - return $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) @@ -3605,18 +4640,18 @@ 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 */ public function cleanBindings(array $bindings) { - return collect($bindings) - ->reject(function ($binding) { - return $binding instanceof Expression; - }) - ->map([$this, 'castBinding']) - ->values() - ->all(); + return (new Collection($bindings)) + ->reject(function ($binding) { + return $binding instanceof ExpressionContract; + }) + ->map($this->castBinding(...)) + ->values() + ->all(); } /** @@ -3650,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. * @@ -3682,6 +4731,19 @@ 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. * @@ -3709,7 +4771,6 @@ public function clone() /** * Clone the query without the given properties. * - * @param array $properties * @return static */ public function cloneWithout(array $properties) @@ -3724,7 +4785,6 @@ public function cloneWithout(array $properties) /** * Clone the query without the given bindings. * - * @param array $except * @return static */ public function cloneWithoutBindings(array $except) @@ -3739,11 +4799,28 @@ public function cloneWithoutBindings(array $except) /** * 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 dump() + public function dumpRawSql() { - dump($this->toSql(), $this->getBindings()); + dump($this->toRawSql()); return $this; } @@ -3758,6 +4835,16 @@ 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. * 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 46fae45a02..aa0dd52344 100755 --- a/Query/Grammars/Grammar.php +++ b/Query/Grammars/Grammar.php @@ -2,11 +2,14 @@ namespace Illuminate\Database\Query\Grammars; +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\Query\JoinLateralClause; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use RuntimeException; class Grammar extends BaseGrammar @@ -36,6 +39,7 @@ class Grammar extends BaseGrammar 'aggregate', 'columns', 'from', + 'indexHint', 'joins', 'wheres', 'groups', @@ -58,6 +62,17 @@ public function compileSelect(Builder $query) 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. @@ -108,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) @@ -173,17 +188,49 @@ 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. * @@ -217,9 +264,9 @@ public 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(); } /** @@ -245,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']; } /** @@ -276,6 +323,26 @@ protected function whereBitwise(Builder $query, $where) return $this->whereBasic($query, $where); } + /** + * Compile a "where like" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + * + * @throws \RuntimeException + */ + protected function whereLike(Builder $query, $where) + { + if ($where['caseSensitive']) { + throw new RuntimeException('This database engine does not support case sensitive like operations.'); + } + + $where['operator'] = $where['not'] ? 'not like' : 'like'; + + return $this->whereBasic($query, $where); + } + /** * Compile a "where in" clause. * @@ -379,9 +446,9 @@ protected function whereBetween(Builder $query, $where) { $between = $where['not'] ? 'not between' : 'between'; - $min = $this->parameter(is_array($where['values']) ? reset($where['values']) : $where['values'][0]); + $min = $this->parameter(is_array($where['values']) ? array_first($where['values']) : $where['values'][0]); - $max = $this->parameter(is_array($where['values']) ? end($where['values']) : $where['values'][1]); + $max = $this->parameter(is_array($where['values']) ? array_last($where['values']) : $where['values'][1]); return $this->wrap($where['column']).' '.$between.' '.$min.' and '.$max; } @@ -397,13 +464,31 @@ protected function whereBetweenColumns(Builder $query, $where) { $between = $where['not'] ? 'not between' : 'between'; - $min = $this->wrap(is_array($where['values']) ? reset($where['values']) : $where['values'][0]); + $min = $this->wrap(is_array($where['values']) ? array_first($where['values']) : $where['values'][0]); - $max = $this->wrap(is_array($where['values']) ? end($where['values']) : $where['values'][1]); + $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. * @@ -503,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).')'; } @@ -611,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. * @@ -619,7 +735,7 @@ protected function compileJsonContains($column, $value) */ public function prepareBindingForJsonContains($binding) { - return json_encode($binding); + return json_encode($binding, JSON_UNESCAPED_UNICODE); } /** @@ -699,12 +815,26 @@ public function compileJsonValueCast($value) * @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. * @@ -725,7 +855,7 @@ protected function compileGroups(Builder $query, $groups) */ protected function compileHavings(Builder $query) { - return 'having '.$this->removeLeadingBoolean(collect($query->havings)->map(function ($having) { + return 'having '.$this->removeLeadingBoolean((new Collection($query->havings))->map(function ($having) { return $having['boolean'].' '.$this->compileHaving($having); })->implode(' ')); } @@ -741,21 +871,16 @@ 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['sql']; - } elseif ($having['type'] === 'between') { - return $this->compileHavingBetween($having); - } elseif ($having['type'] === 'Null') { - return $this->compileHavingNull($having); - } elseif ($having['type'] === 'NotNull') { - return $this->compileHavingNotNull($having); - } elseif ($having['type'] === 'bit') { - return $this->compileHavingBit($having); - } elseif ($having['type'] === 'Nested') { - return $this->compileNestedHavings($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), + }; } /** @@ -833,6 +958,17 @@ protected function compileHavingBit($having) 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. * @@ -869,7 +1005,11 @@ protected function compileOrders(Builder $query, $orders) */ protected function compileOrdersToArray(Builder $query, $orders) { - return array_map(function ($order) { + 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); } @@ -877,7 +1017,7 @@ protected function compileOrdersToArray(Builder $query, $orders) /** * Compile the random statement into SQL. * - * @param string $seed + * @param string|int $seed * @return string */ public function compileRandom($seed) @@ -897,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. * @@ -1008,18 +1208,18 @@ public function compileInsert(Builder $query, array $values) return "insert into {$table} default values"; } - if (! is_array(reset($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 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"; } @@ -1038,12 +1238,28 @@ 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 string|null $sequence * @return string */ public function compileInsertGetId(Builder $query, $values, $sequence) @@ -1061,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.'); } /** @@ -1095,9 +1332,9 @@ public function compileUpdate(Builder $query, array $values) */ protected function compileUpdateColumns(Builder $query, array $values) { - return collect($values)->map(function ($value, $key) { - return $this->wrap($key).' = '.$this->parameter($value); - })->implode(', '); + return (new Collection($values)) + ->map(fn ($value, $key) => $this->wrap($key).' = '.$this->parameter($value)) + ->implode(', '); } /** @@ -1157,6 +1394,8 @@ public function prepareBindingsForUpdate(array $bindings, array $values) { $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)) ); @@ -1247,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. * @@ -1325,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. * 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 3e4f06a20b..013d30e4ae 100755 --- a/Query/Grammars/MySqlGrammar.php +++ b/Query/Grammars/MySqlGrammar.php @@ -3,7 +3,10 @@ namespace Illuminate\Database\Query\Grammars; use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinLateralClause; +use Illuminate\Support\Collection; use Illuminate\Support\Str; +use InvalidArgumentException; class MySqlGrammar extends Grammar { @@ -14,6 +17,46 @@ class MySqlGrammar extends Grammar */ protected $operators = ['sounds like']; + /** + * Compile a select query into SQL. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + 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 "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 binary' : 'like'; + + return $this->whereBasic($query, $where); + } + /** * Add a "where null" clause to the query. * @@ -23,8 +66,10 @@ class MySqlGrammar extends Grammar */ protected function whereNull(Builder $query, $where) { - if ($this->isJsonSelector($where['column'])) { - [$field, $path] = $this->wrapJsonFieldAndPath($where['column']); + $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\')'; } @@ -41,8 +86,10 @@ protected function whereNull(Builder $query, $where) */ protected function whereNotNull(Builder $query, $where) { - if ($this->isJsonSelector($where['column'])) { - [$field, $path] = $this->wrapJsonFieldAndPath($where['column']); + $columnValue = (string) $this->getValue($where['column']); + + if ($this->isJsonSelector($columnValue)) { + [$field, $path] = $this->wrapJsonFieldAndPath($columnValue); return '(json_extract('.$field.$path.') is not null AND json_type(json_extract('.$field.$path.')) != \'NULL\')'; } @@ -74,6 +121,110 @@ public function whereFullText(Builder $query, $where) 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. * @@ -86,6 +237,19 @@ 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)); + } + /** * Compile a "JSON contains" statement into SQL. * @@ -100,6 +264,20 @@ 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. * @@ -142,12 +320,22 @@ public function compileJsonValueCast($value) /** * 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.')'; } /** @@ -191,7 +379,7 @@ public function compileInsert(Builder $query, array $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, $value); } @@ -221,7 +409,7 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar $sql .= ' on duplicate key update '; - $columns = collect($update)->map(function ($value, $key) use ($useUpsertAlias) { + $columns = (new Collection($update))->map(function ($value, $key) use ($useUpsertAlias) { if (! is_numeric($key)) { return $this->wrap($key).' = '.$this->parameter($value); } @@ -234,6 +422,26 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar 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"); + } + + /** + * {@inheritdoc} + */ + protected function supportsStraightJoins() + { + return true; + } + /** * Prepare a JSON column being updated using the JSON_SET function. * @@ -289,19 +497,19 @@ protected function compileUpdateWithoutJoins(Builder $query, $table, $columns, $ * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { - $values = collect($values)->reject(function ($value, $column) { - return $this->isJsonSelector($column) && is_bool($value); - })->map(function ($value) { - return is_array($value) ? json_encode($value) : $value; - })->all(); + $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 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 @@ -312,9 +520,6 @@ protected function compileDeleteWithoutJoins(Builder $query, $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); } @@ -326,6 +531,43 @@ protected function compileDeleteWithoutJoins(Builder $query, $table, $where) return $sql; } + /** + * 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 string $where + * @return string + */ + protected function compileDeleteWithJoins(Builder $query, $table, $where) + { + $sql = parent::compileDeleteWithJoins($query, $table, $where); + + if (! empty($query->orders)) { + $sql .= ' '.$this->compileOrders($query, $query->orders); + } + + if (isset($query->limit)) { + $sql .= ' '.$this->compileLimit($query, $query->limit); + } + + return $sql; + } + + /** + * 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\''; + } + /** * Wrap a single string in keyword identifiers. * diff --git a/Query/Grammars/PostgresGrammar.php b/Query/Grammars/PostgresGrammar.php index ad4678b0c9..a21443523c 100755 --- a/Query/Grammars/PostgresGrammar.php +++ b/Query/Grammars/PostgresGrammar.php @@ -3,7 +3,9 @@ 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; class PostgresGrammar extends Grammar @@ -21,6 +23,13 @@ class PostgresGrammar extends Grammar 'is distinct from', 'is not distinct from', ]; + /** + * The Postgres grammar specific custom operators. + * + * @var array + */ + protected static $customOperators = []; + /** * The grammar specific bitwise operators. * @@ -30,6 +39,13 @@ class PostgresGrammar extends Grammar '~', '&', '|', '#', '<<', '>>', '<<=', '>>=', ]; + /** + * Indicates if the cascade option should be used when truncating. + * + * @var bool + */ + protected static $cascadeTruncate = true; + /** * Compile a basic where clause. * @@ -67,6 +83,22 @@ protected function whereBitwise(Builder $query, $where) 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. * @@ -76,9 +108,14 @@ protected function whereBitwise(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; } /** @@ -90,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; } /** @@ -125,9 +167,13 @@ public function whereFullText(Builder $query, $where) $language = 'english'; } - $columns = collect($where['columns'])->map(function ($column) use ($language) { - return "to_tsvector('{$language}', {$this->wrap($column)})"; - })->implode(' || '); + $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'; @@ -139,6 +185,10 @@ public function whereFullText(Builder $query, $where) $mode = 'websearch_to_tsquery'; } + if (($where['options']['mode'] ?? []) === 'raw') { + $mode = 'to_tsquery'; + } + return "({$columns}) @@ {$mode}('{$language}', {$this->parameter($where['value'])})"; } @@ -323,12 +373,41 @@ 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 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 $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 string|null $sequence * @return string */ public function compileInsertGetId(Builder $query, $values, $sequence) @@ -361,7 +440,7 @@ public function compileUpdate(Builder $query, array $values) */ protected function compileUpdateColumns(Builder $query, array $values) { - return collect($values)->map(function ($value, $key) { + return (new Collection($values))->map(function ($value, $key) { $column = last(explode('.', $key)); if ($this->isJsonSelector($key)) { @@ -387,7 +466,7 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; - $columns = collect($update)->map(function ($value, $key) { + $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); @@ -396,6 +475,18 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar 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. * @@ -436,9 +527,9 @@ public function compileUpdateFrom(Builder $query, $values) // 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(); + $froms = (new Collection($query->joins)) + ->map(fn ($join) => $this->wrapTable($join->table)) + ->all(); if (count($froms) > 0) { $from = ' from '.implode(', ', $froms); @@ -509,11 +600,13 @@ protected function compileUpdateJoinWheres(Builder $query) */ public function prepareBindingsForUpdateFrom(array $bindings, array $values) { - $values = collect($values)->map(function ($value, $column) { - return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) - ? json_encode($value) - : $value; - })->all(); + $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']); @@ -549,9 +642,10 @@ protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values) * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { - $values = collect($values)->map(function ($value, $column) { + $values = (new Collection($values))->map(function ($value, $column) { return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) ? json_encode($value) : $value; @@ -559,6 +653,8 @@ public function prepareBindingsForUpdate(array $bindings, array $values) $cleanBindings = Arr::except($bindings, 'select'); + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); + return array_values( array_merge($values, Arr::flatten($cleanBindings)) ); @@ -604,7 +700,17 @@ protected function compileDeleteWithJoinsOrLimit(Builder $query) */ 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'; } /** @@ -667,13 +773,15 @@ protected function wrapJsonPathAttributes($path) { $quote = func_num_args() === 2 ? func_get_arg(1) : "'"; - return collect($path)->map(function ($attribute) { - return $this->parseJsonPathArrayKeys($attribute); - })->collapse()->map(function ($attribute) use ($quote) { - return filter_var($attribute, FILTER_VALIDATE_INT) !== false - ? $attribute - : $quote.$attribute.$quote; - })->all(); + 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(); } /** @@ -689,7 +797,7 @@ protected function parseJsonPathArrayKeys($attribute) preg_match_all('/\[([^\]]+)\]/', $parts[0], $keys); - return collect([$key]) + return (new Collection([$key])) ->merge($keys[1]) ->diff('') ->values() @@ -698,4 +806,68 @@ protected function parseJsonPathArrayKeys($attribute) 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 81b890b8b7..28401b9e97 100755 --- a/Query/Grammars/SQLiteGrammar.php +++ b/Query/Grammars/SQLiteGrammar.php @@ -4,7 +4,9 @@ use Illuminate\Database\Query\Builder; use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Illuminate\Support\Str; +use InvalidArgumentException; class SQLiteGrammar extends Grammar { @@ -42,6 +44,58 @@ protected function wrapUnion($sql) return 'select * from ('.$sql.')'; } + /** + * 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 parent::whereBasic($query, $where); + } + + /** + * Compile a "where like" clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @param array $where + * @return string + */ + protected function whereLike(Builder $query, $where) + { + if ($where['caseSensitive'] == false) { + return parent::whereLike($query, $where); + } + $where['operator'] = $where['not'] ? 'not glob' : 'glob'; + + 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 + ); + } + /** * Compile a "where date" clause. * @@ -117,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. * @@ -132,6 +210,31 @@ protected function compileJsonLength($column, $operator, $value) return 'json_array_length('.$field.$path.') '.$operator.' '.$value; } + /** + * 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. * @@ -145,6 +248,25 @@ protected function compileJsonContainsKey($column) return 'json_type('.$field.$path.') is not null'; } + /** + * Compile a group limit clause. + * + * @param \Illuminate\Database\Query\Builder $query + * @return string + */ + protected function compileGroupLimit(Builder $query) + { + $version = $query->getConnection()->getServerVersion(); + + if (version_compare($version, '3.25.0', '>=')) { + return parent::compileGroupLimit($query); + } + + $query->groupLimit = null; + + return $this->compileSelect($query); + } + /** * Compile an update statement into SQL. * @@ -173,6 +295,35 @@ 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. * @@ -184,15 +335,17 @@ protected function compileUpdateColumns(Builder $query, array $values) { $jsonGroups = $this->groupJsonColumnsForUpdate($values); - return collect($values)->reject(function ($value, $key) { - return $this->isJsonSelector($key); - })->merge($jsonGroups)->map(function ($value, $key) use ($jsonGroups) { - $column = last(explode('.', $key)); + 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); + $value = isset($jsonGroups[$key]) ? $this->compileJsonPatch($column, $value) : $this->parameter($value); - return $this->wrap($column).' = '.$value; - })->implode(', '); + return $this->wrap($column).' = '.$value; + }) + ->implode(', '); } /** @@ -210,7 +363,7 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar $sql .= ' on conflict ('.$this->columnize($uniqueBy).') do update set '; - $columns = collect($update)->map(function ($value, $key) { + $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); @@ -277,18 +430,21 @@ protected function compileUpdateWithJoinsOrLimit(Builder $query, array $values) * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { $groups = $this->groupJsonColumnsForUpdate($values); - $values = collect($values)->reject(function ($value, $key) { - return $this->isJsonSelector($key); - })->merge($groups)->map(function ($value) { - return is_array($value) ? json_encode($value) : $value; - })->all(); + $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, Arr::flatten($cleanBindings)) ); @@ -334,8 +490,12 @@ protected function compileDeleteWithJoinsOrLimit(Builder $query) */ 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 f66a6f1180..f22f528e35 100755 --- a/Query/Grammars/SqlServerGrammar.php +++ b/Query/Grammars/SqlServerGrammar.php @@ -3,8 +3,11 @@ 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 InvalidArgumentException; class SqlServerGrammar extends Grammar { @@ -28,6 +31,7 @@ class SqlServerGrammar extends Grammar 'aggregate', 'columns', 'from', + 'indexHint', 'joins', 'wheres', 'groups', @@ -101,6 +105,30 @@ 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} * @@ -251,38 +279,6 @@ protected function compileHavingBitwise($having) return '('.$column.' '.$having['operator'].' '.$parameter.') != 0'; } - /** - * Move the order bindings to be after the "select" statement to account for an order by subquery. - * - * @param \Illuminate\Database\Query\Builder $query - * @return array - */ - protected function sortBindingsForSubqueryOrderBy($query) - { - return Arr::sort($query->bindings, function ($bindings, $key) { - return array_search($key, ['select', 'order', 'from', 'join', 'where', 'groupBy', 'having', 'union', 'unionOrder']); - }); - } - - /** - * Compile the limit / offset row constraint for a query. - * - * @param \Illuminate\Database\Query\Builder $query - * @return string - */ - protected function compileRowConstraint($query) - { - $start = (int) $query->offset + 1; - - if ($query->limit > 0) { - $finish = (int) $query->offset + (int) $query->limit; - - return "between {$start} and {$finish}"; - } - - return ">= {$start}"; - } - /** * Compile a delete statement without joins into SQL. * @@ -296,14 +292,14 @@ protected function compileDeleteWithoutJoins(Builder $query, $table, $where) $sql = parent::compileDeleteWithoutJoins($query, $table, $where); return ! is_null($query->limit) && $query->limit > 0 && $query->offset <= 0 - ? Str::replaceFirst('delete', 'delete top ('.$query->limit.')', $sql) - : $sql; + ? 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) @@ -329,6 +325,22 @@ protected function compileLimit(Builder $query, $limit) 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. * @@ -414,24 +426,24 @@ protected function compileUpdateWithJoins(Builder $query, $table, $columns, $whe */ public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) { - $columns = $this->columnize(array_keys(reset($values))); + $columns = $this->columnize(array_keys(array_first($values))); $sql = 'merge '.$this->wrapTable($query->from).' '; - $parameters = collect($values)->map(function ($record) { - return '('.$this->parameterize($record).')'; - })->implode(', '); + $parameters = (new Collection($values)) + ->map(fn ($record) => '('.$this->parameterize($record).')') + ->implode(', '); $sql .= 'using (values '.$parameters.') '.$this->wrapTable('laravel_source').' ('.$columns.') '; - $on = collect($uniqueBy)->map(function ($column) use ($query) { - return $this->wrap('laravel_source.'.$column).' = '.$this->wrap($query->from.'.'.$column); - })->implode(' and '); + $on = (new Collection($uniqueBy)) + ->map(fn ($column) => $this->wrap('laravel_source.'.$column).' = '.$this->wrap($query->from.'.'.$column)) + ->implode(' and '); $sql .= 'on '.$on.' '; if ($update) { - $update = collect($update)->map(function ($value, $key) { + $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); @@ -452,15 +464,32 @@ public function compileUpsert(Builder $query, array $values, array $uniqueBy, ar * @param array $values * @return array */ + #[\Override] public function prepareBindingsForUpdate(array $bindings, array $values) { $cleanBindings = Arr::except($bindings, 'select'); + $values = Arr::flatten(array_map(fn ($value) => value($value), $values)); + return array_values( 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. * @@ -483,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. * @@ -531,13 +570,14 @@ protected function wrapJsonBooleanValue($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 57d650a38c..d5733f3550 100755 --- a/Query/JoinClause.php +++ b/Query/JoinClause.php @@ -16,7 +16,7 @@ class JoinClause extends Builder /** * The table the join clause is joining to. * - * @var string + * @var \Illuminate\Contracts\Database\Query\Expression|string */ public $table; @@ -54,7 +54,6 @@ class JoinClause extends Builder * @param \Illuminate\Database\Query\Builder $parentQuery * @param string $type * @param string $table - * @return void */ public function __construct(Builder $parentQuery, $type, $table) { @@ -82,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 \Illuminate\Database\Query\Expression|string|null $second + * @param \Illuminate\Contracts\Database\Query\Expression|string|null $second * @param string $boolean * @return $this * @@ -102,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 \Illuminate\Database\Query\Expression|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) 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 @@ +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 5956a8fb31..2818f91d8c 100755 --- a/Query/Processors/PostgresProcessor.php +++ b/Query/Processors/PostgresProcessor.php @@ -30,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) { + $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) { - return ((object) $result)->column_name; + $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 0069b436d5..46f692e49a 100755 --- a/Query/Processors/Processor.php +++ b/Query/Processors/Processor.php @@ -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 49476f0955..8d000c4579 100755 --- a/Query/Processors/SqlServerProcessor.php +++ b/Query/Processors/SqlServerProcessor.php @@ -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 84aebb1a67..b9251d2fe0 100644 --- a/QueryException.php +++ b/QueryException.php @@ -2,6 +2,7 @@ namespace Illuminate\Database; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use PDOException; use Throwable; @@ -29,6 +30,20 @@ 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. * @@ -36,15 +51,18 @@ class QueryException extends PDOException * @param string $sql * @param array $bindings * @param \Throwable $previous - * @return void + * @param null|'read'|'write' $readWriteType + * @param array $connectionDetails */ - public function __construct($connectionName, $sql, array $bindings, Throwable $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($connectionName, $sql, $bindings, $previous); @@ -64,7 +82,40 @@ public function __construct($connectionName, $sql, array $bindings, Throwable $p */ protected function formatMessage($connectionName, $sql, $bindings, Throwable $previous) { - return $previous->getMessage().' (Connection: '.$connectionName.', 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); } /** @@ -87,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. * @@ -96,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/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()); } /** @@ -43,7 +69,7 @@ public function __construct($pdo, $database = '', $tablePrefix = '', array $conf */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -67,7 +93,7 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** @@ -78,7 +104,7 @@ protected function getDefaultSchemaGrammar() * * @throws \RuntimeException */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { return new SqliteSchemaState($this, $files, $processFactory); } @@ -92,24 +118,4 @@ protected function getDefaultPostProcessor() { return new SQLiteProcessor; } - - /** - * Get the Doctrine DBAL driver. - * - * @return \Illuminate\Database\PDO\SQLiteDriver - */ - protected function getDoctrineDriver() - { - return new SQLiteDriver; - } - - /** - * Get the database connection foreign key constraints configuration option. - * - * @return bool|null - */ - protected function getForeignKeyConstraintsConfigurationValue() - { - return $this->getConfig('foreign_key_constraints'); - } } diff --git a/SQLiteDatabaseDoesNotExistException.php b/SQLiteDatabaseDoesNotExistException.php index fab5079e73..8ea87cf39e 100644 --- a/SQLiteDatabaseDoesNotExistException.php +++ b/SQLiteDatabaseDoesNotExistException.php @@ -17,11 +17,10 @@ class SQLiteDatabaseDoesNotExistException extends InvalidArgumentException * Create a new exception instance. * * @param string $path - * @return void */ public function __construct($path) { - parent::__construct("Database ({$path}) does not exist."); + parent::__construct("Database file at path [{$path}] does not exist. Ensure this is an absolute path to the database."); $this->path = $path; } diff --git a/Schema/Blueprint.php b/Schema/Blueprint.php index e3ab9a11ba..8c5c1cd7dc 100755 --- a/Schema/Blueprint.php +++ b/Schema/Blueprint.php @@ -2,32 +2,39 @@ namespace Illuminate\Database\Schema; -use BadMethodCallException; use Closure; use Illuminate\Database\Connection; +use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Grammars\Grammar; -use Illuminate\Database\SQLiteConnection; +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. @@ -78,18 +85,25 @@ class Blueprint */ 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); @@ -99,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) || $grammar::hasMacro($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); } } @@ -144,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(); - $this->addFluentCommands($grammar); + if (! $this->creating()) { + $this->commands = array_map( + fn ($command) => $command instanceof ColumnDefinition + ? $this->createCommand($command->change ? 'change' : 'add', ['column' => $command]) + : $command, + $this->commands + ); + + $this->addAlterCommands(); + } } /** @@ -208,13 +215,34 @@ protected function addImpliedCommands(Grammar $grammar) protected function addFluentIndexes() { foreach ($this->columns as $column) { - foreach (['primary', 'unique', 'index', 'fulltext', 'fullText', '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); - $column->{$index} = false; + $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; } @@ -223,8 +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}); - $column->{$index} = false; + $indexMethod = $index === 'index' && $column->type === 'vector' + ? 'vectorIndex' + : $index; + + $this->{$indexMethod}($column->name, $column->{$index}); + $column->{$index} = null; continue 2; } @@ -235,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; } /** @@ -264,9 +325,8 @@ public function addFluentCommands(Grammar $grammar) */ 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'); } /** @@ -279,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. * @@ -312,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) @@ -426,7 +529,7 @@ public function dropForeignIdFor($model, $column = null) $model = new $model; } - return $this->dropForeign([$column ?: $model->getForeignKey()]); + return $this->dropColumn($column ?: $model->getForeignKey()); } /** @@ -574,7 +677,7 @@ public function index($columns, $name = null, $algorithm = null) } /** - * Specify an fulltext for the table. + * Specify a fulltext index for the table. * * @param string|array $columns * @param string|null $name @@ -591,11 +694,24 @@ public function fullText($columns, $name = null, $algorithm = null) * * @param string|array $columns * @param string|null $name + * @param string|null $operatorClass * @return \Illuminate\Database\Schema\IndexDefinition */ - public function spatialIndex($columns, $name = null) + public function spatialIndex($columns, $name = null, $operatorClass = null) { - return $this->indexCommand('spatialIndex', $columns, $name); + 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'); } /** @@ -629,7 +745,7 @@ public function foreign($columns, $name = null) } /** - * 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 @@ -640,7 +756,7 @@ public function id($column = 'id') } /** - * 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 @@ -651,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 @@ -662,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 @@ -673,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 @@ -684,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 @@ -695,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 @@ -734,7 +850,7 @@ public function string($column, $length = null) } /** - * Create a new tiny 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 @@ -745,7 +861,7 @@ public function tinyText($column) } /** - * Create a new text column on the table. + * Create a new text column on the table (up to 65,535 characters / ~64 KB). * * @param string $column * @return \Illuminate\Database\Schema\ColumnDefinition @@ -756,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 @@ -767,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 @@ -779,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 @@ -792,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 @@ -805,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 @@ -818,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 @@ -831,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 @@ -843,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 @@ -855,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 @@ -867,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 @@ -879,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 @@ -891,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 @@ -903,7 +1024,7 @@ public function unsignedBigInteger($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 * @return \Illuminate\Database\Schema\ForeignIdColumnDefinition @@ -931,90 +1052,61 @@ public function foreignIdFor($model, $column = null) $model = new $model; } - return $model->getKeyType() === 'int' && $model->getIncrementing() - ? $this->foreignId($column ?: $model->getForeignKey()) - : $this->foreignUuid($column ?: $model->getForeignKey()); - } + $column = $column ?: $model->getForeignKey(); - /** - * Create a new float column on the table. - * - * @param string $column - * @param int $total - * @param int $places - * @param bool $unsigned - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function float($column, $total = 8, $places = 2, $unsigned = false) - { - return $this->addColumn('float', $column, compact('total', 'places', 'unsigned')); - } + if ($model->getKeyType() === 'int') { + return $this->foreignId($column) + ->table($model->getTable()) + ->referencesModelColumn($model->getKeyName()); + } - /** - * Create a new double column on the table. - * - * @param string $column - * @param int|null $total - * @param int|null $places - * @param bool $unsigned - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function double($column, $total = null, $places = null, $unsigned = false) - { - return $this->addColumn('double', $column, compact('total', 'places', 'unsigned')); - } + $modelTraits = class_uses_recursive($model); - /** - * Create a new decimal column on the table. - * - * @param string $column - * @param int $total - * @param int $places - * @param bool $unsigned - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function decimal($column, $total = 8, $places = 2, $unsigned = false) - { - return $this->addColumn('decimal', $column, compact('total', 'places', 'unsigned')); + 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 unsigned float column on the table. + * Create a new float column on the table. * * @param string $column - * @param int $total - * @param int $places + * @param int $precision * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function unsignedFloat($column, $total = 8, $places = 2) + public function float($column, $precision = 53) { - return $this->float($column, $total, $places, true); + return $this->addColumn('float', $column, compact('precision')); } /** - * Create a new unsigned double 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 unsignedDouble($column, $total = null, $places = null) + public function double($column) { - return $this->double($column, $total, $places, true); + 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->decimal($column, $total, $places, true); + return $this->addColumn('decimal', $column, compact('total', 'places')); } /** @@ -1037,6 +1129,8 @@ 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')); } @@ -1092,8 +1186,10 @@ public function date($column) * @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')); } @@ -1104,8 +1200,10 @@ public function dateTime($column, $precision = 0) * @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')); } @@ -1116,8 +1214,10 @@ public function dateTimeTz($column, $precision = 0) * @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')); } @@ -1128,8 +1228,10 @@ public function time($column, $precision = 0) * @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')); } @@ -1140,8 +1242,10 @@ public function timeTz($column, $precision = 0) * @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')); } @@ -1152,8 +1256,10 @@ public function timestamp($column, $precision = 0) * @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')); } @@ -1161,13 +1267,14 @@ public function timestampTz($column, $precision = 0) * Add nullable creation and update timestamps to the table. * * @param int|null $precision - * @return void + * @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(), + ]); } /** @@ -1176,24 +1283,52 @@ public function timestamps($precision = 0) * Alias for self::timestamps(). * * @param int|null $precision - * @return void + * @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|null $precision - * @return void + * @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(), + ]); } /** @@ -1203,7 +1338,7 @@ public function timestampsTz($precision = 0) * @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(); } @@ -1215,11 +1350,23 @@ public function softDeletes($column = 'deleted_at', $precision = 0) * @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. * @@ -1235,11 +1382,13 @@ 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')); } /** @@ -1274,7 +1423,7 @@ public function foreignUuid($column) * @param int|null $length * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function ulid($column = 'uuid', $length = 26) + public function ulid($column = 'ulid', $length = 26) { return $this->char($column, $length); } @@ -1321,112 +1470,52 @@ public function macAddress($column = 'mac_address') * 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) - { - return $this->addColumn('geometry', $column); - } - - /** - * Create a new point column on the table. - * - * @param string $column - * @param int|null $srid - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function point($column, $srid = null) - { - return $this->addColumn('point', $column, compact('srid')); - } - - /** - * Create a new linestring column on the table. - * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function lineString($column) - { - return $this->addColumn('linestring', $column); - } - - /** - * Create a new polygon column on the table. - * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function polygon($column) - { - return $this->addColumn('polygon', $column); - } - - /** - * Create a new geometrycollection column on the table. - * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function geometryCollection($column) - { - return $this->addColumn('geometrycollection', $column); - } - - /** - * Create a new multipoint column on the table. - * - * @param string $column - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function multiPoint($column) + public function geometry($column, $subtype = null, $srid = 0) { - return $this->addColumn('multipoint', $column); + return $this->addColumn('geometry', $column, compact('subtype', 'srid')); } /** - * Create a new multilinestring column on the table. + * Create a new geography column on the table. * * @param string $column + * @param string|null $subtype + * @param int $srid * @return \Illuminate\Database\Schema\ColumnDefinition */ - public function multiLineString($column) + public function geography($column, $subtype = null, $srid = 4326) { - return $this->addColumn('multilinestring', $column); + return $this->addColumn('geography', $column, compact('subtype', 'srid')); } /** - * Create a new multipolygon 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 multiPolygon($column) + public function computed($column, $expression) { - return $this->addColumn('multipolygon', $column); + return $this->addColumn('computed', $column, compact('expression')); } /** - * Create a new multipolygon 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 multiPolygonZ($column) + public function vector($column, $dimensions = null) { - return $this->addColumn('multipolygonz', $column); - } + $options = $dimensions ? compact('dimensions') : []; - /** - * Create a new generated, computed column on the table. - * - * @param string $column - * @param string $expression - * @return \Illuminate\Database\Schema\ColumnDefinition - */ - public function computed($column, $expression) - { - return $this->addColumn('computed', $column, compact('expression')); + return $this->addColumn('vector', $column, $options); } /** @@ -1434,16 +1523,17 @@ public function computed($column, $expression) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function morphs($name, $indexName = null) + public function morphs($name, $indexName = null, $after = null) { if (Builder::$defaultMorphKeyType === 'uuid') { - $this->uuidMorphs($name, $indexName); + $this->uuidMorphs($name, $indexName, $after); } elseif (Builder::$defaultMorphKeyType === 'ulid') { - $this->ulidMorphs($name, $indexName); + $this->ulidMorphs($name, $indexName, $after); } else { - $this->numericMorphs($name, $indexName); + $this->numericMorphs($name, $indexName, $after); } } @@ -1452,16 +1542,17 @@ public function morphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function nullableMorphs($name, $indexName = null) + public function nullableMorphs($name, $indexName = null, $after = null) { if (Builder::$defaultMorphKeyType === 'uuid') { - $this->nullableUuidMorphs($name, $indexName); + $this->nullableUuidMorphs($name, $indexName, $after); } elseif (Builder::$defaultMorphKeyType === 'ulid') { - $this->nullableUlidMorphs($name, $indexName); + $this->nullableUlidMorphs($name, $indexName, $after); } else { - $this->nullableNumericMorphs($name, $indexName); + $this->nullableNumericMorphs($name, $indexName, $after); } } @@ -1470,13 +1561,16 @@ public function nullableMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function numericMorphs($name, $indexName = null) + public function numericMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type"); + $this->string("{$name}_type") + ->after($after); - $this->unsignedBigInteger("{$name}_id"); + $this->unsignedBigInteger("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } @@ -1486,13 +1580,18 @@ public function numericMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function nullableNumericMorphs($name, $indexName = null) + public function nullableNumericMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type")->nullable(); + $this->string("{$name}_type") + ->nullable() + ->after($after); - $this->unsignedBigInteger("{$name}_id")->nullable(); + $this->unsignedBigInteger("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } @@ -1502,13 +1601,16 @@ public function nullableNumericMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function uuidMorphs($name, $indexName = null) + public function uuidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type"); + $this->string("{$name}_type") + ->after($after); - $this->uuid("{$name}_id"); + $this->uuid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } @@ -1518,13 +1620,18 @@ public function uuidMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function nullableUuidMorphs($name, $indexName = null) + public function nullableUuidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type")->nullable(); + $this->string("{$name}_type") + ->nullable() + ->after($after); - $this->uuid("{$name}_id")->nullable(); + $this->uuid("{$name}_id") + ->nullable() + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } @@ -1534,13 +1641,16 @@ public function nullableUuidMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function ulidMorphs($name, $indexName = null) + public function ulidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type"); + $this->string("{$name}_type") + ->after($after); - $this->ulid("{$name}_id"); + $this->ulid("{$name}_id") + ->after(! is_null($after) ? "{$name}_type" : null); $this->index(["{$name}_type", "{$name}_id"], $indexName); } @@ -1550,19 +1660,24 @@ public function ulidMorphs($name, $indexName = null) * * @param string $name * @param string|null $indexName + * @param string|null $after * @return void */ - public function nullableUlidMorphs($name, $indexName = null) + public function nullableUlidMorphs($name, $indexName = null, $after = null) { - $this->string("{$name}_type")->nullable(); + $this->string("{$name}_type") + ->nullable() + ->after($after); - $this->ulid("{$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 */ @@ -1571,6 +1686,18 @@ public function rememberToken() return $this->string('remember_token', 100)->nullable(); } + /** + * 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. * @@ -1583,15 +1710,16 @@ public function comment($comment) } /** - * Add a new index command to the blueprint. + * 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; @@ -1601,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') ); } @@ -1636,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); } @@ -1666,6 +1802,10 @@ protected function addColumnDefinition($definition) { $this->columns[] = $definition; + if (! $this->creating()) { + $this->commands[] = $definition; + } + if ($this->after) { $definition->after($this->after); @@ -1679,7 +1819,7 @@ protected function addColumnDefinition($definition) * Add the columns from the callback after the given column. * * @param string $column - * @param \Closure $callback + * @param (\Closure(self): void) $callback * @return void */ public function after($column, Closure $callback) @@ -1703,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; } @@ -1742,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. * @@ -1762,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. * @@ -1777,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() @@ -1787,32 +1965,10 @@ public function getChangedColumns() } /** - * Determine if the blueprint has auto-increment columns. - * - * @return bool + * Get the default time precision. */ - public function hasAutoIncrementColumn() + protected function defaultTimePrecision(): ?int { - return ! is_null(collect($this->getAddedColumns())->first(function ($column) { - return $column->autoIncrement === true; - })); - } - - /** - * Get the auto-increment column starting values. - * - * @return array - */ - public function autoIncrementingStartingValues() - { - if (! $this->hasAutoIncrementColumn()) { - return []; - } - - return collect($this->getAddedColumns())->mapWithKeys(function ($column) { - return $column->autoIncrement === true - ? [$column->name => $column->get('startingValue', $column->get('from'))] - : [$column->name => null]; - })->filter()->all(); + 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 176432c33a..180e647743 100755 --- a/Schema/Builder.php +++ b/Schema/Builder.php @@ -5,11 +5,16 @@ use Closure; 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. * @@ -27,21 +32,26 @@ 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|null + * @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 string + * @var 'int'|'uuid'|'ulid' */ public static $defaultMorphKeyType = 'int'; @@ -49,7 +59,6 @@ class Builder * Create a new database Schema manager. * * @param \Illuminate\Database\Connection $connection - * @return void */ public function __construct(Connection $connection) { @@ -60,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) @@ -68,6 +77,14 @@ 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. * @@ -92,7 +109,7 @@ public static function defaultMorphKeyType(string $type) */ public static function morphUsingUuids() { - return static::defaultMorphKeyType('uuid'); + static::defaultMorphKeyType('uuid'); } /** @@ -102,7 +119,7 @@ public static function morphUsingUuids() */ public static function morphUsingUlids() { - return static::defaultMorphKeyType('ulid'); + static::defaultMorphKeyType('ulid'); } /** @@ -110,12 +127,12 @@ public static function morphUsingUlids() * * @param string $name * @return bool - * - * @throws \LogicException */ public function createDatabase($name) { - throw new LogicException('This database driver does not support creating databases.'); + return $this->connection->statement( + $this->grammar->compileCreateDatabase($name) + ); } /** @@ -123,12 +140,24 @@ public function createDatabase($name) * * @param string $name * @return bool - * - * @throws \LogicException */ public function dropDatabaseIfExists($name) { - throw new LogicException('This database driver does not support dropping databases.'); + 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()) + ); } /** @@ -139,11 +168,96 @@ public function dropDatabaseIfExists($name) */ 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)) + ); } /** @@ -156,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)) ); } @@ -164,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)) { @@ -210,33 +324,164 @@ public function whenTableDoesntHaveColumn(string $table, string $column, Closure } } + /** + * 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'); + } - return $this->connection->getPostProcessor()->processColumnListing($results); + /** + * Get the columns for a given table. + * + * @param string $table + * @return list + */ + 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) + ) + ); + } + + /** + * 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) + ) + ); } /** @@ -297,7 +542,7 @@ public function dropIfExists($table) * Drop columns from a table schema. * * @param string $table - * @param string|array $columns + * @param string|array $columns * @return void */ public function dropColumns($table, $columns) @@ -343,18 +588,6 @@ public function dropAllTypes() throw new LogicException('This database driver does not support dropping all types.'); } - /** - * Get all of the table names for the database. - * - * @return void - * - * @throws \LogicException - */ - public function getAllTables() - { - throw new LogicException('This database driver does not support getting all tables.'); - } - /** * Rename a table on the schema. * @@ -393,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. * @@ -401,7 +687,7 @@ public function disableForeignKeyConstraints() */ protected function build(Blueprint $blueprint) { - $blueprint->build($this->connection, $this->grammar); + $blueprint->build(); } /** @@ -411,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 Container::getInstance()->make(Blueprint::class, compact('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; + } + + /** + * 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); - return $this; + 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 51265ac421..05618db57c 100644 --- a/Schema/ColumnDefinition.php +++ b/Schema/ColumnDefinition.php @@ -10,27 +10,30 @@ * @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 (MySQL/PostgreSQL/SQL Server) + * @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 generatedAs(string|Expression $expression = null) Create a SQL compliant identity column (PostgreSQL) - * @method $this index(string $indexName = null) Add an index + * @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() Add a primary index - * @method $this fulltext(string $indexName = null) Add a fulltext index - * @method $this spatialIndex(string $indexName = null) Add a spatial index + * @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 $expression) Create a stored generated column (MySQL/PostgreSQL/SQLite) + * @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(string $indexName = null) Add a unique index + * @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 $expression) Create a virtual generated column (MySQL/PostgreSQL/SQLite) + * @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 index 354b248d29..2501bebe68 100644 --- a/Schema/ForeignIdColumnDefinition.php +++ b/Schema/ForeignIdColumnDefinition.php @@ -2,7 +2,7 @@ namespace Illuminate\Database\Schema; -use Illuminate\Support\Str; +use Illuminate\Support\Stringable; class ForeignIdColumnDefinition extends ColumnDefinition { @@ -18,7 +18,6 @@ class ForeignIdColumnDefinition extends ColumnDefinition * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param array $attributes - * @return void */ public function __construct(Blueprint $blueprint, $attributes = []) { @@ -31,22 +30,27 @@ public function __construct(Blueprint $blueprint, $attributes = []) * Create a foreign key constraint on this column referencing the "id" column of the conventionally related table. * * @param string|null $table - * @param string $column + * @param string|null $column + * @param string|null $indexName * @return \Illuminate\Database\Schema\ForeignKeyDefinition */ - public function constrained($table = null, $column = 'id') + public function constrained($table = null, $column = null, $indexName = null) { - return $this->references($column)->on($table ?? Str::of($this->name)->beforeLast('_'.$column)->plural()); + $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) + public function references($column, $indexName = null) { - return $this->blueprint->foreign($this->name)->references($column); + return $this->blueprint->foreign($this->name, $indexName)->references($column); } } diff --git a/Schema/ForeignKeyDefinition.php b/Schema/ForeignKeyDefinition.php index a03fcff777..cd17896560 100644 --- a/Schema/ForeignKeyDefinition.php +++ b/Schema/ForeignKeyDefinition.php @@ -7,10 +7,11 @@ /** * @method ForeignKeyDefinition deferrable(bool $value = true) Set the foreign key as deferrable (PostgreSQL) * @method ForeignKeyDefinition initiallyImmediate(bool $value = true) Set the default time to check the constraint (PostgreSQL) + * @method ForeignKeyDefinition lock(('none'|'shared'|'default'|'exclusive') $value) Specify the DDL lock mode for the foreign key operation (MySQL) * @method ForeignKeyDefinition on(string $table) Specify the referenced table - * @method ForeignKeyDefinition onDelete(string $action) Add an ON DELETE action - * @method ForeignKeyDefinition onUpdate(string $action) Add an ON UPDATE action - * @method ForeignKeyDefinition references(string|array $columns) Specify the referenced column(s) + * @method ForeignKeyDefinition onDelete(('cascade'|'restrict'|'set null'|'no action') $action) Add an ON DELETE action + * @method ForeignKeyDefinition onUpdate(('cascade'|'restrict'|'set null'|'no action') $action) Add an ON UPDATE action + * @method ForeignKeyDefinition references(string|string[] $columns) Specify the referenced column(s) */ class ForeignKeyDefinition extends Fluent { @@ -34,6 +35,26 @@ 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. * @@ -63,4 +84,14 @@ 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 9579222991..0000000000 --- a/Schema/Grammars/ChangeColumn.php +++ /dev/null @@ -1,235 +0,0 @@ -isDoctrineAvailable()) { - throw new RuntimeException(sprintf( - 'Changing columns for table "%s" requires Doctrine DBAL. Please install the doctrine/dbal package.', - $blueprint->getTable() - )); - } - - $schema = $connection->getDoctrineSchemaManager(); - $databasePlatform = $schema->getDatabasePlatform(); - $databasePlatform->registerDoctrineTypeMapping('enum', 'string'); - - $tableDiff = static::getChangedDiff( - $grammar, $blueprint, $schema - ); - - if ($tableDiff !== false) { - return (array) $databasePlatform->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)); - continue; - } - - $column->setCustomSchemaOption($option, 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'], ['tinyText', 'text', 'mediumText', 'longText'])) { - $options['length'] = static::calculateDoctrineTextLength($fluent['type']); - } - - if ($fluent['type'] === 'char') { - $options['fixed'] = true; - } - - if (static::doesntNeedCharacterOptions($fluent['type'])) { - $options['customSchemaOptions'] = [ - 'collation' => '', - 'charset' => '', - ]; - } - - return $options; - } - - /** - * Get the doctrine column type. - * - * @param string $type - * @return \Doctrine\DBAL\Types\Type - */ - protected static function getDoctrineColumnType($type) - { - $type = strtolower($type); - - return Type::getType(match ($type) { - 'biginteger' => 'bigint', - 'smallinteger' => 'smallint', - 'tinytext', 'mediumtext', 'longtext' => 'text', - 'binary' => 'blob', - 'uuid' => 'guid', - 'char' => 'string', - 'double' => 'float', - default => $type, - }); - } - - /** - * Calculate the proper column length to force the Doctrine text type. - * - * @param string $type - * @return int - */ - protected static function calculateDoctrineTextLength($type) - { - return match ($type) { - 'tinyText' => 1, - 'mediumText' => 65535 + 1, - 'longText' => 16777215 + 1, - default => 255 + 1, - }; - } - - /** - * Determine if the given type does not need character / collation options. - * - * @param string $type - * @return bool - */ - protected static function doesntNeedCharacterOptions($type) - { - return in_array($type, [ - 'bigInteger', - 'binary', - 'boolean', - 'date', - 'dateTime', - 'decimal', - 'double', - 'float', - 'integer', - 'json', - 'mediumInteger', - 'smallInteger', - 'time', - 'timestamp', - 'tinyInteger', - ]); - } - - /** - * Get the matching Doctrine option for a given Fluent attribute name. - * - * @param string $attribute - * @return string|null - */ - protected static function mapFluentOptionToDoctrine($attribute) - { - return match ($attribute) { - 'type', 'name' => null, - 'nullable' => 'notnull', - 'total' => 'precision', - 'places' => 'scale', - default => $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 d446dd7dfb..5a1fd468e1 100755 --- a/Schema/Grammars/Grammar.php +++ b/Schema/Grammars/Grammar.php @@ -2,21 +2,27 @@ namespace Illuminate\Database\Schema\Grammars; -use Doctrine\DBAL\Schema\AbstractSchemaManager as SchemaManager; -use Doctrine\DBAL\Schema\TableDiff; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Concerns\CompilesJsonPaths; -use Illuminate\Database\Connection; use Illuminate\Database\Grammar as BaseGrammar; -use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Fluent; -use LogicException; 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. * @@ -35,27 +41,145 @@ abstract class Grammar extends BaseGrammar * Compile a create database command. * * @param string $name - * @param \Illuminate\Database\Connection $connection - * @return void - * - * @throws \LogicException + * @return string */ - public function compileCreateDatabase($name, $connection) + public function compileCreateDatabase($name) { - throw new LogicException('This database driver does not support creating databases.'); + 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 \LogicException + * @throws \RuntimeException */ - public function compileDropDatabaseIfExists($name) + 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 LogicException('This database driver does not support dropping databases.'); + throw new RuntimeException('This database driver does not support retrieving foreign keys.'); } /** @@ -63,12 +187,15 @@ public function compileDropDatabaseIfExists($name) * * @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) + ); } /** @@ -76,14 +203,13 @@ 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, Connection $connection) + public function compileChange(Blueprint $blueprint, Fluent $command) { - return ChangeColumn::compile($this, $blueprint, $command, $connection); + throw new RuntimeException('This database driver does not support modifying columns.'); } /** @@ -155,7 +281,21 @@ 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\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 @@ -165,17 +305,29 @@ protected function getColumns(Blueprint $blueprint) $columns = []; foreach ($blueprint->getAddedColumns() as $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); - - $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. * @@ -200,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. * @@ -220,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 @@ -231,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); } } @@ -249,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) { @@ -267,26 +461,27 @@ public function prefixArray($prefix, array $values) * Wrap a table in keyword identifiers. * * @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, ); } @@ -299,28 +494,16 @@ public function wrap($value, $prefixAlias = false) 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 c5259e1005..30b559eb6e 100755 --- a/Schema/Grammars/MySqlGrammar.php +++ b/Schema/Grammars/MySqlGrammar.php @@ -2,8 +2,10 @@ namespace Illuminate\Database\Schema\Grammars; -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; @@ -15,8 +17,8 @@ class MySqlGrammar extends Grammar * @var string[] */ protected $modifiers = [ - 'Unsigned', 'Charset', 'Collate', 'VirtualAs', 'StoredAs', 'Nullable', 'Invisible', - 'Srid', 'Default', 'Increment', 'Comment', 'After', 'First', + 'Unsigned', 'Charset', 'Collate', 'VirtualAs', 'StoredAs', 'Nullable', + 'Default', 'OnUpdate', 'Invisible', 'Increment', 'Comment', 'After', 'First', ]; /** @@ -26,55 +28,175 @@ class MySqlGrammar extends Grammar */ protected $serials = ['bigInteger', 'integer', 'mediumInteger', 'smallInteger', 'tinyInteger']; + /** + * The commands to be executed outside of create or alter commands. + * + * @var string[] + */ + protected $fluentCommands = ['AutoIncrementStartingValues']; + /** * Compile a create database command. * * @param string $name - * @param \Illuminate\Database\Connection $connection * @return string */ - public function compileCreateDatabase($name, $connection) + public function compileCreateDatabase($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 schemas. + * + * @return string + */ + public function compileSchemas() + { + 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( - 'create database %s default character set %s default collate %s', - $this->wrapValue($name), - $this->wrapValue($connection->getConfig('charset')), - $this->wrapValue($connection->getConfig('collation')), + '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 a drop database if exists command. + * Compile the query to determine the tables. * - * @param string $name + * @param string|string[]|null $schema * @return string */ - public function compileDropDatabaseIfExists($name) + public function compileTables($schema) { return sprintf( - 'drop database if exists %s', - $this->wrapValue($name) + '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 list of tables. + * Compile the query to determine the views. * + * @param string|string[]|null $schema * @return string */ - public function compileTableExists() + public function compileViews($schema) { - return "select * from information_schema.tables where table_schema = ? and table_name = ? and table_type = 'BASE TABLE'"; + 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 determine the list of columns. + * Compile the query to compare the schema. * + * @param string|string[]|null $schema + * @param string $column * @return string */ - public function compileColumnListing() + protected function compileSchemaWhereClause($schema, $column) { - return 'select column_name as `column_name` from information_schema.columns where table_schema = ? and table_name = ?'; + 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) + ); } /** @@ -82,28 +204,25 @@ public function compileColumnListing() * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection - * @return array + * @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 array_values(array_filter(array_merge([$this->compileCreateEngine( - $sql, $connection, $blueprint - )], $this->compileAutoIncrementStartingValues($blueprint)))); + return $this->compileCreateEngine($sql, $blueprint); } /** @@ -111,34 +230,44 @@ 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 array + * @return string */ - protected function compileCreateTable($blueprint, $command, $connection) + protected function compileCreateTable($blueprint, $command) { - return trim(sprintf('%s table %s (%s)', + $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) + ); } /** * 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; } @@ -147,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}'"; } @@ -158,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; } @@ -178,29 +306,116 @@ protected function compileCreateEngine($sql, Connection $connection, 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', $this->getColumns($blueprint)); - - return array_values(array_merge( - ['alter table '.$this->wrapTable($blueprint).' '.implode(', ', $columns)], - $this->compileAutoIncrementStartingValues($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 : '' + ); } /** * Compile the auto-incrementing column starting values. * * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @return array + * @param \Illuminate\Support\Fluent $command + * @return string */ - public function compileAutoIncrementStartingValues(Blueprint $blueprint) + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command) { - return collect($blueprint->autoIncrementingStartingValues())->map(function ($value, $column) use ($blueprint) { - return 'alter table '.$this->wrapTable($blueprint->getTable()).' auto_increment = '.$value; - })->all(); + 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; } /** @@ -212,9 +427,12 @@ public function compileAutoIncrementStartingValues(Blueprint $blueprint) */ 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 : '' + ); } /** @@ -275,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 : '' ); } @@ -319,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; } /** @@ -386,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. * @@ -433,43 +680,23 @@ 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)); - } - - /** - * Compile the SQL needed to retrieve all table names. - * - * @return string - */ - public function compileGetAllTables() - { - return 'SHOW FULL TABLES WHERE table_type = \'BASE TABLE\''; - } - - /** - * Compile the SQL needed to retrieve all view names. - * - * @return string - */ - public function compileGetAllViews() - { - return 'SHOW FULL TABLES WHERE table_type = \'VIEW\''; + return 'drop view '.implode(', ', $this->escapeNames($views)); } /** @@ -507,6 +734,20 @@ public function compileTableComment(Blueprint $blueprint, Fluent $command) ); } + /** + * 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 + ); + } + /** * Create the column definition for a char type. * @@ -636,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'; } /** @@ -647,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'; } @@ -728,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'; } @@ -739,13 +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'; - $columnType = $column->useCurrent ? "$columnType default $current" : $columnType; + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } - return $column->useCurrentOnUpdate ? "$columnType on update $current" : $columnType; + return $column->precision ? "datetime($column->precision)" : 'datetime'; } /** @@ -789,13 +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'; - $columnType = $column->useCurrent ? "$columnType default $current" : $columnType; + if ($column->useCurrent) { + $column->default(new Expression($current)); + } + + if ($column->useCurrentOnUpdate) { + $column->onUpdate(new Expression($current)); + } - return $column->useCurrentOnUpdate ? "$columnType on update $current" : $columnType; + return $column->precision ? "timestamp($column->precision)" : 'timestamp'; } /** @@ -817,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'; } @@ -828,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'; } @@ -870,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'; - } + $subtype = $column->subtype ? strtolower($column->subtype) : null; - /** - * Create the column definition for a spatial Point type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePoint(Fluent $column) - { - return 'point'; - } - - /** - * 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'; } /** @@ -983,7 +1216,7 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) } if (! is_null($virtualAs = $column->virtualAs)) { - return " as ({$virtualAs})"; + return " as ({$this->getValue($virtualAs)})"; } } @@ -1005,7 +1238,7 @@ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) } if (! is_null($storedAs = $column->storedAs)) { - return " as ({$storedAs}) stored"; + return " as ({$this->getValue($storedAs)}) stored"; } } @@ -1100,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. * @@ -1110,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'; } } @@ -1156,20 +1405,6 @@ protected function modifyComment(Blueprint $blueprint, Fluent $column) } } - /** - * Get the SQL for a SRID column modifier. - * - * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @param \Illuminate\Support\Fluent $column - * @return string|null - */ - protected function modifySrid(Blueprint $blueprint, Fluent $column) - { - if (is_int($column->srid) && $column->srid > 0) { - return ' srid '.$column->srid; - } - } - /** * Wrap a single string in keyword identifiers. * diff --git a/Schema/Grammars/PostgresGrammar.php b/Schema/Grammars/PostgresGrammar.php index f425feb0fd..4a1660a20e 100755 --- a/Schema/Grammars/PostgresGrammar.php +++ b/Schema/Grammars/PostgresGrammar.php @@ -2,8 +2,11 @@ namespace Illuminate\Database\Schema\Grammars; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Collection; use Illuminate\Support\Fluent; +use LogicException; class PostgresGrammar extends Grammar { @@ -19,7 +22,7 @@ class PostgresGrammar extends Grammar * * @var string[] */ - protected $modifiers = ['Collate', 'Increment', 'Nullable', 'Default', 'VirtualAs', 'StoredAs']; + protected $modifiers = ['Collate', 'Nullable', 'Default', 'VirtualAs', 'StoredAs', 'GeneratedAs', 'Increment']; /** * The columns available as serials. @@ -33,56 +36,195 @@ class PostgresGrammar extends Grammar * * @var string[] */ - protected $fluentCommands = ['Comment']; + protected $fluentCommands = ['AutoIncrementStartingValues', 'Comment']; /** * Compile a create database command. * * @param string $name - * @param \Illuminate\Database\Connection $connection * @return string */ - public function compileCreateDatabase($name, $connection) + 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 + */ + public function compileTableExists($schema, $table) { return sprintf( - 'create database %s encoding %s', - $this->wrapValue($name), - $this->wrapValue($connection->getConfig('charset')), + '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 a drop database if exists command. + * Compile the query to determine the tables. * - * @param string $name + * @param string|string[]|null $schema * @return string */ - public function compileDropDatabaseIfExists($name) + public function compileTables($schema) + { + 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 views. + * + * @param string|string[]|null $schema + * @return string + */ + public function compileViews($schema) + { + 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( - 'drop database if exists %s', - $this->wrapValue($name) + '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 if a table exists. + * 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 information_schema.tables where table_catalog = ? and table_schema = ? and table_name = ? and table_type = 'BASE 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 list of columns. + * Compile the query to determine the foreign keys. * + * @param string|null $schema + * @param string $table * @return string */ - public function compileColumnListing() + public function compileForeignKeys($schema, $table) { - return 'select column_name from information_schema.columns where table_catalog = ? and table_schema = ? and table_name = ?'; + 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()' + ); } /** @@ -90,15 +232,15 @@ public function compileColumnListing() * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @return array + * @return string */ public function compileCreate(Blueprint $blueprint, Fluent $command) { - return array_values(array_filter(array_merge([sprintf('%s table %s (%s)', + return sprintf('%s table %s (%s)', $blueprint->temporary ? 'create temporary' : 'create', $this->wrapTable($blueprint), implode(', ', $this->getColumns($blueprint)) - )], $this->compileAutoIncrementStartingValues($blueprint)))); + ); } /** @@ -110,23 +252,57 @@ public function compileCreate(Blueprint $blueprint, Fluent $command) */ public function compileAdd(Blueprint $blueprint, Fluent $command) { - return array_values(array_filter(array_merge([sprintf('alter table %s %s', + return sprintf('alter table %s add column %s', $this->wrapTable($blueprint), - implode(', ', $this->prefixArray('add column', $this->getColumns($blueprint))) - )], $this->compileAutoIncrementStartingValues($blueprint)))); + $this->getColumn($blueprint, $command->column) + ); } /** * Compile the auto-incrementing column starting values. * * @param \Illuminate\Database\Schema\Blueprint $blueprint - * @return array + * @param \Illuminate\Support\Fluent $command + * @return string */ - public function compileAutoIncrementStartingValues(Blueprint $blueprint) + public function compileAutoIncrementStartingValues(Blueprint $blueprint, Fluent $command) { - return collect($blueprint->autoIncrementingStartingValues())->map(function ($value, $column) use ($blueprint) { - return 'alter sequence '.$blueprint->getTable().'_'.$column.'_seq restart with '.$value; - })->all(); + 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('alter column '.$this->wrap($column), $changes)) + ); } /** @@ -148,15 +324,39 @@ 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) { - $sql = 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'; @@ -166,7 +366,7 @@ public function compileUnique(Blueprint $blueprint, Fluent $command) $sql .= $command->initiallyImmediate ? ' initially immediate' : ' initially deferred'; } - return $sql; + return isset($createIndexSql) ? [$createIndexSql, $sql] : [$sql]; } /** @@ -178,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 : '', @@ -203,7 +404,8 @@ public function compileFulltext(Blueprint $blueprint, Fluent $command) return "to_tsvector({$this->quoteString($language)}, {$this->wrap($column)})"; }, $command->columns); - return sprintf('create index %s on %s using gin ((%s))', + return sprintf('create index %s%s on %s using gin ((%s))', + $command->online ? 'concurrently ' : '', $this->wrap($command->index), $this->wrapTable($blueprint), implode(' || ', $columns) @@ -221,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. * @@ -277,66 +529,45 @@ public function compileDropIfExists(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->escapeNames($tables)).' cascade'; + return 'drop table '.implode(', ', $this->escapeNames($tables)).' cascade'; } /** * 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->escapeNames($views)).' cascade'; + return 'drop view '.implode(', ', $this->escapeNames($views)).' cascade'; } /** * Compile the SQL needed to drop all types. * - * @param array $types + * @param array $types * @return string */ public function compileDropAllTypes($types) { - return 'drop type '.implode(',', $this->escapeNames($types)).' cascade'; + return 'drop type '.implode(', ', $this->escapeNames($types)).' cascade'; } /** - * Compile the SQL needed to retrieve all table names. + * Compile the SQL needed to drop all domains. * - * @param string|array $searchPath + * @param array $domains * @return string */ - public function compileGetAllTables($searchPath) + public function compileDropAllDomains($domains) { - return "select tablename, concat('\"', schemaname, '\".\"', tablename, '\"') as qualifiedname from pg_catalog.pg_tables where schemaname in ('".implode("','", (array) $searchPath)."')"; - } - - /** - * Compile the SQL needed to retrieve all view names. - * - * @param string|array $searchPath - * @return string - */ - public function compileGetAllViews($searchPath) - { - return "select viewname, concat('\"', schemaname, '\".\"', viewname, '\"') as qualifiedname from pg_catalog.pg_views where schemaname in ('".implode("','", (array) $searchPath)."')"; - } - - /** - * Compile the SQL needed to retrieve all type names. - * - * @return string - */ - public function compileGetAllTypes() - { - return 'select distinct pg_type.typname from pg_type inner join pg_enum on pg_enum.enumtypid = pg_type.oid'; + return 'drop domain '.implode(', ', $this->escapeNames($domains)).' cascade'; } /** @@ -362,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}"; } @@ -489,11 +721,13 @@ public function compileDisableForeignKeyConstraints() */ public function compileComment(Blueprint $blueprint, Fluent $command) { - return sprintf('comment on column %s.%s is %s', - $this->wrapTable($blueprint), - $this->wrap($command->column->name), - "'".str_replace("'", "''", $command->value)."'" - ); + 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)."'" + ); + } } /** @@ -514,16 +748,15 @@ public function compileTableComment(Blueprint $blueprint, Fluent $command) /** * Quote-escape the given tables, views, or types. * - * @param array $names - * @return array + * @param array $names + * @return array */ public function escapeNames($names) { - return array_map(static function ($name) { - return '"'.collect(explode('.', $name)) - ->map(fn ($segment) => trim($segment, '\'"')) - ->implode('"."').'"'; - }, $names); + return array_map( + fn ($name) => (new Collection(explode('.', $name)))->map($this->wrapValue(...))->implode('.'), + $names + ); } /** @@ -608,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'; } /** @@ -619,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'; } /** @@ -630,7 +863,7 @@ protected function typeBigInteger(Fluent $column) */ protected function typeMediumInteger(Fluent $column) { - return $this->generatableColumn('integer', $column); + return $this->typeInteger($column); } /** @@ -641,7 +874,7 @@ protected function typeMediumInteger(Fluent $column) */ protected function typeTinyInteger(Fluent $column) { - return $this->generatableColumn('smallint', $column); + return $this->typeSmallInteger($column); } /** @@ -652,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'; } /** @@ -698,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'; } /** @@ -790,6 +992,10 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + return 'date'; } @@ -845,9 +1051,11 @@ protected function typeTimeTz(Fluent $column) */ protected function typeTimestamp(Fluent $column) { - $columnType = 'timestamp'.(is_null($column->precision) ? '' : "($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'; } /** @@ -858,9 +1066,11 @@ protected function typeTimestamp(Fluent $column) */ protected function typeTimestampTz(Fluent $column) { - $columnType = 'timestamp'.(is_null($column->precision) ? '' : "($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'; } /** @@ -871,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); } @@ -926,115 +1140,45 @@ protected function typeMacAddress(Fluent $column) */ protected function typeGeometry(Fluent $column) { - return $this->formatPostGisType('geometry', $column); - } - - /** - * 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', $column); - } - - /** - * Create the column definition for a spatial LineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typeLineString(Fluent $column) - { - return $this->formatPostGisType('linestring', $column); - } - - /** - * 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', $column); - } - - /** - * Create the column definition for a spatial GeometryCollection type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typeGeometryCollection(Fluent $column) - { - return $this->formatPostGisType('geometrycollection', $column); - } - - /** - * Create the column definition for a spatial MultiPoint type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typeMultiPoint(Fluent $column) - { - return $this->formatPostGisType('multipoint', $column); - } + if ($column->subtype) { + return sprintf('geometry(%s%s)', + strtolower($column->subtype), + $column->srid ? ','.$column->srid : '' + ); + } - /** - * Create the column definition for a spatial MultiLineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiLineString(Fluent $column) - { - return $this->formatPostGisType('multilinestring', $column); + 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 */ - protected function typeMultiPolygon(Fluent $column) + protected function typeGeography(Fluent $column) { - return $this->formatPostGisType('multipolygon', $column); - } + if ($column->subtype) { + return sprintf('geography(%s%s)', + strtolower($column->subtype), + $column->srid ? ','.$column->srid : '' + ); + } - /** - * Create the column definition for a spatial MultiPolygonZ type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - protected function typeMultiPolygonZ(Fluent $column) - { - return $this->formatPostGisType('multipolygonz', $column); + return 'geography'; } /** - * Format the column definition for a PostGIS spatial type. + * Create the column definition for a vector type. * - * @param string $type * @param \Illuminate\Support\Fluent $column * @return string */ - private function formatPostGisType($type, Fluent $column) + protected function typeVector(Fluent $column) { - if ($column->isGeometry === null) { - return sprintf('geography(%s, %s)', $type, $column->projection ?? '4326'); - } - - if ($column->projection !== null) { - return sprintf('geometry(%s, %s)', $type, $column->projection); - } - - return "geometry({$type})"; + return isset($column->dimensions) && $column->dimensions !== '' + ? "vector({$column->dimensions})" + : 'vector'; } /** @@ -1060,6 +1204,10 @@ protected function modifyCollate(Blueprint $blueprint, Fluent $column) */ protected function modifyNullable(Blueprint $blueprint, Fluent $column) { + if ($column->change) { + return $column->nullable ? 'drop not null' : 'set not null'; + } + return $column->nullable ? ' null' : ' not null'; } @@ -1072,6 +1220,14 @@ protected function modifyNullable(Blueprint $blueprint, Fluent $column) */ protected function modifyDefault(Blueprint $blueprint, Fluent $column) { + 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); } @@ -1086,7 +1242,10 @@ protected function modifyDefault(Blueprint $blueprint, Fluent $column) */ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) { - if ((in_array($column->type, $this->serials) || ($column->generatedAs !== null)) && $column->autoIncrement) { + if (! $column->change + && ! $this->hasCommand($blueprint, 'primary') + && (in_array($column->type, $this->serials) || ($column->generatedAs !== null)) + && $column->autoIncrement) { return ' primary key'; } } @@ -1097,11 +1256,23 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column * @return string|null + * + * @throws \LogicException */ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) { - if ($column->virtualAs !== null) { - return " generated always as ({$column->virtualAs})"; + 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"; } } @@ -1111,11 +1282,55 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $column * @return string|null + * + * @throws \LogicException */ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) { - if ($column->storedAs !== null) { - return " generated always as ({$column->storedAs}) stored"; + 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 identity column modifier. + * + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $column + * @return string|list|null + */ + protected function modifyGeneratedAs(Blueprint $blueprint, Fluent $column) + { + $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 0db0c507e4..0000000000 --- a/Schema/Grammars/RenameColumn.php +++ /dev/null @@ -1,84 +0,0 @@ -getDoctrineSchemaManager(); - $databasePlatform = $schema->getDatabasePlatform(); - $databasePlatform->registerDoctrineTypeMapping('enum', 'string'); - - $column = $connection->getDoctrineColumn( - $grammar->getTablePrefix().$blueprint->getTable(), $command->from - ); - - return (array) $databasePlatform->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(), self::getWritableColumnOptions($column)), - ]; - - return $tableDiff; - } - - /** - * Get the writable column options. - * - * @param \Doctrine\DBAL\Schema\Column $column - * @return array - */ - private static function getWritableColumnOptions(Column $column) - { - return array_filter($column->toArray(), function (string $name) use ($column) { - return method_exists($column, 'set'.$name); - }, ARRAY_FILTER_USE_KEY); - } -} diff --git a/Schema/Grammars/SQLiteGrammar.php b/Schema/Grammars/SQLiteGrammar.php old mode 100755 new mode 100644 index 9bb6fd8fa0..c472266c43 --- a/Schema/Grammars/SQLiteGrammar.php +++ b/Schema/Grammars/SQLiteGrammar.php @@ -2,10 +2,11 @@ namespace Illuminate\Database\Schema\Grammars; -use Doctrine\DBAL\Schema\Index; -use Illuminate\Database\Connection; +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 RuntimeException; @@ -16,7 +17,7 @@ class SQLiteGrammar extends Grammar * * @var string[] */ - protected $modifiers = ['VirtualAs', 'StoredAs', 'Nullable', 'Default', 'Increment']; + protected $modifiers = ['Increment', 'Nullable', 'Default', 'Collate', 'VirtualAs', 'StoredAs']; /** * The columns available as serials. @@ -26,24 +27,198 @@ class SQLiteGrammar extends Grammar 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,17 +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); - return collect($columns)->reject(function ($column) { - return preg_match('/as \(.*\) stored/', $column) > 0; - })->map(function ($column) use ($blueprint) { - return 'alter table '.$this->wrapTable($blueprint).' '.$column; - })->all(); + $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... + } + + /** + * 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... } /** @@ -153,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) ); } @@ -169,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) ); } @@ -195,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... } /** @@ -229,74 +479,74 @@ 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 retrieve all table names. + * Compile the SQL needed to rebuild the database. * + * @param string|null $schema * @return string */ - public function compileGetAllTables() + public function compileRebuild($schema = null) { - return 'select type, name from sqlite_master where type = \'table\' and name not like \'sqlite_%\''; + return sprintf('vacuum %s', + $this->wrapValue($schema ?? 'main') + ); } /** - * Compile the SQL needed to retrieve all view names. + * Compile a drop column command. * - * @return string + * @param \Illuminate\Database\Schema\Blueprint $blueprint + * @param \Illuminate\Support\Fluent $command + * @return list|null */ - public function compileGetAllViews() + public function compileDropColumn(Blueprint $blueprint, Fluent $command) { - return 'select type, name from sqlite_master where type = \'view\''; - } + if (version_compare($this->connection->getServerVersion(), '3.35', '<')) { + // Handled on table alteration... - /** - * Compile the SQL needed to rebuild the database. - * - * @return string - */ - public function compileRebuild() - { - return 'vacuum'; + return null; + } + + $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 column command. + * Compile a drop primary key command. * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection - * @return array + * @return string */ - public function compileDropColumn(Blueprint $blueprint, Fluent $command, Connection $connection) + public function compileDropPrimary(Blueprint $blueprint, Fluent $command) { - $tableDiff = $this->getDoctrineTableDiff( - $blueprint, $schema = $connection->getDoctrineSchemaManager() - ); - - foreach ($command->columns as $name) { - $tableDiff->removedColumns[$name] = $connection->getDoctrineColumn( - $this->getTablePrefix().$blueprint->getTable(), $name - ); - } - - return (array) $schema->getDatabasePlatform()->getAlterTableSQL($tableDiff); + // Handled on table alteration... } /** @@ -308,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); } /** @@ -322,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) + ); } /** @@ -341,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. * @@ -360,33 +629,38 @@ public function compileRename(Blueprint $blueprint, Fluent $command) * * @param \Illuminate\Database\Schema\Blueprint $blueprint * @param \Illuminate\Support\Fluent $command - * @param \Illuminate\Database\Connection $connection * @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']]) + ), ]; } @@ -397,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); } /** @@ -407,27 +681,22 @@ 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;'; - } - - /** - * Compile the SQL needed to disable a writable schema. - * - * @return string - */ - public function compileDisableWriteableSchema() - { - return 'PRAGMA writable_schema = 0;'; + return sprintf('pragma %s%s', + $key, + is_null($value) ? '' : ' = '.$value + ); } /** @@ -570,7 +839,7 @@ protected function typeFloat(Fluent $column) */ protected function typeDouble(Fluent $column) { - return 'float'; + return 'double'; } /** @@ -618,7 +887,7 @@ protected function typeEnum(Fluent $column) */ protected function typeJson(Fluent $column) { - return 'text'; + return $this->connection->getConfig('use_native_json') ? 'json' : 'text'; } /** @@ -629,7 +898,7 @@ protected function typeJson(Fluent $column) */ protected function typeJsonb(Fluent $column) { - return 'text'; + return $this->connection->getConfig('use_native_jsonb') ? 'jsonb' : 'text'; } /** @@ -640,6 +909,10 @@ protected function typeJsonb(Fluent $column) */ protected function typeDate(Fluent $column) { + if ($column->useCurrent) { + $column->default(new Expression('CURRENT_DATE')); + } + return 'date'; } @@ -699,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'; } /** @@ -721,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); } @@ -774,86 +1055,20 @@ 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'; - } - - /** - * Create the column definition for a spatial LineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeLineString(Fluent $column) - { - return 'linestring'; - } - - /** - * Create the column definition for a spatial Polygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typePolygon(Fluent $column) - { - return 'polygon'; - } - - /** - * 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'; - } - - /** - * Create the column definition for a spatial MultiPoint type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPoint(Fluent $column) - { - return 'multipoint'; - } - - /** - * Create the column definition for a spatial MultiLineString type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiLineString(Fluent $column) - { - return 'multilinestring'; - } - - /** - * Create the column definition for a spatial MultiPolygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPolygon(Fluent $column) - { - return 'multipolygon'; + return $this->typeGeometry($column); } /** @@ -887,7 +1102,7 @@ protected function modifyVirtualAs(Blueprint $blueprint, Fluent $column) } if (! is_null($virtualAs = $column->virtualAs)) { - return " as ({$virtualAs})"; + return " as ({$this->getValue($virtualAs)})"; } } @@ -909,7 +1124,7 @@ protected function modifyStoredAs(Blueprint $blueprint, Fluent $column) } if (! is_null($storedAs = $column->storedAs)) { - return " as ({$column->storedAs}) stored"; + return " as ({$this->getValue($column->storedAs)}) stored"; } } @@ -962,6 +1177,20 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) } } + /** + * 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. * diff --git a/Schema/Grammars/SqlServerGrammar.php b/Schema/Grammars/SqlServerGrammar.php index 5121d782ef..28b5e5a7a1 100755 --- a/Schema/Grammars/SqlServerGrammar.php +++ b/Schema/Grammars/SqlServerGrammar.php @@ -2,6 +2,7 @@ namespace Illuminate\Database\Schema\Grammars; +use Illuminate\Database\Query\Expression; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Fluent; @@ -19,7 +20,7 @@ class SqlServerGrammar extends Grammar * * @var string[] */ - protected $modifiers = ['Increment', 'Collate', 'Nullable', 'Default', 'Persisted']; + protected $modifiers = ['Collate', 'Nullable', 'Default', 'Persisted', 'Increment']; /** * The columns available as serials. @@ -29,53 +30,170 @@ class SqlServerGrammar extends Grammar protected $serials = ['tinyInteger', 'smallInteger', 'mediumInteger', 'integer', 'bigInteger']; /** - * Compile a create database command. + * The commands to be executed outside of create or alter command. * - * @param string $name - * @param \Illuminate\Database\Connection $connection + * @var string[] + */ + protected $fluentCommands = ['Default']; + + /** + * Compile the query to determine the schemas. + * + * @return string + */ + public function compileSchemas() + { + 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 if the given table exists. + * + * @param string|null $schema + * @param string $table * @return string */ - public function compileCreateDatabase($name, $connection) + public function compileTableExists($schema, $table) { return sprintf( - 'create database %s', - $this->wrapValue($name), + 'select (case when object_id(%s, \'U\') is null then 0 else 1 end) as [exists]', + $this->quoteString($schema ? $schema.'.'.$table : $table) ); } /** - * Compile a drop database if exists command. + * 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 $name + * @param string|string[]|null $schema * @return string */ - public function compileDropDatabaseIfExists($name) + 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( - 'drop database if exists %s', - $this->wrapValue($name) + '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 if a table exists. + * 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 sys.sysobjects where id = object_id(?) and xtype in ('U', 'V')"; + 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 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 "select name from sys.columns where object_id = object_id('$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()', + ); } /** @@ -87,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)) + ); } /** @@ -103,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. * @@ -132,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)' : '' ); } @@ -148,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)' : '' ); } @@ -171,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. * @@ -192,8 +352,8 @@ public function compileDrop(Blueprint $blueprint, Fluent $command) */ public function compileDropIfExists(Blueprint $blueprint, Fluent $command) { - return sprintf('if exists (select * from sys.sysobjects where id = object_id(%s, \'U\')) 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) ); } @@ -233,14 +393,17 @@ public function compileDropColumn(Blueprint $blueprint, Fluent $command) */ public function compileDropDefaultConstraint(Blueprint $blueprint, Fluent $command) { - $columns = "'".implode("','", $command->columns)."'"; + $columns = $command->name === 'change' + ? "'".$command->column->name."'" + : "'".implode("', '", $command->columns)."'"; - $tableName = $this->getTablePrefix().$blueprint->getTable(); + $table = $this->wrapTable($blueprint); + $tableName = $this->quoteString($this->wrapTable($blueprint)); $sql = "DECLARE @sql NVARCHAR(MAX) = '';"; - $sql .= "SELECT @sql += 'ALTER TABLE [dbo].[{$tableName}] DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' "; + $sql .= "SELECT @sql += 'ALTER TABLE $table DROP CONSTRAINT ' + OBJECT_NAME([default_object_id]) + ';' "; $sql .= 'FROM sys.columns '; - $sql .= "WHERE [object_id] = OBJECT_ID('[dbo].[{$tableName}]') AND [name] in ({$columns}) AND [default_object_id] <> 0;"; + $sql .= "WHERE [object_id] = OBJECT_ID($tableName) AND [name] in ($columns) AND [default_object_id] <> 0;"; $sql .= 'EXEC(@sql)'; return $sql; @@ -323,9 +486,10 @@ 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) + ); } /** @@ -337,8 +501,8 @@ public function compileRename(Blueprint $blueprint, Fluent $command) */ 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) ); } @@ -393,26 +557,6 @@ public function compileDropAllViews() EXEC sp_executesql @sql;"; } - /** - * Compile the SQL needed to retrieve all table names. - * - * @return string - */ - public function compileGetAllTables() - { - return "select name, type from sys.tables where type = 'U'"; - } - - /** - * Compile the SQL needed to retrieve all view names. - * - * @return string - */ - public function compileGetAllViews() - { - return "select name, type from sys.objects where type = 'V'"; - } - /** * Create the column definition for a char type. * @@ -542,6 +686,10 @@ protected function typeSmallInteger(Fluent $column) */ protected function typeFloat(Fluent $column) { + if ($column->precision) { + return "float({$column->precision})"; + } + return 'float'; } @@ -553,7 +701,7 @@ protected function typeFloat(Fluent $column) */ protected function typeDouble(Fluent $column) { - return 'float'; + return 'double precision'; } /** @@ -623,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'; } @@ -678,9 +830,11 @@ 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'; } /** @@ -693,9 +847,11 @@ protected function typeTimestamp(Fluent $column) */ 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'; } /** @@ -706,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); } @@ -717,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)'; } @@ -759,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) + protected function typeGeometry(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'; + return 'geometry'; } /** - * 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) - { - 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) - { - return 'geography'; - } - - /** - * Create the column definition for a spatial MultiPolygon type. - * - * @param \Illuminate\Support\Fluent $column - * @return string - */ - public function typeMultiPolygon(Fluent $column) + protected function typeGeography(Fluent $column) { return 'geography'; } @@ -849,7 +947,7 @@ public function typeMultiPolygon(Fluent $column) */ protected function typeComputed(Fluent $column) { - return "as ({$column->expression})"; + return "as ({$this->getValue($column->expression)})"; } /** @@ -889,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); } } @@ -903,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'; } } @@ -917,30 +1015,23 @@ protected function modifyIncrement(Blueprint $blueprint, Fluent $column) */ protected function modifyPersisted(Blueprint $blueprint, Fluent $column) { - if ($column->persisted) { - return ' persisted'; - } - } + if ($column->change) { + if ($column->type === 'computed') { + return $column->persisted ? ' add persisted' : ' drop persisted'; + } - /** - * Wrap a table in keyword identifiers. - * - * @param \Illuminate\Database\Query\Expression|string $table - * @return string - */ - public function wrapTable($table) - { - if ($table instanceof Blueprint && $table->temporary) { - $this->setTablePrefix('#'); + return null; } - return parent::wrapTable($table); + if ($column->persisted) { + return ' persisted'; + } } /** * Quote the given string literal. * - * @param string|array $value + * @param string|array $value * @return string */ public function quoteString($value) diff --git a/Schema/IndexDefinition.php b/Schema/IndexDefinition.php index fc5d78e5b9..2ebe07a0ea 100644 --- a/Schema/IndexDefinition.php +++ b/Schema/IndexDefinition.php @@ -6,9 +6,12 @@ /** * @method $this algorithm(string $algorithm) Specify an algorithm for the index (MySQL/PostgreSQL) - * @method $this language(string $language) Specify a language for the full text index (PostgreSQL) * @method $this deferrable(bool $value = true) Specify that the unique index is deferrable (PostgreSQL) * @method $this initiallyImmediate(bool $value = true) Specify the default time to check the unique index constraint (PostgreSQL) + * @method $this language(string $language) Specify a language for the full text index (PostgreSQL) + * @method $this lock(('none'|'shared'|'default'|'exclusive') $value) Specify the DDL lock mode for the index operation (MySQL) + * @method $this nullsNotDistinct(bool $value = true) Specify that the null values should not be treated as distinct (PostgreSQL) + * @method $this online(bool $value = true) Specify that index creation should not lock the table (PostgreSQL/SqlServer) */ class IndexDefinition extends Fluent { diff --git a/Schema/MariaDbBuilder.php b/Schema/MariaDbBuilder.php new file mode 100755 index 0000000000..012befe380 --- /dev/null +++ b/Schema/MariaDbBuilder.php @@ -0,0 +1,8 @@ +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 bbb0763a72..6676411225 100755 --- a/Schema/MySqlBuilder.php +++ b/Schema/MySqlBuilder.php @@ -4,64 +4,6 @@ class MySqlBuilder extends Builder { - /** - * Create a database in the schema. - * - * @param string $name - * @return bool - */ - public function createDatabase($name) - { - return $this->connection->statement( - $this->grammar->compileCreateDatabase($name, $this->connection) - ); - } - - /** - * 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) - ); - } - - /** - * Determine if the given table exists. - * - * @param string $table - * @return bool - */ - public function hasTable($table) - { - $table = $this->connection->getTablePrefix().$table; - - return count($this->connection->selectFromWriteConnection( - $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->selectFromWriteConnection( - $this->grammar->compileColumnListing(), [$this->connection->getDatabaseName(), $table] - ); - - return $this->connection->getPostProcessor()->processColumnListing($results); - } - /** * Drop all tables from the database. * @@ -69,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; @@ -83,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(); + } } /** @@ -97,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; @@ -115,26 +47,12 @@ public function dropAllViews() } /** - * Get all of the table names for the database. - * - * @return array - */ - public function getAllTables() - { - return $this->connection->select( - $this->grammar->compileGetAllTables() - ); - } - - /** - * Get all of the view names for the database. + * Get the names of current schemas for the connection. * - * @return array + * @return string[]|null */ - public function getAllViews() + public function getCurrentSchemaListing() { - return $this->connection->select( - $this->grammar->compileGetAllViews() - ); + return [$this->connection->getDatabaseName()]; } } diff --git a/Schema/MySqlSchemaState.php b/Schema/MySqlSchemaState.php index 0cd3486e60..dbfa3ccaef 100644 --- a/Schema/MySqlSchemaState.php +++ b/Schema/MySqlSchemaState.php @@ -5,6 +5,7 @@ use Exception; use Illuminate\Database\Connection; use Illuminate\Support\Str; +use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Process; class MySqlSchemaState extends SchemaState @@ -26,7 +27,9 @@ public function dump(Connection $connection, $path) $this->removeAutoIncrementingState($path); - $this->appendMigrationData($path); + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } } /** @@ -53,7 +56,7 @@ protected function removeAutoIncrementingState(string $path) protected function appendMigrationData(string $path) { $process = $this->executeDumpProcess($this->makeProcess( - $this->baseDumpCommand().' '.$this->migrationTable.' --no-create-info --skip-extended-insert --skip-routines --compact' + $this->baseDumpCommand().' '.$this->getMigrationTable().' --no-create-info --skip-extended-insert --skip-routines --compact --complete-insert' ), null, array_merge($this->baseVariables($this->connection->getConfig()), [ // ])); @@ -69,7 +72,9 @@ protected function appendMigrationData(string $path) */ public function load($path) { - $command = 'mysql '.$this->connectionString().' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; + $versionInfo = $this->detectClientVersion(); + + $command = 'mysql '.$this->connectionString($versionInfo).' --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"'; $process = $this->makeProcess($command)->setTimeout(null); @@ -85,7 +90,9 @@ public function load($path) */ protected function baseDumpCommand() { - $command = 'mysqldump '.$this->connectionString().' --no-tablespaces --skip-add-locks --skip-comments --skip-set-charset --tz-utc --column-statistics=0'; + $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'; @@ -97,22 +104,45 @@ protected function baseDumpCommand() /** * 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() + 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}"'; + ? ' --socket="${:LARAVEL_LOAD_SOCKET}"' + : ' --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}"'; - if (isset($config['options'][\PDO::MYSQL_ATTR_SSL_CA])) { + /** @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; } @@ -133,7 +163,9 @@ protected function baseVariables(array $config) 'LARAVEL_LOAD_USER' => $config['username'], 'LARAVEL_LOAD_PASSWORD' => $config['password'] ?? '', 'LARAVEL_LOAD_DATABASE' => $config['database'], - 'LARAVEL_LOAD_SSL_CA' => $config['options'][\PDO::MYSQL_ATTR_SSL_CA] ?? '', + '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 ]; } @@ -143,23 +175,30 @@ protected function baseVariables(array $config) * @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) + 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); + ), $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); + ), $output, $variables, $depth + 1); } throw $e; @@ -167,4 +206,30 @@ protected function executeDumpProcess(Process $process, $output, array $variable 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 adfbd688ee..66f3117427 100755 --- a/Schema/PostgresBuilder.php +++ b/Schema/PostgresBuilder.php @@ -6,52 +6,7 @@ class PostgresBuilder extends Builder { - use ParsesSearchPath { - parseSearchPath as baseParseSearchPath; - } - - /** - * Create a database in the schema. - * - * @param string $name - * @return bool - */ - public function createDatabase($name) - { - return $this->connection->statement( - $this->grammar->compileCreateDatabase($name, $this->connection) - ); - } - - /** - * 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) - ); - } - - /** - * Determine if the given table exists. - * - * @param string $table - * @return bool - */ - public function hasTable($table) - { - [$database, $schema, $table] = $this->parseSchemaAndTable($table); - - $table = $this->connection->getTablePrefix().$table; - - return count($this->connection->selectFromWriteConnection( - $this->grammar->compileTableExists(), [$database, $schema, $table] - )) > 0; - } + use ParsesSearchPath; /** * Drop all tables from the database. @@ -62,15 +17,11 @@ public function dropAllTables() { $tables = []; - $excludedTables = $this->grammar->escapeNames( - $this->connection->getConfig('dont_drop') ?? ['spatial_ref_sys'] - ); + $excludedTables = $this->connection->getConfig('dont_drop') ?? ['spatial_ref_sys']; - foreach ($this->getAllTables() as $row) { - $row = (array) $row; - - if (empty(array_intersect($this->grammar->escapeNames($row), $excludedTables))) { - $tables[] = $row['qualifiedname'] ?? reset($row); + foreach ($this->getTables($this->getCurrentSchemaListing()) as $table) { + if (empty(array_intersect([$table['name'], $table['schema_qualified_name']], $excludedTables))) { + $tables[] = $table['schema_qualified_name']; } } @@ -90,13 +41,7 @@ public function dropAllTables() */ public function dropAllViews() { - $views = []; - - foreach ($this->getAllViews() as $row) { - $row = (array) $row; - - $views[] = $row['qualifiedname'] ?? reset($row); - } + $views = array_column($this->getViews($this->getCurrentSchemaListing()), 'schema_qualified_name'); if (empty($views)) { return; @@ -115,134 +60,41 @@ public function dropAllViews() public function dropAllTypes() { $types = []; - - foreach ($this->getAllTypes() as $row) { - $row = (array) $row; - - $types[] = reset($row); + $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']; + } + } } - if (empty($types)) { - return; + if (! empty($types)) { + $this->connection->statement($this->grammar->compileDropAllTypes($types)); } - $this->connection->statement( - $this->grammar->compileDropAllTypes($types) - ); - } - - /** - * Get all of the table names for the database. - * - * @return array - */ - public function getAllTables() - { - return $this->connection->select( - $this->grammar->compileGetAllTables( - $this->parseSearchPath( - $this->connection->getConfig('search_path') ?: $this->connection->getConfig('schema') - ) - ) - ); + if (! empty($domains)) { + $this->connection->statement($this->grammar->compileDropAllDomains($domains)); + } } /** - * Get all of the view names for the database. + * Get the current schemas for the connection. * - * @return array + * @return string[] */ - public function getAllViews() + public function getCurrentSchemaListing() { - return $this->connection->select( - $this->grammar->compileGetAllViews( - $this->parseSearchPath( - $this->connection->getConfig('search_path') ?: $this->connection->getConfig('schema') - ) + return array_map( + fn ($schema) => $schema === '$user' ? $this->connection->getConfig('username') : $schema, + $this->parseSearchPath( + $this->connection->getConfig('search_path') + ?: $this->connection->getConfig('schema') + ?: 'public' ) ); } - - /** - * Get all of the type names for the database. - * - * @return array - */ - public function getAllTypes() - { - return $this->connection->select( - $this->grammar->compileGetAllTypes() - ); - } - - /** - * Get the column listing for a given table. - * - * @param string $table - * @return array - */ - public function getColumnListing($table) - { - [$database, $schema, $table] = $this->parseSchemaAndTable($table); - - $table = $this->connection->getTablePrefix().$table; - - $results = $this->connection->selectFromWriteConnection( - $this->grammar->compileColumnListing(), [$database, $schema, $table] - ); - - return $this->connection->getPostProcessor()->processColumnListing($results); - } - - /** - * Parse the database object reference and extract the database, schema, and table. - * - * @param string $reference - * @return array - */ - protected function parseSchemaAndTable($reference) - { - $searchPath = $this->parseSearchPath( - $this->connection->getConfig('search_path') ?: $this->connection->getConfig('schema') ?: 'public' - ); - - $parts = explode('.', $reference); - - $database = $this->connection->getConfig('database'); - - // If the reference contains a database name, we will use that instead of the - // default database name for the connection. This allows the database name - // to be specified in the query instead of at the full connection level. - if (count($parts) === 3) { - $database = $parts[0]; - array_shift($parts); - } - - // We will use the default schema unless the schema has been specified in the - // query. If the schema has been specified in the query then we can use it - // instead of a default schema configured in the connection search path. - $schema = $searchPath[0]; - - if (count($parts) === 2) { - $schema = $parts[0]; - array_shift($parts); - } - - return [$database, $schema, $parts[0]]; - } - - /** - * Parse the "search_path" configuration value into an array. - * - * @param string|array|null $searchPath - * @return array - */ - protected function parseSearchPath($searchPath) - { - return array_map(function ($schema) { - return $schema === '$user' - ? $this->connection->getConfig('username') - : $schema; - }, $this->baseParseSearchPath($searchPath)); - } } diff --git a/Schema/PostgresSchemaState.php b/Schema/PostgresSchemaState.php index b6d4273ffa..25da812e61 100644 --- a/Schema/PostgresSchemaState.php +++ b/Schema/PostgresSchemaState.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Schema; use Illuminate\Database\Connection; +use Illuminate\Support\Collection; class PostgresSchemaState extends SchemaState { @@ -15,19 +16,19 @@ class PostgresSchemaState extends SchemaState */ public function dump(Connection $connection, $path) { - $excludedTables = collect($connection->getSchemaBuilder()->getAllTables()) - ->map->tablename - ->reject(function ($table) { - return $table === $this->migrationTable; - })->map(function ($table) { - return '--exclude-table-data="*.'.$table.'"'; - })->implode(' '); + $commands = new Collection([ + $this->baseDumpCommand().' --schema-only > '.$path, + ]); - $this->makeProcess( - $this->baseDumpCommand().' --file="${:LARAVEL_LOAD_PATH}" '.$excludedTables - )->mustRun($this->output, array_merge($this->baseVariables($this->connection->getConfig()), [ - 'LARAVEL_LOAD_PATH' => $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, + ])); + }); } /** @@ -51,6 +52,18 @@ public function load($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. * @@ -58,7 +71,7 @@ public function load($path) */ protected function baseDumpCommand() { - return 'pg_dump --no-owner --no-acl -Fc --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; + return 'pg_dump --no-owner --no-acl --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --username="${:LARAVEL_LOAD_USER}" --dbname="${:LARAVEL_LOAD_DATABASE}"'; } /** @@ -73,7 +86,7 @@ protected function baseVariables(array $config) return [ 'LARAVEL_LOAD_HOST' => is_array($config['host']) ? $config['host'][0] : $config['host'], - 'LARAVEL_LOAD_PORT' => $config['port'], + '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 4e74f92d58..f750e97edf 100644 --- a/Schema/SQLiteBuilder.php +++ b/Schema/SQLiteBuilder.php @@ -2,6 +2,8 @@ namespace Illuminate\Database\Schema; +use Illuminate\Database\QueryException; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\File; class SQLiteBuilder extends Builder @@ -25,9 +27,66 @@ public function createDatabase($name) */ public function dropDatabaseIfExists($name) { - return File::exists($name) - ? File::delete($name) - : true; + 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)) + ); } /** @@ -37,17 +96,26 @@ public function dropDatabaseIfExists($name) */ 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)); + } + } } /** @@ -57,46 +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 all of the table names for the database. + * Get the value for the given pragma name or set the given value. * - * @return array + * @param string $key + * @param mixed $value + * @return mixed */ - public function getAllTables() + public function pragma($key, $value = null) { - return $this->connection->select( - $this->grammar->compileGetAllTables() - ); + return is_null($value) + ? $this->connection->scalar($this->grammar->pragma($key)) + : $this->connection->statement($this->grammar->pragma($key, $value)); } /** - * Get all of the view names for the database. + * Empty the database file. * - * @return array + * @param string|null $path + * @return void */ - public function getAllViews() + public function refreshDatabaseFile($path = null) { - return $this->connection->select( - $this->grammar->compileGetAllViews() - ); + file_put_contents($path ?? $this->connection->getDatabaseName(), ''); } /** - * Empty the database file. + * Get the names of current schemas for the connection. * - * @return void + * @return string[]|null */ - public function refreshDatabaseFile() + public function getCurrentSchemaListing() { - file_put_contents($this->connection->getDatabaseName(), ''); + return ['main']; } } diff --git a/Schema/SchemaState.php b/Schema/SchemaState.php index e6f35ab91f..be792138f7 100644 --- a/Schema/SchemaState.php +++ b/Schema/SchemaState.php @@ -49,9 +49,8 @@ abstract class SchemaState * @param \Illuminate\Database\Connection $connection * @param \Illuminate\Filesystem\Filesystem|null $files * @param callable|null $processFactory - * @return void */ - public function __construct(Connection $connection, Filesystem $files = null, callable $processFactory = null) + public function __construct(Connection $connection, ?Filesystem $files = null, ?callable $processFactory = null) { $this->connection = $connection; @@ -86,7 +85,7 @@ abstract public function load($path); /** * Create a new process instance. * - * @param array $arguments + * @param mixed ...$arguments * @return \Symfony\Component\Process\Process */ public function makeProcess(...$arguments) @@ -94,6 +93,26 @@ 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. * diff --git a/Schema/SqlServerBuilder.php b/Schema/SqlServerBuilder.php index c323e126a6..9161bc61af 100644 --- a/Schema/SqlServerBuilder.php +++ b/Schema/SqlServerBuilder.php @@ -2,34 +2,10 @@ namespace Illuminate\Database\Schema; +use Illuminate\Support\Arr; + class SqlServerBuilder extends Builder { - /** - * Create a database in the schema. - * - * @param string $name - * @return bool - */ - public function createDatabase($name) - { - return $this->connection->statement( - $this->grammar->compileCreateDatabase($name, $this->connection) - ); - } - - /** - * 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) - ); - } - /** * Drop all tables from the database. * @@ -53,26 +29,12 @@ public function dropAllViews() } /** - * Drop all tables from the database. - * - * @return array - */ - public function getAllTables() - { - return $this->connection->select( - $this->grammar->compileGetAllTables() - ); - } - - /** - * Get all of the view names for the database. + * Get the default schema name for the connection. * - * @return array + * @return string|null */ - public function getAllViews() + public function getCurrentSchemaName() { - return $this->connection->select( - $this->grammar->compileGetAllViews() - ); + return Arr::first($this->getSchemas(), fn ($schema) => $schema['default'])['name']; } } diff --git a/Schema/SqliteSchemaState.php b/Schema/SqliteSchemaState.php index 9a98b6331c..3d954a39de 100644 --- a/Schema/SqliteSchemaState.php +++ b/Schema/SqliteSchemaState.php @@ -3,6 +3,7 @@ namespace Illuminate\Database\Schema; use Illuminate\Database\Connection; +use Illuminate\Support\Collection; class SqliteSchemaState extends SchemaState { @@ -15,20 +16,19 @@ class SqliteSchemaState extends SchemaState */ public function dump(Connection $connection, $path) { - with($process = $this->makeProcess( - $this->baseCommand().' .schema' - ))->setTimeout(null)->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ - // - ])); + $process = $this->makeProcess($this->baseCommand().' ".schema --indent"') + ->setTimeout(null) + ->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + // + ])); - $migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) { - return stripos($line, 'sqlite_sequence') === false && - strlen($line) > 0; - })->all(); + $migrations = preg_replace('/CREATE TABLE sqlite_.+?\);[\r\n]+/is', '', $process->getOutput()); - $this->files->put($path, implode(PHP_EOL, $migrations).PHP_EOL); + $this->files->put($path, $migrations.PHP_EOL); - $this->appendMigrationData($path); + if ($this->hasMigrationTable()) { + $this->appendMigrationData($path); + } } /** @@ -39,16 +39,15 @@ public function dump(Connection $connection, $path) */ protected function appendMigrationData(string $path) { - with($process = $this->makeProcess( - $this->baseCommand().' ".dump \''.$this->migrationTable.'\'"' - ))->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ + $process = $this->makeProcess( + $this->baseCommand().' ".dump \''.$this->getMigrationTable().'\'"' + )->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [ // ])); - $migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) { - return preg_match('/^\s*(--|INSERT\s)/iu', $line) === 1 && - strlen($line) > 0; - })->all(); + $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); } @@ -61,6 +60,17 @@ protected function appendMigrationData(string $path) */ 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()), [ diff --git a/Seeder.php b/Seeder.php index 7a502a34ac..cac50afb57 100755 --- a/Seeder.php +++ b/Seeder.php @@ -4,7 +4,7 @@ use Illuminate\Console\Command; use Illuminate\Console\View\Components\TwoColumnDetail; -use Illuminate\Container\Container; +use Illuminate\Contracts\Container\Container; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Support\Arr; use InvalidArgumentException; @@ -14,7 +14,7 @@ abstract class Seeder /** * The container instance. * - * @var \Illuminate\Container\Container + * @var \Illuminate\Contracts\Container\Container */ protected $container; @@ -50,10 +50,8 @@ public function call($class, $silent = false, array $parameters = []) $name = get_class($seeder); if ($silent === false && isset($this->command)) { - with(new TwoColumnDetail($this->command->getOutput()))->render( - $name, - 'RUNNING' - ); + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, 'RUNNING'); } $startTime = microtime(true); @@ -61,12 +59,10 @@ public function call($class, $silent = false, array $parameters = []) $seeder->__invoke($parameters); if ($silent === false && isset($this->command)) { - $runTime = number_format((microtime(true) - $startTime) * 1000, 2); + $runTime = number_format((microtime(true) - $startTime) * 1000); - with(new TwoColumnDetail($this->command->getOutput()))->render( - $name, - "$runTime ms DONE" - ); + (new TwoColumnDetail($this->command->getOutput())) + ->render($name, "$runTime ms DONE"); $this->command->getOutput()->writeln(''); } @@ -110,11 +106,15 @@ public function callSilent($class, array $parameters = []) */ public function callOnce($class, $silent = false, array $parameters = []) { - if (in_array($class, static::$called)) { - return; - } + $classes = Arr::wrap($class); + + foreach ($classes as $class) { + if (in_array($class, static::$called)) { + continue; + } - $this->call($class, $silent, $parameters); + $this->call($class, $silent, $parameters); + } } /** @@ -143,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) diff --git a/SqlServerConnection.php b/SqlServerConnection.php index feb4577bc9..7b3d0c5f01 100755 --- a/SqlServerConnection.php +++ b/SqlServerConnection.php @@ -3,7 +3,7 @@ namespace Illuminate\Database; use Closure; -use Illuminate\Database\PDO\SqlServerDriver; +use Exception; use Illuminate\Database\Query\Grammars\SqlServerGrammar as QueryGrammar; use Illuminate\Database\Query\Processors\SqlServerProcessor; use Illuminate\Database\Schema\Grammars\SqlServerGrammar as SchemaGrammar; @@ -14,6 +14,14 @@ class SqlServerConnection extends Connection { + /** + * {@inheritdoc} + */ + public function getDriverTitle() + { + return 'SQL Server'; + } + /** * Execute a Closure within a transaction. * @@ -54,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. * @@ -61,7 +93,7 @@ public function transaction(Closure $callback, $attempts = 1) */ protected function getDefaultQueryGrammar() { - return $this->withTablePrefix(new QueryGrammar); + return new QueryGrammar($this); } /** @@ -85,7 +117,7 @@ public function getSchemaBuilder() */ protected function getDefaultSchemaGrammar() { - return $this->withTablePrefix(new SchemaGrammar); + return new SchemaGrammar($this); } /** @@ -96,7 +128,7 @@ protected function getDefaultSchemaGrammar() * * @throws \RuntimeException */ - public function getSchemaState(Filesystem $files = null, callable $processFactory = null) + public function getSchemaState(?Filesystem $files = null, ?callable $processFactory = null) { throw new RuntimeException('Schema dumping is not supported when using SQL Server.'); } @@ -110,14 +142,4 @@ protected function getDefaultPostProcessor() { return new SqlServerProcessor; } - - /** - * Get the Doctrine DBAL driver. - * - * @return \Illuminate\Database\PDO\SqlServerDriver - */ - protected function getDoctrineDriver() - { - return new SqlServerDriver; - } } diff --git a/UniqueConstraintViolationException.php b/UniqueConstraintViolationException.php new file mode 100644 index 0000000000..13b705b77c --- /dev/null +++ b/UniqueConstraintViolationException.php @@ -0,0 +1,7 @@ +