diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml index 46cf7a225d67..f9d387b11601 100644 --- a/.github/workflows/databases.yml +++ b/.github/workflows/databases.yml @@ -193,7 +193,7 @@ jobs: DB_PASSWORD: password mssql: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 services: sqlsrv: @@ -240,7 +240,7 @@ jobs: DB_PASSWORD: Forge123 sqlite: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 strategy: fail-fast: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 549408b74fcd..94f7c17fad56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,38 @@ # Release Notes for 11.x -## [Unreleased](https://github.com/laravel/framework/compare/v11.20.0...11.x) +## [Unreleased](https://github.com/laravel/framework/compare/v11.21.0...11.x) + +## [v11.21.0](https://github.com/laravel/framework/compare/v11.20.0...v11.21.0) - 2024-08-20 + +* [11.x] Test Improvements by [@crynobone](https://github.com/crynobone) in https://github.com/laravel/framework/pull/52402 +* [11.x] Fix docblock for the event dispatcher by [@seriquynh](https://github.com/seriquynh) in https://github.com/laravel/framework/pull/52411 +* [11.x] fix: Update text email template by [@tranvanhieu01012002](https://github.com/tranvanhieu01012002) in https://github.com/laravel/framework/pull/52417 +* [11.x] Make `expectsChoice` assertion more intuitive with associative arrays. by [@jessarcher](https://github.com/jessarcher) in https://github.com/laravel/framework/pull/52408 +* [11.x] Add `resource()` method to Illuminate\Http\Client\Response by [@einar-hansen](https://github.com/einar-hansen) in https://github.com/laravel/framework/pull/52412 +* [10.x] fix: prevent casting empty string to array from triggering json error by [@calebdw](https://github.com/calebdw) in https://github.com/laravel/framework/pull/52415 +* [11.x] Add ResponseInterface mixin to `Illuminate\Http\Client\Response` by [@einar-hansen](https://github.com/einar-hansen) in https://github.com/laravel/framework/pull/52410 +* [11.x] Don't touch BelongsTo relationship when it doesn't exist by [@patrickomeara](https://github.com/patrickomeara) in https://github.com/laravel/framework/pull/52407 +* [11.x] Fix `Factory::afterCreating` callable argument type by [@villfa](https://github.com/villfa) in https://github.com/laravel/framework/pull/52424 +* [11.x] Auto-secure cookies by [@fabricecw](https://github.com/fabricecw) in https://github.com/laravel/framework/pull/52422 +* fix: add missing phpdoc types for Model::$table and Model::$dateFormat by [@taka-oyama](https://github.com/taka-oyama) in https://github.com/laravel/framework/pull/52425 +* [11.x] Add `withoutHeaders` method by [@milwad-dev](https://github.com/milwad-dev) in https://github.com/laravel/framework/pull/52435 +* Checking availability before calling Log::flushSharedContext() method by [@ajaxray](https://github.com/ajaxray) in https://github.com/laravel/framework/pull/52470 +* [11.x] MessageBag errors out when custom rules are created and the class is left out of the message array by [@DanteB918](https://github.com/DanteB918) in https://github.com/laravel/framework/pull/52451 +* Create Notification make command markdown name placeholder from Notif… by [@hosseinakbari-liefermia](https://github.com/hosseinakbari-liefermia) in https://github.com/laravel/framework/pull/52465 +* [11.x] Add `forceDestroy` to `SoftDeletes` by [@jasonmccreary](https://github.com/jasonmccreary) in https://github.com/laravel/framework/pull/52432 +* Make SQLiteProcessor cope with '/' in column names by [@vroomfondle](https://github.com/vroomfondle) in https://github.com/laravel/framework/pull/52490 +* [11.x] Improve Cookie Testing Coverage by [@saMahmoudzadeh](https://github.com/saMahmoudzadeh) in https://github.com/laravel/framework/pull/52472 +* [11.x] Fix for #52436 artisan schema:dump infinite recursion by [@rust17](https://github.com/rust17) in https://github.com/laravel/framework/pull/52492 +* Run prepareNestedBatches on append/prependToChain & chain by [@SabatinoMasala](https://github.com/SabatinoMasala) in https://github.com/laravel/framework/pull/52486 +* [11.x] Enhance DB inspection commands by [@hafezdivandari](https://github.com/hafezdivandari) in https://github.com/laravel/framework/pull/52501 +* [11.x] Constrain key when asserting database has a model by [@patrickomeara](https://github.com/patrickomeara) in https://github.com/laravel/framework/pull/52464 +* Add `between` to `AssertableJson` by [@rudashi](https://github.com/rudashi) in https://github.com/laravel/framework/pull/52479 +* [11.x] Eager asset prefetching strategies for Vite by [@timacdonald](https://github.com/timacdonald) in https://github.com/laravel/framework/pull/52462 +* [11.x] Support attributes in `app()->call()` by [@innocenzi](https://github.com/innocenzi) in https://github.com/laravel/framework/pull/52428 +* [11.x] Applying `value` Function into the `$default` value of `transform` helper by [@devajmeireles](https://github.com/devajmeireles) in https://github.com/laravel/framework/pull/52510 +* [11.x] Enhanced typing for `HigherOrderCollectionProxy` by [@Voltra](https://github.com/Voltra) in https://github.com/laravel/framework/pull/52484 +* [11.x] Add `expectsSearch()` assertion for testing prompts that use `search()` and `multisearch()` functions by [@JayBizzle](https://github.com/JayBizzle) in https://github.com/laravel/framework/pull/51669 +* [11.x] revert #52510 which added a unneeded function call by [@rodrigopedra](https://github.com/rodrigopedra) in https://github.com/laravel/framework/pull/52526 ## [v11.20.0](https://github.com/laravel/framework/compare/v11.19.0...v11.20.0) - 2024-08-06 diff --git a/src/Illuminate/Bus/Queueable.php b/src/Illuminate/Bus/Queueable.php index f4de59d6b7d2..18f6e90d12b2 100644 --- a/src/Illuminate/Bus/Queueable.php +++ b/src/Illuminate/Bus/Queueable.php @@ -2,6 +2,7 @@ namespace Illuminate\Bus; +use BackedEnum; use Closure; use Illuminate\Queue\CallQueuedClosure; use Illuminate\Support\Arr; @@ -76,12 +77,14 @@ trait Queueable /** * Set the desired connection for the job. * - * @param string|null $connection + * @param \BackedEnum|string|null $connection * @return $this */ public function onConnection($connection) { - $this->connection = $connection; + $this->connection = $connection instanceof BackedEnum + ? $connection->value + : $connection; return $this; } @@ -89,12 +92,14 @@ public function onConnection($connection) /** * Set the desired queue for the job. * - * @param string|null $queue + * @param \BackedEnum|string|null $queue * @return $this */ public function onQueue($queue) { - $this->queue = $queue; + $this->queue = $queue instanceof BackedEnum + ? $queue->value + : $queue; return $this; } @@ -102,13 +107,17 @@ public function onQueue($queue) /** * Set the desired connection for the chain. * - * @param string|null $connection + * @param \BackedEnum|string|null $connection * @return $this */ public function allOnConnection($connection) { - $this->chainConnection = $connection; - $this->connection = $connection; + $resolvedConnection = $connection instanceof BackedEnum + ? $connection->value + : $connection; + + $this->chainConnection = $resolvedConnection; + $this->connection = $resolvedConnection; return $this; } @@ -116,13 +125,17 @@ public function allOnConnection($connection) /** * Set the desired queue for the chain. * - * @param string|null $queue + * @param \BackedEnum|string|null $queue * @return $this */ public function allOnQueue($queue) { - $this->chainQueue = $queue; - $this->queue = $queue; + $resolvedQueue = $queue instanceof BackedEnum + ? $queue->value + : $queue; + + $this->chainQueue = $resolvedQueue; + $this->queue = $resolvedQueue; return $this; } diff --git a/src/Illuminate/Collections/Collection.php b/src/Illuminate/Collections/Collection.php index f041f1f20849..fdfde565b89f 100644 --- a/src/Illuminate/Collections/Collection.php +++ b/src/Illuminate/Collections/Collection.php @@ -1561,7 +1561,7 @@ protected function sortByMany(array $comparisons = [], int $options = SORT_REGUL $result = match ($options) { SORT_NUMERIC => intval($values[0]) <=> intval($values[1]), SORT_STRING => strcmp($values[0], $values[1]), - SORT_NATURAL => strnatcmp($values[0], $values[1]), + SORT_NATURAL => strnatcmp((string) $values[0], (string) $values[1]), SORT_LOCALE_STRING => strcoll($values[0], $values[1]), default => $values[0] <=> $values[1], }; diff --git a/src/Illuminate/Console/Concerns/InteractsWithIO.php b/src/Illuminate/Console/Concerns/InteractsWithIO.php index a398928f633f..6101477a1a77 100644 --- a/src/Illuminate/Console/Concerns/InteractsWithIO.php +++ b/src/Illuminate/Console/Concerns/InteractsWithIO.php @@ -263,8 +263,8 @@ public function withProgressBar($totalSteps, Closure $callback) $bar->start(); if (is_iterable($totalSteps)) { - foreach ($totalSteps as $value) { - $callback($value, $bar); + foreach ($totalSteps as $key => $value) { + $callback($value, $bar, $key); $bar->advance(); } diff --git a/src/Illuminate/Container/Util.php b/src/Illuminate/Container/Util.php index ae1ff41fa1cc..ebd345efac27 100644 --- a/src/Illuminate/Container/Util.php +++ b/src/Illuminate/Container/Util.php @@ -77,7 +77,7 @@ public static function getParameterClassName($parameter) /** * Get a contextual attribute from a dependency. * - * @param ReflectionParameter $dependency + * @param \ReflectionParameter $dependency * @return \ReflectionAttribute|null */ public static function getContextualAttributeFromDependency($dependency) diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php index 6a3e2ef18f59..312f7aed4511 100644 --- a/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php @@ -12,7 +12,7 @@ interface CastsInboundAttributes * @param \Illuminate\Database\Eloquent\Model $model * @param string $key * @param mixed $value - * @param array $attributes + * @param array $attributes * @return mixed */ public function set(Model $model, string $key, mixed $value, array $attributes); diff --git a/src/Illuminate/Database/Connectors/Connector.php b/src/Illuminate/Database/Connectors/Connector.php index a8674ff7dfd9..dc06068462b8 100755 --- a/src/Illuminate/Database/Connectors/Connector.php +++ b/src/Illuminate/Database/Connectors/Connector.php @@ -62,7 +62,9 @@ public function createConnection($dsn, array $config, array $options) */ protected function createPdoConnection($dsn, $username, $password, $options) { - return new PDO($dsn, $username, $password, $options); + return version_compare(phpversion(), '8.4.0', '<') + ? new PDO($dsn, $username, $password, $options) + : PDO::connect($dsn, $username, $password, $options); /** @phpstan-ignore staticMethod.notFound (PHP 8.4) */ } /** diff --git a/src/Illuminate/Database/DetectsLostConnections.php b/src/Illuminate/Database/DetectsLostConnections.php index b6f5fcad2723..dc987adb4118 100644 --- a/src/Illuminate/Database/DetectsLostConnections.php +++ b/src/Illuminate/Database/DetectsLostConnections.php @@ -76,6 +76,9 @@ protected function causedByLostConnection(Throwable $e) 'No such file or directory', 'server is shutting down', 'failed to connect to', + 'Channel connection is closed', + 'Connection lost', + 'Broken pipe', ]); } } diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php index 9bd0615c4734..42b16c43a7da 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasEvents.php @@ -399,7 +399,7 @@ public function dispatchesEvents() /** * Get the event dispatcher instance. * - * @return \Illuminate\Contracts\Events\Dispatcher + * @return \Illuminate\Contracts\Events\Dispatcher|null */ public static function getEventDispatcher() { diff --git a/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php new file mode 100644 index 000000000000..27d69a98b1ae --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Concerns/PreventsCircularRecursion.php @@ -0,0 +1,103 @@ +> + */ + 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); + + $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/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 92b8e7d8f324..7afa59933416 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -35,6 +35,7 @@ abstract class Model implements Arrayable, ArrayAccess, CanBeEscapedWhenCastToSt Concerns\HasUniqueIds, Concerns\HidesAttributes, Concerns\GuardsAttributes, + Concerns\PreventsCircularRecursion, ForwardsCalls; /** @use HasCollection<\Illuminate\Database\Eloquent\Collection> */ use HasCollection; @@ -133,7 +134,7 @@ 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; @@ -1083,25 +1084,27 @@ 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); } /** @@ -1657,7 +1660,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(), + ); } /** @@ -2004,29 +2010,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); + }, []); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php b/src/Illuminate/Database/Eloquent/Relations/Concerns/SupportsInverseRelations.php new file mode 100644 index 000000000000..fa0161520728 --- /dev/null +++ b/src/Illuminate/Database/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|null + { + 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) { + $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/src/Illuminate/Database/Eloquent/Relations/HasMany.php b/src/Illuminate/Database/Eloquent/Relations/HasMany.php index 2a2a3e6a0e5a..77afb416688f 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasMany.php @@ -19,11 +19,18 @@ class HasMany extends HasOneOrMany */ public function one() { - return HasOne::noConstraints(fn () => new HasOne( - $this->getQuery(), - $this->parent, - $this->foreignKey, - $this->localKey + return HasOne::noConstraints(fn () => tap( + new HasOne( + $this->getQuery(), + $this->parent, + $this->foreignKey, + $this->localKey + ), + function ($hasOne) { + if ($inverse = $this->getInverseRelationship()) { + $hasOne->inverse($inverse); + } + } )); } diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOne.php b/src/Illuminate/Database/Eloquent/Relations/HasOne.php index 20728b5a4d3e..be70cb6d79ef 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOne.php @@ -99,9 +99,10 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) */ 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); + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php index c65ab5351889..913728b52a10 100755 --- a/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/HasOneOrMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; +use Illuminate\Database\Eloquent\Relations\Concerns\SupportsInverseRelations; use Illuminate\Database\UniqueConstraintViolationException; /** @@ -17,7 +18,7 @@ */ abstract class HasOneOrMany extends Relation { - use InteractsWithDictionary; + use InteractsWithDictionary, SupportsInverseRelations; /** * The foreign key of the parent model. @@ -60,6 +61,7 @@ public function make(array $attributes = []) { return tap($this->related->newInstance($attributes), function ($instance) { $this->setForeignAttributesForCreate($instance); + $this->applyInverseRelationToModel($instance); }); } @@ -153,9 +155,13 @@ protected function matchOneOrMany(array $models, Collection $results, $relation, // 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) - ); + $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); } } @@ -363,6 +369,8 @@ public function create(array $attributes = []) $this->setForeignAttributesForCreate($instance); $instance->save(); + + $this->applyInverseRelationToModel($instance); }); } @@ -387,7 +395,7 @@ public function forceCreate(array $attributes = []) { $attributes[$this->getForeignKeyName()] = $this->getParentKey(); - return $this->related->forceCreate($attributes); + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } /** @@ -438,6 +446,8 @@ public function createManyQuietly(iterable $records) protected function setForeignAttributesForCreate(Model $model) { $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); + + $this->applyInverseRelationToModel($model); } /** @inheritDoc */ diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php index cd2a51d33ed6..cbdd1d55b586 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphMany.php @@ -19,12 +19,19 @@ class MorphMany extends MorphOneOrMany */ public function one() { - return MorphOne::noConstraints(fn () => new MorphOne( - $this->getQuery(), - $this->getParent(), - $this->morphType, - $this->foreignKey, - $this->localKey + 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); + } + } )); } diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php index 34429fdcd112..e9d7dd267518 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOne.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOne.php @@ -101,9 +101,12 @@ public function addOneOfManyJoinSubQueryConstraints(JoinClause $join) */ 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); + }); } /** diff --git a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php index 248e92abde7c..3478f73859d4 100755 --- a/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php +++ b/src/Illuminate/Database/Eloquent/Relations/MorphOneOrMany.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; /** * @template TRelatedModel of \Illuminate\Database\Eloquent\Model @@ -80,7 +81,7 @@ public function forceCreate(array $attributes = []) $attributes[$this->getForeignKeyName()] = $this->getParentKey(); $attributes[$this->getMorphType()] = $this->morphClass; - return $this->related->forceCreate($attributes); + return $this->applyInverseRelationToModel($this->related->forceCreate($attributes)); } /** @@ -94,6 +95,8 @@ protected function setForeignAttributesForCreate(Model $model) $model->{$this->getForeignKeyName()} = $this->getParentKey(); $model->{$this->getMorphType()} = $this->morphClass; + + $this->applyInverseRelationToModel($model); } /** @@ -154,4 +157,17 @@ 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/src/Illuminate/Database/Query/Builder.php b/src/Illuminate/Database/Query/Builder.php index 7dde9d31b1b4..4152b1bb57e9 100755 --- a/src/Illuminate/Database/Query/Builder.php +++ b/src/Illuminate/Database/Query/Builder.php @@ -2238,7 +2238,7 @@ public function orWhereFullText($columns, $value, array $options = []) /** * Add a "where" clause to the query for multiple columns with "and" conditions between them. * - * @param \Illuminate\Contracts\Database\Query\Expression[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @param string $boolean @@ -2262,7 +2262,7 @@ public function whereAll($columns, $operator = null, $value = null, $boolean = ' /** * Add an "or where" clause to the query for multiple columns with "and" conditions between them. * - * @param \Illuminate\Contracts\Database\Query\Expression[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @return $this @@ -2275,7 +2275,7 @@ public function orWhereAll($columns, $operator = null, $value = null) /** * Add a "where" clause to the query for multiple columns with "or" conditions between them. * - * @param \Illuminate\Contracts\Database\Query\Expression[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @param string $boolean @@ -2299,7 +2299,7 @@ public function whereAny($columns, $operator = null, $value = null, $boolean = ' /** * Add an "or where" clause to the query for multiple columns with "or" conditions between them. * - * @param \Illuminate\Contracts\Database\Query\Expression[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @return $this @@ -2312,7 +2312,7 @@ public function orWhereAny($columns, $operator = null, $value = null) /** * 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[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @param string $boolean @@ -2326,7 +2326,7 @@ public function whereNone($columns, $operator = null, $value = null, $boolean = /** * 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[]|string[] $columns + * @param \Illuminate\Contracts\Database\Query\Expression[]|\Closure[]|string[] $columns * @param mixed $operator * @param mixed $value * @return $this diff --git a/src/Illuminate/Database/Schema/BlueprintState.php b/src/Illuminate/Database/Schema/BlueprintState.php index d617f58fac41..1fd5eeb5dc1c 100644 --- a/src/Illuminate/Database/Schema/BlueprintState.php +++ b/src/Illuminate/Database/Schema/BlueprintState.php @@ -105,7 +105,7 @@ public function __construct(Blueprint $blueprint, Connection $connection, Gramma $this->foreignKeys = collect($schema->getForeignKeys($table))->map(fn ($foreignKey) => new ForeignKeyDefinition([ 'columns' => $foreignKey['columns'], - 'on' => $foreignKey['foreign_table'], + 'on' => $this->withoutTablePrefix($foreignKey['foreign_table']), 'references' => $foreignKey['foreign_columns'], 'onUpdate' => $foreignKey['on_update'], 'onDelete' => $foreignKey['on_delete'], @@ -251,4 +251,19 @@ public function update(Fluent $command) break; } } + + /** + * Remove the table prefix from a table name, if it exists. + * + * @param string $table + * @return string + */ + protected function withoutTablePrefix(string $table) + { + $prefix = $this->connection->getTablePrefix(); + + return str_starts_with($table, $prefix) + ? substr($table, strlen($prefix)) + : $table; + } } diff --git a/src/Illuminate/Filesystem/Filesystem.php b/src/Illuminate/Filesystem/Filesystem.php index cfd4c2207835..8bba99a2f2ed 100644 --- a/src/Illuminate/Filesystem/Filesystem.php +++ b/src/Illuminate/Filesystem/Filesystem.php @@ -184,7 +184,7 @@ public function lines($path) * * @param string $path * @param string $algorithm - * @return string + * @return string|false */ public function hash($path, $algorithm = 'md5') { diff --git a/src/Illuminate/Foundation/Application.php b/src/Illuminate/Foundation/Application.php index 211fce8ef438..7c82367b67ea 100755 --- a/src/Illuminate/Foundation/Application.php +++ b/src/Illuminate/Foundation/Application.php @@ -45,7 +45,7 @@ class Application extends Container implements ApplicationContract, CachesConfig * * @var string */ - const VERSION = '11.21.0'; + const VERSION = '11.22.0'; /** * The base path for the Laravel installation. diff --git a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php index a1976ac92cce..c3455b92105b 100644 --- a/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php +++ b/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php @@ -135,8 +135,6 @@ public function registerDumper() * Register the "validate" macro on the request. * * @return void - * - * @throws \Illuminate\Validation\ValidationException */ public function registerRequestValidation() { diff --git a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php index 517796c1d893..f216a15c1e30 100644 --- a/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php +++ b/src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php @@ -272,6 +272,10 @@ protected function getConnection($connection = null, $table = null) */ protected function getTable($table) { + if ($table instanceof Model) { + return $table->getTable(); + } + return $this->newModelFor($table)?->getTable() ?: $table; } @@ -283,6 +287,10 @@ protected function getTable($table) */ protected function getTableConnection($table) { + if ($table instanceof Model) { + return $table->getConnectionName(); + } + return $this->newModelFor($table)?->getConnectionName(); } diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 747da7b14d08..7922f8309253 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -111,6 +111,13 @@ class Vite implements Htmlable */ protected $prefetchConcurrently = 3; + /** + * The name of the event that should trigger prefetching. The event must be dispatched on the `window`. + * + * @var string + */ + protected $prefetchEvent = 'load'; + /** * Get the preloaded assets. * @@ -282,9 +289,24 @@ public function usePreloadTagAttributes($attributes) } /** - * Use the "waterfall" prefetching strategy. + * Eagerly prefetch assets. * * @param int|null $concurrency + * @param string $event + * @return $this + */ + public function prefetch($concurrency = null, $event = 'load') + { + $this->prefetchEvent = $event; + + return $concurrency === null + ? $this->usePrefetchStrategy('aggressive') + : $this->usePrefetchStrategy('waterfall', ['concurrency' => $concurrency]); + } + + /** + * Use the "waterfall" prefetching strategy. + * * @return $this */ public function useWaterfallPrefetching(?int $concurrency = null) @@ -473,8 +495,8 @@ public function __invoke($entrypoints, $buildDirectory = null) ->pipe(fn ($assets) => with(Js::from($assets), fn ($assets) => match ($this->prefetchStrategy) { 'waterfall' => new HtmlString($base.<< - window.addEventListener('load', () => window.setTimeout(() => { + nonceAttribute()}> + window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => { const makeLink = (asset) => { const link = document.createElement('link') @@ -516,8 +538,8 @@ public function __invoke($entrypoints, $buildDirectory = null) HTML), 'aggressive' => new HtmlString($base.<< - window.addEventListener('load', () => window.setTimeout(() => { + nonceAttribute()}> + window.addEventListener('{$this->prefetchEvent}', () => window.setTimeout(() => { const makeLink = (asset) => { const link = document.createElement('link') @@ -954,6 +976,20 @@ protected function chunk($manifest, $file) return $manifest[$file]; } + /** + * Get the nonce attribute for the prefetch script tags. + * + * @return \Illuminate\Support\HtmlString + */ + protected function nonceAttribute() + { + if ($this->cspNonce() === null) { + return new HtmlString(''); + } + + return new HtmlString(' nonce="'.$this->cspNonce().'"'); + } + /** * Determine if the HMR server is running. * diff --git a/src/Illuminate/Foundation/helpers.php b/src/Illuminate/Foundation/helpers.php index e03e506dc284..d73632529bff 100644 --- a/src/Illuminate/Foundation/helpers.php +++ b/src/Illuminate/Foundation/helpers.php @@ -828,7 +828,7 @@ function response($content = null, $status = 200, array $headers = []) /** * Generate the URL to a named route. * - * @param string $name + * @param \BackedEnum|string $name * @param mixed $parameters * @param bool $absolute * @return string @@ -907,7 +907,7 @@ function storage_path($path = '') /** * Create a new redirect response to a named route. * - * @param string $route + * @param \BackedEnum|string $route * @param mixed $parameters * @param int $status * @param array $headers diff --git a/src/Illuminate/Http/Client/PendingRequest.php b/src/Illuminate/Http/Client/PendingRequest.php index f43bd3d4d671..d9b2e4cf2f89 100644 --- a/src/Illuminate/Http/Client/PendingRequest.php +++ b/src/Illuminate/Http/Client/PendingRequest.php @@ -716,7 +716,7 @@ public function throwIf($condition) /** * Throw an exception if a server or client error occurred and the given condition evaluates to false. * - * @param bool $condition + * @param callable|bool $condition * @return $this */ public function throwUnless($condition) diff --git a/src/Illuminate/Http/Middleware/TrustProxies.php b/src/Illuminate/Http/Middleware/TrustProxies.php index 4b7b0f62b9b1..0d3c747ba6d3 100644 --- a/src/Illuminate/Http/Middleware/TrustProxies.php +++ b/src/Illuminate/Http/Middleware/TrustProxies.php @@ -23,6 +23,7 @@ class TrustProxies Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_PREFIX | Request::HEADER_X_FORWARDED_AWS_ELB; /** diff --git a/src/Illuminate/Routing/Redirector.php b/src/Illuminate/Routing/Redirector.php index 9235d19bad19..24de72d1fe7d 100755 --- a/src/Illuminate/Routing/Redirector.php +++ b/src/Illuminate/Routing/Redirector.php @@ -143,7 +143,7 @@ public function secure($path, $status = 302, $headers = []) /** * Create a new redirect response to a named route. * - * @param string $route + * @param \BackedEnum|string $route * @param mixed $parameters * @param int $status * @param array $headers @@ -157,7 +157,7 @@ public function route($route, $parameters = [], $status = 302, $headers = []) /** * Create a new redirect response to a signed named route. * - * @param string $route + * @param \BackedEnum|string $route * @param mixed $parameters * @param \DateTimeInterface|\DateInterval|int|null $expiration * @param int $status @@ -172,7 +172,7 @@ public function signedRoute($route, $parameters = [], $expiration = null, $statu /** * Create a new redirect response to a signed named route. * - * @param string $route + * @param \BackedEnum|string $route * @param \DateTimeInterface|\DateInterval|int|null $expiration * @param mixed $parameters * @param int $status diff --git a/src/Illuminate/Routing/Route.php b/src/Illuminate/Routing/Route.php index 80ca0a4a9b57..b114d22203d6 100755 --- a/src/Illuminate/Routing/Route.php +++ b/src/Illuminate/Routing/Route.php @@ -2,6 +2,7 @@ namespace Illuminate\Routing; +use BackedEnum; use Closure; use Illuminate\Container\Container; use Illuminate\Http\Exceptions\HttpResponseException; @@ -17,6 +18,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; use Illuminate\Support\Traits\Macroable; +use InvalidArgumentException; use Laravel\SerializableClosure\SerializableClosure; use LogicException; use Symfony\Component\Routing\Route as SymfonyRoute; @@ -752,8 +754,10 @@ public function secure() /** * Get or set the domain for the route. * - * @param string|null $domain + * @param \BackedEnum|string|null $domain * @return $this|string|null + * + * @throws \InvalidArgumentException */ public function domain($domain = null) { @@ -761,6 +765,10 @@ public function domain($domain = null) return $this->getDomain(); } + if ($domain instanceof BackedEnum && ! is_string($domain = $domain->value)) { + throw new InvalidArgumentException('Enum must be string backed.'); + } + $parsed = RouteUri::parse($domain); $this->action['domain'] = $parsed->uri; @@ -874,11 +882,17 @@ public function getName() /** * Add or change the route name. * - * @param string $name + * @param \BackedEnum|string $name * @return $this + * + * @throws \InvalidArgumentException */ public function name($name) { + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { + throw new InvalidArgumentException('Enum must be string backed.'); + } + $this->action['as'] = isset($this->action['as']) ? $this->action['as'].$name : $name; return $this; diff --git a/src/Illuminate/Routing/RouteRegistrar.php b/src/Illuminate/Routing/RouteRegistrar.php index f38301866e6a..dc4755087d18 100644 --- a/src/Illuminate/Routing/RouteRegistrar.php +++ b/src/Illuminate/Routing/RouteRegistrar.php @@ -2,6 +2,7 @@ namespace Illuminate\Routing; +use BackedEnum; use BadMethodCallException; use Closure; use Illuminate\Support\Arr; @@ -18,10 +19,10 @@ * @method \Illuminate\Routing\Route put(string $uri, \Closure|array|string|null $action = null) * @method \Illuminate\Routing\RouteRegistrar as(string $value) * @method \Illuminate\Routing\RouteRegistrar controller(string $controller) - * @method \Illuminate\Routing\RouteRegistrar domain(string $value) + * @method \Illuminate\Routing\RouteRegistrar domain(\BackedEnum|string $value) * @method \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) * @method \Illuminate\Routing\RouteRegistrar missing(\Closure $missing) - * @method \Illuminate\Routing\RouteRegistrar name(string $value) + * @method \Illuminate\Routing\RouteRegistrar name(\BackedEnum|string $value) * @method \Illuminate\Routing\RouteRegistrar namespace(string|null $value) * @method \Illuminate\Routing\RouteRegistrar prefix(string $prefix) * @method \Illuminate\Routing\RouteRegistrar scopeBindings() @@ -126,6 +127,10 @@ public function attribute($key, $value) ); } + if ($value instanceof BackedEnum && ! is_string($value = $value->value)) { + throw new InvalidArgumentException("Attribute [{$key}] expects a string backed enum."); + } + $this->attributes[$attributeKey] = $value; return $this; diff --git a/src/Illuminate/Routing/UrlGenerator.php b/src/Illuminate/Routing/UrlGenerator.php index abbe7cf5e341..02229297a718 100755 --- a/src/Illuminate/Routing/UrlGenerator.php +++ b/src/Illuminate/Routing/UrlGenerator.php @@ -347,7 +347,7 @@ public function formatScheme($secure = null) /** * Create a signed route URL for a named route. * - * @param string $name + * @param \BackedEnum|string $name * @param mixed $parameters * @param \DateTimeInterface|\DateInterval|int|null $expiration * @param bool $absolute @@ -402,7 +402,7 @@ protected function ensureSignedRouteParametersAreNotReserved($parameters) /** * Create a temporary signed route URL for a named route. * - * @param string $name + * @param \BackedEnum|string $name * @param \DateTimeInterface|\DateInterval|int $expiration * @param array $parameters * @param bool $absolute @@ -491,15 +491,19 @@ public function signatureHasNotExpired(Request $request) /** * Get the URL to a named route. * - * @param string $name + * @param \BackedEnum|string $name * @param mixed $parameters * @param bool $absolute * @return string * - * @throws \Symfony\Component\Routing\Exception\RouteNotFoundException + * @throws \Symfony\Component\Routing\Exception\RouteNotFoundException|\InvalidArgumentException */ public function route($name, $parameters = [], $absolute = true) { + if ($name instanceof BackedEnum && ! is_string($name = $name->value)) { + throw new InvalidArgumentException('Attribute [name] expects a string backed enum.'); + } + if (! is_null($route = $this->routes->getByName($name))) { return $this->toRoute($route, $parameters, $absolute); } diff --git a/src/Illuminate/Support/Facades/File.php b/src/Illuminate/Support/Facades/File.php index 6f15962587d1..2ef76942a4ee 100755 --- a/src/Illuminate/Support/Facades/File.php +++ b/src/Illuminate/Support/Facades/File.php @@ -11,7 +11,7 @@ * @method static mixed getRequire(string $path, array $data = []) * @method static mixed requireOnce(string $path, array $data = []) * @method static \Illuminate\Support\LazyCollection lines(string $path) - * @method static string hash(string $path, string $algorithm = 'md5') + * @method static string|false hash(string $path, string $algorithm = 'md5') * @method static int|bool put(string $path, string $contents, bool $lock = false) * @method static void replace(string $path, string $content, int|null $mode = null) * @method static void replaceInFile(array|string $search, array|string $replace, string $path) diff --git a/src/Illuminate/Support/Facades/Http.php b/src/Illuminate/Support/Facades/Http.php index 47d7e3355188..34747a1ebc04 100644 --- a/src/Illuminate/Support/Facades/Http.php +++ b/src/Illuminate/Support/Facades/Http.php @@ -62,7 +62,7 @@ * @method static \Illuminate\Http\Client\PendingRequest beforeSending(callable $callback) * @method static \Illuminate\Http\Client\PendingRequest throw(callable|null $callback = null) * @method static \Illuminate\Http\Client\PendingRequest throwIf(callable|bool $condition) - * @method static \Illuminate\Http\Client\PendingRequest throwUnless(bool $condition) + * @method static \Illuminate\Http\Client\PendingRequest throwUnless(callable|bool $condition) * @method static \Illuminate\Http\Client\PendingRequest dump() * @method static \Illuminate\Http\Client\PendingRequest dd() * @method static \Illuminate\Http\Client\Response get(string $url, array|string|null $query = null) diff --git a/src/Illuminate/Support/Facades/Redirect.php b/src/Illuminate/Support/Facades/Redirect.php index c7490f4ae819..8884fe9af989 100755 --- a/src/Illuminate/Support/Facades/Redirect.php +++ b/src/Illuminate/Support/Facades/Redirect.php @@ -10,9 +10,9 @@ * @method static \Illuminate\Http\RedirectResponse to(string $path, int $status = 302, array $headers = [], bool|null $secure = null) * @method static \Illuminate\Http\RedirectResponse away(string $path, int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse secure(string $path, int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse route(string $route, mixed $parameters = [], int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse signedRoute(string $route, mixed $parameters = [], \DateTimeInterface|\DateInterval|int|null $expiration = null, int $status = 302, array $headers = []) - * @method static \Illuminate\Http\RedirectResponse temporarySignedRoute(string $route, \DateTimeInterface|\DateInterval|int|null $expiration, mixed $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse route(\BackedEnum|string $route, mixed $parameters = [], int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse signedRoute(\BackedEnum|string $route, mixed $parameters = [], \DateTimeInterface|\DateInterval|int|null $expiration = null, int $status = 302, array $headers = []) + * @method static \Illuminate\Http\RedirectResponse temporarySignedRoute(\BackedEnum|string $route, \DateTimeInterface|\DateInterval|int|null $expiration, mixed $parameters = [], int $status = 302, array $headers = []) * @method static \Illuminate\Http\RedirectResponse action(string|array $action, mixed $parameters = [], int $status = 302, array $headers = []) * @method static \Illuminate\Routing\UrlGenerator getUrlGenerator() * @method static void setSession(\Illuminate\Session\Store $session) diff --git a/src/Illuminate/Support/Facades/Route.php b/src/Illuminate/Support/Facades/Route.php index 52644d388e7c..c24273bfe19d 100755 --- a/src/Illuminate/Support/Facades/Route.php +++ b/src/Illuminate/Support/Facades/Route.php @@ -90,10 +90,10 @@ * @method static \Illuminate\Routing\RouteRegistrar whereIn(array|string $parameters, array $values) * @method static \Illuminate\Routing\RouteRegistrar as(string $value) * @method static \Illuminate\Routing\RouteRegistrar controller(string $controller) - * @method static \Illuminate\Routing\RouteRegistrar domain(string $value) + * @method static \Illuminate\Routing\RouteRegistrar domain(\BackedEnum|string $value) * @method static \Illuminate\Routing\RouteRegistrar middleware(array|string|null $middleware) * @method static \Illuminate\Routing\RouteRegistrar missing(\Closure $missing) - * @method static \Illuminate\Routing\RouteRegistrar name(string $value) + * @method static \Illuminate\Routing\RouteRegistrar name(\BackedEnum|string $value) * @method static \Illuminate\Routing\RouteRegistrar namespace(string|null $value) * @method static \Illuminate\Routing\RouteRegistrar prefix(string $prefix) * @method static \Illuminate\Routing\RouteRegistrar scopeBindings() diff --git a/src/Illuminate/Support/Facades/URL.php b/src/Illuminate/Support/Facades/URL.php index 4e239f399207..ea1afcd8450e 100755 --- a/src/Illuminate/Support/Facades/URL.php +++ b/src/Illuminate/Support/Facades/URL.php @@ -14,13 +14,13 @@ * @method static string secureAsset(string $path) * @method static string assetFrom(string $root, string $path, bool|null $secure = null) * @method static string formatScheme(bool|null $secure = null) - * @method static string signedRoute(string $name, mixed $parameters = [], \DateTimeInterface|\DateInterval|int|null $expiration = null, bool $absolute = true) - * @method static string temporarySignedRoute(string $name, \DateTimeInterface|\DateInterval|int $expiration, array $parameters = [], bool $absolute = true) + * @method static string signedRoute(\BackedEnum|string $name, mixed $parameters = [], \DateTimeInterface|\DateInterval|int|null $expiration = null, bool $absolute = true) + * @method static string temporarySignedRoute(\BackedEnum|string $name, \DateTimeInterface|\DateInterval|int $expiration, array $parameters = [], bool $absolute = true) * @method static bool hasValidSignature(\Illuminate\Http\Request $request, bool $absolute = true, array $ignoreQuery = []) * @method static bool hasValidRelativeSignature(\Illuminate\Http\Request $request, array $ignoreQuery = []) * @method static bool hasCorrectSignature(\Illuminate\Http\Request $request, bool $absolute = true, array $ignoreQuery = []) * @method static bool signatureHasNotExpired(\Illuminate\Http\Request $request) - * @method static string route(string $name, mixed $parameters = [], bool $absolute = true) + * @method static string route(\BackedEnum|string $name, mixed $parameters = [], bool $absolute = true) * @method static string toRoute(\Illuminate\Routing\Route $route, mixed $parameters, bool $absolute) * @method static string action(string|array $action, mixed $parameters = [], bool $absolute = true) * @method static array formatParameters(mixed|array $parameters) diff --git a/src/Illuminate/Support/Facades/Vite.php b/src/Illuminate/Support/Facades/Vite.php index 3e9e0299d3c9..8dccfdcd54f8 100644 --- a/src/Illuminate/Support/Facades/Vite.php +++ b/src/Illuminate/Support/Facades/Vite.php @@ -16,6 +16,7 @@ * @method static \Illuminate\Foundation\Vite useScriptTagAttributes(callable|array $attributes) * @method static \Illuminate\Foundation\Vite useStyleTagAttributes(callable|array $attributes) * @method static \Illuminate\Foundation\Vite usePreloadTagAttributes(callable|array|false $attributes) + * @method static \Illuminate\Foundation\Vite prefetch(int|null $concurrency = null, string $event = 'load') * @method static \Illuminate\Foundation\Vite useWaterfallPrefetching(int|null $concurrency = null) * @method static \Illuminate\Foundation\Vite useAggressivePrefetching() * @method static \Illuminate\Foundation\Vite usePrefetchStrategy(string|null $strategy, array $config = []) diff --git a/src/Illuminate/Testing/Constraints/SeeInOrder.php b/src/Illuminate/Testing/Constraints/SeeInOrder.php index 609f32d50b92..aba5c6bdac4c 100644 --- a/src/Illuminate/Testing/Constraints/SeeInOrder.php +++ b/src/Illuminate/Testing/Constraints/SeeInOrder.php @@ -40,6 +40,8 @@ public function __construct($content) */ public function matches($values): bool { + $decodedContent = html_entity_decode($this->content, ENT_QUOTES, 'UTF-8'); + $position = 0; foreach ($values as $value) { @@ -47,7 +49,9 @@ public function matches($values): bool continue; } - $valuePosition = mb_strpos($this->content, $value, $position); + $decodedValue = html_entity_decode($value, ENT_QUOTES, 'UTF-8'); + + $valuePosition = mb_strpos($decodedContent, $decodedValue, $position); if ($valuePosition === false || $valuePosition < $position) { $this->failedValue = $value; @@ -55,7 +59,7 @@ public function matches($values): bool return false; } - $position = $valuePosition + mb_strlen($value); + $position = $valuePosition + mb_strlen($decodedValue); } return true; diff --git a/src/Illuminate/Testing/TestResponse.php b/src/Illuminate/Testing/TestResponse.php index 7b475334c7ba..5f361a6dc754 100644 --- a/src/Illuminate/Testing/TestResponse.php +++ b/src/Illuminate/Testing/TestResponse.php @@ -1036,7 +1036,7 @@ public function assertJsonIsObject($key = null) } /** - * Validate and return the decoded response JSON. + * Validate the decoded response JSON. * * @return \Illuminate\Testing\AssertableJsonString * @@ -1060,7 +1060,7 @@ public function decodeResponseJson() } /** - * Validate and return the decoded response JSON. + * Return the decoded response JSON. * * @param string|null $key * @return mixed diff --git a/tests/Bus/QueueableTest.php b/tests/Bus/QueueableTest.php new file mode 100644 index 000000000000..a9026bca70e8 --- /dev/null +++ b/tests/Bus/QueueableTest.php @@ -0,0 +1,92 @@ + ['redis', 'redis'], + 'uses BackedEnum #1' => [ConnectionEnum::SQS, 'sqs'], + 'uses BackedEnum #2' => [ConnectionEnum::REDIS, 'redis'], + 'uses null' => [null, null], + ]; + } + + /** + * @dataProvider connectionDataProvider + */ + public function testOnConnection(mixed $connection, ?string $expected): void + { + $job = new FakeJob(); + $job->onConnection($connection); + + $this->assertSame($job->connection, $expected); + } + + /** + * @dataProvider connectionDataProvider + */ + public function testAllOnConnection(mixed $connection, ?string $expected): void + { + $job = new FakeJob(); + $job->allOnConnection($connection); + + $this->assertSame($job->connection, $expected); + $this->assertSame($job->chainConnection, $expected); + } + + public static function queuesDataProvider(): array + { + return [ + 'uses string' => ['high', 'high'], + 'uses BackedEnum #1' => [QueueEnum::DEFAULT, 'default'], + 'uses BackedEnum #2' => [QueueEnum::HIGH, 'high'], + 'uses null' => [null, null], + ]; + } + + /** + * @dataProvider queuesDataProvider + */ + public function testOnQueue(mixed $queue, ?string $expected): void + { + $job = new FakeJob(); + $job->onQueue($queue); + + $this->assertSame($job->queue, $expected); + } + + /** + * @dataProvider queuesDataProvider + */ + public function testAllOnQueue(mixed $queue, ?string $expected): void + { + $job = new FakeJob(); + $job->allOnQueue($queue); + + $this->assertSame($job->queue, $expected); + $this->assertSame($job->chainQueue, $expected); + } +} + +class FakeJob +{ + use Queueable; +} + +enum ConnectionEnum: string +{ + case SQS = 'sqs'; + case REDIS = 'redis'; +} + +enum QueueEnum: string +{ + case HIGH = 'high'; + case DEFAULT = 'default'; +} diff --git a/tests/Console/Concerns/InteractsWithIOTest.php b/tests/Console/Concerns/InteractsWithIOTest.php new file mode 100755 index 000000000000..0e8f47281187 --- /dev/null +++ b/tests/Console/Concerns/InteractsWithIOTest.php @@ -0,0 +1,88 @@ +makePartial(); + $command->setOutput($output); + + $output->shouldReceive('createProgressBar') + ->once() + ->with(count($iterable)) + ->andReturnUsing(function ($steps) use ($bufferedOutput) { + // we can't mock ProgressBar because it's final, so return a real one + return new ProgressBar($bufferedOutput, $steps); + }); + + $calledTimes = 0; + $result = $command->withProgressBar($iterable, function ($value, $bar, $key) use (&$calledTimes, $iterable) { + $this->assertInstanceOf(ProgressBar::class, $bar); + $this->assertSame(array_values($iterable)[$calledTimes], $value); + $this->assertSame(array_keys($iterable)[$calledTimes], $key); + $calledTimes++; + }); + + $this->assertSame(count($iterable), $calledTimes); + $this->assertSame($iterable, $result); + } + + public static function iterableDataProvider(): Generator + { + yield [['a', 'b', 'c']]; + + yield [['foo' => 'a', 'bar' => 'b', 'baz' => 'c']]; + } + + public function testWithProgressBarInteger() + { + $command = new CommandInteractsWithIO; + $bufferedOutput = new BufferedOutput(); + $output = m::mock(OutputStyle::class, [new ArgvInput(), $bufferedOutput])->makePartial(); + $command->setOutput($output); + + $totalSteps = 5; + + $output->shouldReceive('createProgressBar') + ->once() + ->with($totalSteps) + ->andReturnUsing(function ($steps) use ($bufferedOutput) { + // we can't mock ProgressBar because it's final, so return a real one + return new ProgressBar($bufferedOutput, $steps); + }); + + $called = false; + $command->withProgressBar($totalSteps, function ($bar) use (&$called) { + $this->assertInstanceOf(ProgressBar::class, $bar); + $called = true; + }); + + $this->assertTrue($called); + } +} + +class CommandInteractsWithIO extends Command +{ + use InteractsWithIO; +} diff --git a/tests/Database/DatabaseConcernsPreventsCircularRecursionTest.php b/tests/Database/DatabaseConcernsPreventsCircularRecursionTest.php new file mode 100644 index 000000000000..c04e85957718 --- /dev/null +++ b/tests/Database/DatabaseConcernsPreventsCircularRecursionTest.php @@ -0,0 +1,251 @@ +assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + + $this->assertEquals(0, $instance->callStack()); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + + $this->assertEquals(1, $instance->callStack()); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOnRecursion() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $instance->defaultStack); + + $this->assertEquals(['instance' => 1, 'default' => 0], $instance->callCallableDefaultStack()); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $instance->defaultStack); + + $this->assertEquals(['instance' => 2, 'default' => 1], $instance->callCallableDefaultStack()); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveDefaultCallbackIsCalledOnlyOncePerCallStack() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $instance->defaultStack); + + $this->assertEquals( + [ + ['instance' => 1, 'default' => 0], + ['instance' => 1, 'default' => 0], + ['instance' => 1, 'default' => 0], + ], + $instance->callCallableDefaultStackRepeatedly(), + ); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $instance->defaultStack); + + $this->assertEquals( + [ + ['instance' => 2, 'default' => 1], + ['instance' => 2, 'default' => 1], + ['instance' => 2, 'default' => 1], + ], + $instance->callCallableDefaultStackRepeatedly(), + ); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $instance->defaultStack); + } + + public function testRecursiveCallsAreLimitedToIndividualInstances() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $other = $instance->other; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(1, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callStack(); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $other->callStack(); + $this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + } + + public function testRecursiveCallsToCircularReferenceCallsOtherInstanceOnce() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $other = $instance->other; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(2, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $other->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(4, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $other->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(3, $other->instanceStack); + $this->assertEquals(3, $instance->instanceStack); + + $other->callOtherStack(); + $this->assertEquals(8, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(4, $other->instanceStack); + $this->assertEquals(4, $instance->instanceStack); + } + + public function testRecursiveCallsToCircularLinkedListCallsEachInstanceOnce() + { + $instance = new PreventsCircularRecursionWithRecursiveMethod(); + $second = $instance->other; + $third = new PreventsCircularRecursionWithRecursiveMethod($second); + $instance->other = $third; + + $this->assertEquals(0, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(0, $instance->instanceStack); + $this->assertEquals(0, $second->instanceStack); + $this->assertEquals(0, $third->instanceStack); + + $instance->callOtherStack(); + $this->assertEquals(3, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(1, $instance->instanceStack); + $this->assertEquals(1, $second->instanceStack); + $this->assertEquals(1, $third->instanceStack); + + $second->callOtherStack(); + $this->assertEquals(6, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(2, $instance->instanceStack); + $this->assertEquals(2, $second->instanceStack); + $this->assertEquals(2, $third->instanceStack); + + $third->callOtherStack(); + $this->assertEquals(9, PreventsCircularRecursionWithRecursiveMethod::$globalStack); + $this->assertEquals(3, $instance->instanceStack); + $this->assertEquals(3, $second->instanceStack); + $this->assertEquals(3, $third->instanceStack); + } +} + +class PreventsCircularRecursionWithRecursiveMethod +{ + use PreventsCircularRecursion; + + public function __construct( + public ?PreventsCircularRecursionWithRecursiveMethod $other = null, + ) { + $this->other ??= new PreventsCircularRecursionWithRecursiveMethod($this); + } + + public static int $globalStack = 0; + public int $instanceStack = 0; + public int $defaultStack = 0; + + public function callStack(): int + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return $this->callStack(); + }, + $this->instanceStack, + ); + } + + public function callCallableDefaultStack(): array + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return $this->callCallableDefaultStack(); + }, + fn () => [ + 'instance' => $this->instanceStack, + 'default' => $this->defaultStack++, + ], + ); + } + + public function callCallableDefaultStackRepeatedly(): array + { + return $this->withoutRecursion( + function () { + static::$globalStack++; + $this->instanceStack++; + + return [ + $this->callCallableDefaultStackRepeatedly(), + $this->callCallableDefaultStackRepeatedly(), + $this->callCallableDefaultStackRepeatedly(), + ]; + }, + fn () => [ + 'instance' => $this->instanceStack, + 'default' => $this->defaultStack++, + ], + ); + } + + public function callOtherStack(): int + { + return $this->withoutRecursion( + function () { + $this->other->callStack(); + + return $this->other->callOtherStack(); + }, + $this->instanceStack, + ); + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php b/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php new file mode 100755 index 000000000000..47f597dc04b2 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationHasManyTest.php @@ -0,0 +1,309 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_users', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_users'); + $this->schema()->drop('test_posts'); + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('posts')); + foreach ($user->posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('posts')->get(); + + foreach ($users as $user) { + $posts = $user->getRelation('posts'); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('lastPost')); + $post = $user->lastPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('lastPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('lastPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::all(); + + foreach ($users as $user) { + $this->assertFalse($user->relationLoaded('firstPost')); + $post = $user->firstPost; + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasManyInverseUserModel::factory()->count(3)->withPosts()->create(); + $users = HasManyInverseUserModel::with('firstPost')->get(); + + foreach ($users as $user) { + $post = $user->getRelation('firstPost'); + + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->makeMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createMany(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $user = HasManyInverseUserModel::create(); + + $posts = $user->posts()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = array_fill(0, 3, new HasManyInversePostModel); + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertTrue($post->relationLoaded('user')); + $this->assertSame($user, $post->user); + } + } + + public function testHasManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $user = HasManyInverseUserModel::create(); + + $posts = HasManyInversePostModel::factory()->count(3)->create(); + + foreach ($posts as $post) { + $this->assertTrue($user->isNot($post->user)); + } + + $user->posts()->saveMany($posts); + + foreach ($posts as $post) { + $this->assertSame($user, $post->user); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasManyInverseUserModel extends Model +{ + use HasFactory; + + protected $table = 'test_users'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new HasManyInverseUserModelFactory(); + } + + public function posts(): HasMany + { + return $this->hasMany(HasManyInversePostModel::class, 'user_id')->inverse('user'); + } + + public function lastPost(): HasOne + { + return $this->hasOne(HasManyInversePostModel::class, 'user_id')->latestOfMany()->inverse('user'); + } + + public function firstPost(): HasOne + { + return $this->posts()->one(); + } +} + +class HasManyInverseUserModelFactory extends Factory +{ + protected $model = HasManyInverseUserModel::class; + + public function definition() + { + return []; + } + + public function withPosts(int $count = 3) + { + return $this->afterCreating(function (HasManyInverseUserModel $model) use ($count) { + HasManyInversePostModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class HasManyInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id', 'user_id']; + + protected static function newFactory() + { + return new HasManyInversePostModelFactory(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(HasManyInverseUserModel::class, 'user_id'); + } +} + +class HasManyInversePostModelFactory extends Factory +{ + protected $model = HasManyInversePostModel::class; + + public function definition() + { + return [ + 'user_id' => HasManyInverseUserModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php b/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php new file mode 100755 index 000000000000..4667ab091fc2 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationHasOneTest.php @@ -0,0 +1,246 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_parent', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_child', function ($table) { + $table->increments('id'); + $table->foreignId('parent_id')->unique(); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_parent'); + $this->schema()->drop('test_child'); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + $models = HasOneInverseParentModel::all(); + + foreach ($models as $parent) { + $this->assertFalse($parent->relationLoaded('child')); + $child = $parent->child; + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + HasOneInverseChildModel::factory(5)->create(); + + $models = HasOneInverseParentModel::with('child')->get(); + + foreach ($models as $parent) { + $child = $parent->child; + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenMaking() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->make(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->create(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->createQuietly(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $parent = HasOneInverseParentModel::create(); + + $child = $parent->child()->forceCreate(); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSaving() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->save($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::make(); + + $this->assertFalse($child->relationLoaded('parent')); + $parent->child()->saveQuietly($child); + + $this->assertTrue($child->relationLoaded('parent')); + $this->assertSame($parent, $child->parent); + } + + public function testHasOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $parent = HasOneInverseParentModel::create(); + $child = HasOneInverseChildModel::factory()->create(); + + $this->assertTrue($parent->isNot($child->parent)); + + $parent->child()->save($child); + + $this->assertTrue($parent->is($child->parent)); + $this->assertSame($parent, $child->parent); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class HasOneInverseParentModel extends Model +{ + use HasFactory; + + protected $table = 'test_parent'; + + protected $fillable = ['id']; + + protected static function newFactory() + { + return new HasOneInverseParentModelFactory(); + } + + public function child(): HasOne + { + return $this->hasOne(HasOneInverseChildModel::class, 'parent_id')->inverse('parent'); + } +} + +class HasOneInverseParentModelFactory extends Factory +{ + protected $model = HasOneInverseParentModel::class; + + public function definition() + { + return []; + } +} + +class HasOneInverseChildModel extends Model +{ + use HasFactory; + + protected $table = 'test_child'; + protected $fillable = ['id', 'parent_id']; + + protected static function newFactory() + { + return new HasOneInverseChildModelFactory(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(HasOneInverseParentModel::class, 'parent_id'); + } +} + +class HasOneInverseChildModelFactory extends Factory +{ + protected $model = HasOneInverseChildModel::class; + + public function definition() + { + return [ + 'parent_id' => HasOneInverseParentModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php new file mode 100755 index 000000000000..d7e66ef906d3 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphManyTest.php @@ -0,0 +1,376 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_comments', function ($table) { + $table->increments('id'); + $table->morphs('commentable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_comments'); + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('comments')); + $comments = $post->comments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('comments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('comments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedComments')); + $comments = $post->guessedComments; + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->withComments()->count(3)->create(); + $posts = MorphManyInversePostModel::with('guessedComments')->get(); + + foreach ($posts as $post) { + $comments = $post->getRelation('guessedComments'); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('lastComment')); + $comment = $post->lastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('lastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('lastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedLastComment')); + $comment = $post->guessedLastComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphLatestOfManyGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('guessedLastComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('guessedLastComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('firstComment')); + $comment = $post->firstComment; + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphOneOfManyInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphManyInversePostModel::factory()->count(3)->withComments()->create(); + $posts = MorphManyInversePostModel::with('firstComment')->get(); + + foreach ($posts as $post) { + $comment = $post->getRelation('firstComment'); + + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenMakingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->makeMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingMany() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createMany(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenCreatingManyQuietly() + { + $post = MorphManyInversePostModel::create(); + + $comments = $post->comments()->createManyQuietly(array_fill(0, 3, [])); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenSavingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = array_fill(0, 3, new MorphManyInverseCommentModel); + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertTrue($comment->relationLoaded('commentable')); + $this->assertSame($post, $comment->commentable); + } + } + + public function testMorphManyInverseRelationIsProperlySetToParentWhenUpdatingMany() + { + $post = MorphManyInversePostModel::create(); + $comments = MorphManyInverseCommentModel::factory()->count(3)->create(); + + foreach ($comments as $comment) { + $this->assertTrue($post->isNot($comment->commentable)); + } + + $post->comments()->saveMany($comments); + + foreach ($comments as $comment) { + $this->assertSame($post, $comment->commentable); + } + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphManyInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new MorphManyInversePostModelFactory(); + } + + public function comments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse('commentable'); + } + + public function guessedComments(): MorphMany + { + return $this->morphMany(MorphManyInverseCommentModel::class, 'commentable')->inverse(); + } + + public function lastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse('commentable'); + } + + public function guessedLastComment(): MorphOne + { + return $this->morphOne(MorphManyInverseCommentModel::class, 'commentable')->latestOfMany()->inverse(); + } + + public function firstComment(): MorphOne + { + return $this->comments()->one(); + } +} + +class MorphManyInversePostModelFactory extends Factory +{ + protected $model = MorphManyInversePostModel::class; + + public function definition() + { + return []; + } + + public function withComments(int $count = 3) + { + return $this->afterCreating(function (MorphManyInversePostModel $model) use ($count) { + MorphManyInverseCommentModel::factory()->recycle($model)->count($count)->create(); + }); + } +} + +class MorphManyInverseCommentModel extends Model +{ + use HasFactory; + + protected $table = 'test_comments'; + protected $fillable = ['id', 'commentable_type', 'commentable_id']; + + protected static function newFactory() + { + return new MorphManyInverseCommentModelFactory(); + } + + public function commentable(): MorphTo + { + return $this->morphTo('commentable'); + } +} + +class MorphManyInverseCommentModelFactory extends Factory +{ + protected $model = MorphManyInverseCommentModel::class; + + public function definition() + { + return [ + 'commentable_type' => MorphManyInversePostModel::class, + 'commentable_id' => MorphManyInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php new file mode 100755 index 000000000000..25ee5baf9e81 --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationMorphOneTest.php @@ -0,0 +1,276 @@ +addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + $db->bootEloquent(); + $db->setAsGlobal(); + + $this->createSchema(); + } + + protected function createSchema() + { + $this->schema()->create('test_posts', function ($table) { + $table->increments('id'); + $table->timestamps(); + }); + + $this->schema()->create('test_images', function ($table) { + $table->increments('id'); + $table->morphs('imageable'); + $table->timestamps(); + }); + } + + /** + * Tear down the database schema. + * + * @return void + */ + protected function tearDown(): void + { + $this->schema()->drop('test_posts'); + $this->schema()->drop('test_images'); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('image')); + $image = $post->image; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('image')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('image'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenLazyLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::all(); + + foreach ($posts as $post) { + $this->assertFalse($post->relationLoaded('guessedImage')); + $image = $post->guessedImage; + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneGuessedInverseRelationIsProperlySetToParentWhenEagerLoaded() + { + MorphOneInverseImageModel::factory(6)->create(); + $posts = MorphOneInversePostModel::with('guessedImage')->get(); + + foreach ($posts as $post) { + $image = $post->getRelation('guessedImage'); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenMaking() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->make(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->create(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenCreatingQuietly() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->createQuietly(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenForceCreating() + { + $post = MorphOneInversePostModel::create(); + + $image = $post->image()->forceCreate(); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSaving() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->save($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenSavingQuietly() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::make(); + + $this->assertFalse($image->relationLoaded('imageable')); + $post->image()->saveQuietly($image); + + $this->assertTrue($image->relationLoaded('imageable')); + $this->assertSame($post, $image->imageable); + } + + public function testMorphOneInverseRelationIsProperlySetToParentWhenUpdating() + { + $post = MorphOneInversePostModel::create(); + $image = MorphOneInverseImageModel::factory()->create(); + + $this->assertTrue($post->isNot($image->imageable)); + + $post->image()->save($image); + + $this->assertTrue($post->is($image->imageable)); + $this->assertSame($post, $image->imageable); + } + + /** + * Helpers... + */ + + /** + * Get a database connection instance. + * + * @return \Illuminate\Database\Connection + */ + protected function connection($connection = 'default') + { + return Eloquent::getConnectionResolver()->connection($connection); + } + + /** + * Get a schema builder instance. + * + * @return \Illuminate\Database\Schema\Builder + */ + protected function schema($connection = 'default') + { + return $this->connection($connection)->getSchemaBuilder(); + } +} + +class MorphOneInversePostModel extends Model +{ + use HasFactory; + + protected $table = 'test_posts'; + protected $fillable = ['id']; + + protected static function newFactory() + { + return new MorphOneInversePostModelFactory(); + } + + public function image(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse('imageable'); + } + + public function guessedImage(): MorphOne + { + return $this->morphOne(MorphOneInverseImageModel::class, 'imageable')->inverse(); + } +} + +class MorphOneInversePostModelFactory extends Factory +{ + protected $model = MorphOneInversePostModel::class; + + public function definition() + { + return []; + } +} + +class MorphOneInverseImageModel extends Model +{ + use HasFactory; + + protected $table = 'test_images'; + protected $fillable = ['id', 'imageable_type', 'imageable_id']; + + protected static function newFactory() + { + return new MorphOneInverseImageModelFactory(); + } + + public function imageable(): MorphTo + { + return $this->morphTo('imageable'); + } +} + +class MorphOneInverseImageModelFactory extends Factory +{ + protected $model = MorphOneInverseImageModel::class; + + public function definition() + { + return [ + 'imageable_type' => MorphOneInversePostModel::class, + 'imageable_id' => MorphOneInversePostModel::factory(), + ]; + } +} diff --git a/tests/Database/DatabaseEloquentInverseRelationTest.php b/tests/Database/DatabaseEloquentInverseRelationTest.php new file mode 100755 index 000000000000..100921ea18da --- /dev/null +++ b/tests/Database/DatabaseEloquentInverseRelationTest.php @@ -0,0 +1,364 @@ +shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + new HasInverseRelationStub($builder, new HasInverseRelationParentStub()); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationIsEmptyString() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + $this->expectException(RelationNotFoundException::class); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse(''); + } + + public function testBuilderCallbackIsNotSetIfInverseRelationshipDoesNotExist() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->never(); + + $this->expectException(RelationNotFoundException::class); + + (new HasInverseRelationStub($builder, new HasInverseRelationParentStub()))->inverse('foo'); + } + + public function testWithoutInverseMethodRemovesInverseRelation() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub())); + $this->assertNull($relation->getInverseRelationship()); + + $relation->inverse('test'); + $this->assertSame('test', $relation->getInverseRelationship()); + + $relation->withoutInverse(); + $this->assertNull($relation->getInverseRelationship()); + } + + public function testBuilderCallbackIsAppliedWhenInverseRelationIsSet() + { + $parent = new HasInverseRelationParentStub(); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use ($parent) { + $relation = (new \ReflectionFunction($callback))->getClosureThis(); + + return $relation instanceof HasInverseRelationStub && $relation->getParent() === $parent; + })->once()->andReturnSelf(); + + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + } + + public function testBuilderCallbackAppliesInverseRelationToAllModelsInResult() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + (new HasInverseRelationStub($builder, $parent))->inverse('test'); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + $this->assertFalse($model->relationLoaded('test')); + } + + $results = $afterQuery($results); + + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertTrue($model->relationLoaded('test')); + $this->assertSame($parent, $model->test); + } + } + + public function testInverseRelationIsNotSetIfInverseRelationIsUnset() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationRelatedStub()); + + // Capture the callback so that we can manually call it. + $afterQuery = null; + $builder->shouldReceive('afterQuery')->withArgs(function (\Closure $callback) use (&$afterQuery) { + return (bool) $afterQuery = $callback; + })->once()->andReturnSelf(); + + $parent = new HasInverseRelationParentStub(); + $relation = (new HasInverseRelationStub($builder, $parent)); + $relation->inverse('test'); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + $results = $afterQuery($results); + foreach ($results as $model) { + $this->assertNotEmpty($model->getRelations()); + $this->assertSame($parent, $model->getRelation('test')); + } + + // Reset the inverse relation + $relation->withoutInverse(); + + $results = new Collection(array_fill(0, 5, new HasInverseRelationRelatedStub())); + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + foreach ($results as $model) { + $this->assertEmpty($model->getRelations()); + } + } + + public function testProvidesPossibleInverseRelationBasedOnParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasOneInverseChildModel); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $possibleRelations = ['hasInverseRelationParentStub', 'parentStub', 'owner']; + $this->assertSame($possibleRelations, array_values($relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossibleInverseRelationBasedOnForeignKey() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertTrue(in_array('test', $relation->exposeGetPossibleInverseRelations())); + } + + public function testProvidesPossibleRecursiveRelationsIfRelatedIsTheSameClassAsParent() + { + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new HasInverseRelationParentStub); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertTrue(in_array('parent', $relation->exposeGetPossibleInverseRelations())); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testGuessesInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub)); + + $this->assertSame($guessedRelation, $relation->exposeGuessInverseRelation()); + } + + public function testGuessesPossibleInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id')); + + $this->assertSame('test', $relation->exposeGuessInverseRelation()); + } + + public function testGuessesRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + + $relation = (new HasInverseRelationStub($builder, $parent)); + + $this->assertSame('parent', $relation->exposeGuessInverseRelation()); + } + + #[DataProvider('guessedParentRelationsDataProvider')] + public function testSetsGuessedInverseRelationBasedOnParent($guessedRelation) + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === $guessedRelation); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub))->inverse(); + + $this->assertSame($guessedRelation, $relation->getInverseRelationship()); + } + + public function testSetsRecursiveInverseRelationsIfRelatedIsSameClassAsParent() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'parent'); + + $parent = clone $related; + $parent->shouldReceive('getForeignKey')->andReturn('recursive_parent_id'); + $parent->shouldReceive('getKeyName')->andReturn('id'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, $parent))->inverse(); + + $this->assertSame('parent', $relation->getInverseRelationship()); + } + + public function testSetsGuessedInverseRelationBasedOnForeignKey() + { + $related = m::mock(Model::class); + $related->shouldReceive('isRelation')->andReturnUsing(fn ($relation) => $relation === 'test'); + + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn($related); + $builder->shouldReceive('afterQuery')->once()->andReturnSelf(); + + $relation = (new HasInverseRelationStub($builder, new HasInverseRelationParentStub, 'test_id'))->inverse(); + + $this->assertSame('test', $relation->getInverseRelationship()); + } + + public static function guessedParentRelationsDataProvider() + { + yield ['hasInverseRelationParentStub']; + yield ['parentStub']; + yield ['owner']; + } +} + +class HasInverseRelationParentStub extends Model +{ + protected static $unguarded = true; + protected $primaryKey = 'id'; + + public function getForeignKey() + { + return 'parent_stub_id'; + } +} + +class HasInverseRelationRelatedStub extends Model +{ + protected static $unguarded = true; + protected $primaryKey = 'id'; + + public function getForeignKey() + { + return 'child_stub_id'; + } + + public function test(): BelongsTo + { + return $this->belongsTo(HasInverseRelationParentStub::class); + } +} + +class HasInverseRelationStub extends Relation +{ + use SupportsInverseRelations; + + public function __construct( + Builder $query, + Model $parent, + protected ?string $foreignKey = null, + ) { + parent::__construct($query, $parent); + $this->foreignKey ??= Str::of(class_basename($parent))->snake()->finish('_id')->toString(); + } + + public function getForeignKeyName() + { + return $this->foreignKey; + } + + // None of these methods will actually be called - they're just needed to fill out `Relation` + public function match(array $models, Collection $results, $relation) + { + return $models; + } + + public function initRelation(array $models, $relation) + { + return $models; + } + + public function getResults() + { + return $this->query->get(); + } + + public function addConstraints() + { + // + } + + public function addEagerConstraints(array $models) + { + // + } + + // Expose access to protected methods for testing + public function exposeGetPossibleInverseRelations(): array + { + return $this->getPossibleInverseRelations(); + } + + public function exposeGuessInverseRelation(): string|null + { + return $this->guessInverseRelation(); + } +} diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index 11757e80384b..d9f73265ffb0 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -1156,6 +1156,28 @@ public function testPushManyRelation() $this->assertEquals([2, 3], $model->relationMany->pluck('id')->all()); } + public function testPushCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertTrue($parent->push()); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + public function testNewQueryReturnsEloquentQueryBuilder() { $conn = m::mock(Connection::class); @@ -1231,6 +1253,79 @@ public function testToArray() $this->assertSame('appended', $array['appendable']); } + public function testToArrayWithCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertSame( + [ + 'id' => 1, + 'parent_id' => null, + 'self' => ['id' => 1, 'parent_id' => null], + 'children' => [ + [ + 'id' => 2, + 'parent_id' => 1, + 'parent' => ['id' => 1, 'parent_id' => null], + 'self' => ['id' => 2, 'parent_id' => 1], + ], + [ + 'id' => 3, + 'parent_id' => 1, + 'parent' => ['id' => 1, 'parent_id' => null], + 'self' => ['id' => 3, 'parent_id' => 1], + ], + ], + ], + $parent->toArray() + ); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + + public function testGetQueueableRelationsWithCircularRelations() + { + $parent = new EloquentModelWithRecursiveRelationshipsStub(['id' => 1, 'parent_id' => null]); + $lastId = $parent->id; + $parent->setRelation('self', $parent); + + $children = new Collection(); + for ($count = 0; $count < 2; $count++) { + $child = new EloquentModelWithRecursiveRelationshipsStub(['id' => ++$lastId, 'parent_id' => $parent->id]); + $child->setRelation('parent', $parent); + $child->setRelation('self', $child); + $children->push($child); + } + $parent->setRelation('children', $children); + + try { + $this->assertSame( + [ + 'self', + 'children', + 'children.parent', + 'children.self', + ], + $parent->getQueueableRelations() + ); + } catch (\RuntimeException $e) { + $this->fail($e->getMessage()); + } + } + public function testVisibleCreatesArrayWhitelist() { $model = new EloquentModelStub; @@ -3683,3 +3778,95 @@ public function set(Model $model, string $key, mixed $value, array $attributes): }; } } + +class EloquentModelWithRecursiveRelationshipsStub extends Model +{ + public $fillable = ['id', 'parent_id']; + + protected static \WeakMap $recursionDetectionCache; + + public function getQueueableRelations() + { + try { + $this->stepIn(); + + return parent::getQueueableRelations(); + } finally { + $this->stepOut(); + } + } + + public function push() + { + try { + $this->stepIn(); + + return parent::push(); + } finally { + $this->stepOut(); + } + } + + public function save(array $options = []) + { + return true; + } + + public function relationsToArray() + { + try { + $this->stepIn(); + + return parent::relationsToArray(); + } finally { + $this->stepOut(); + } + } + + public function parent(): BelongsTo + { + return $this->belongsTo(static::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(static::class, 'parent_id'); + } + + public function self(): BelongsTo + { + return $this->belongsTo(static::class, 'id'); + } + + protected static function getRecursionDetectionCache() + { + return static::$recursionDetectionCache ??= new \WeakMap; + } + + protected function getRecursionDepth(): int + { + $cache = static::getRecursionDetectionCache(); + + return $cache->offsetExists($this) ? $cache->offsetGet($this) : 0; + } + + protected function stepIn(): void + { + $depth = $this->getRecursionDepth(); + + if ($depth > 1) { + throw new \RuntimeException('Recursion detected'); + } + static::getRecursionDetectionCache()->offsetSet($this, $depth + 1); + } + + protected function stepOut(): void + { + $cache = static::getRecursionDetectionCache(); + if ($depth = $this->getRecursionDepth()) { + $cache->offsetSet($this, $depth - 1); + } else { + $cache->offsetUnset($this); + } + } +} diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index aa32a552566f..f12f27f0e26b 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -1325,6 +1325,14 @@ public function testWhereAll() $builder->select('*')->from('users')->whereAll(['last_name', 'email'], 'not like', '%Otwell%'); $this->assertSame('select * from "users" where ("last_name" not like ? and "email" not like ?)', $builder->toSql()); $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAll([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where (("last_name" like ?) and ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testOrWhereAll() @@ -1343,6 +1351,14 @@ public function testOrWhereAll() $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAll(['last_name', 'email'], '%Otwell%'); $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" = ? and "email" = ?)', $builder->toSql()); $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAll([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or (("last_name" like ?) and ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testWhereAny() @@ -1356,6 +1372,14 @@ public function testWhereAny() $builder->select('*')->from('users')->whereAny(['last_name', 'email'], '%Otwell%'); $this->assertSame('select * from "users" where ("last_name" = ? or "email" = ?)', $builder->toSql()); $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereAny([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testOrWhereAny() @@ -1374,6 +1398,14 @@ public function testOrWhereAny() $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAny(['last_name', 'email'], '%Otwell%'); $this->assertSame('select * from "users" where "first_name" like ? or ("last_name" = ? or "email" = ?)', $builder->toSql()); $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereAny([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testWhereNone() @@ -1392,6 +1424,14 @@ public function testWhereNone() $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->whereNone(['last_name', 'email'], 'like', '%Otwell%'); $this->assertSame('select * from "users" where "first_name" like ? and not ("last_name" like ? or "email" like ?)', $builder->toSql()); $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereNone([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where not (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testOrWhereNone() @@ -1410,6 +1450,14 @@ public function testOrWhereNone() $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereNone(['last_name', 'email'], '%Otwell%'); $this->assertSame('select * from "users" where "first_name" like ? or not ("last_name" = ? or "email" = ?)', $builder->toSql()); $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->where('first_name', 'like', '%Taylor%')->orWhereNone([ + fn (Builder $query) => $query->where('last_name', 'like', '%Otwell%'), + fn (Builder $query) => $query->where('email', 'like', '%Otwell%'), + ]); + $this->assertSame('select * from "users" where "first_name" like ? or not (("last_name" like ?) or ("email" like ?))', $builder->toSql()); + $this->assertEquals(['%Taylor%', '%Otwell%', '%Otwell%'], $builder->getBindings()); } public function testUnions() diff --git a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php index 6454a9d82c60..c7f61d32bfd0 100755 --- a/tests/Database/DatabaseSQLiteSchemaGrammarTest.php +++ b/tests/Database/DatabaseSQLiteSchemaGrammarTest.php @@ -7,8 +7,12 @@ use Illuminate\Database\Query\Expression; use Illuminate\Database\Query\Processors\SQLiteProcessor; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Schema\BlueprintState; +use Illuminate\Database\Schema\ColumnDefinition; use Illuminate\Database\Schema\ForeignIdColumnDefinition; +use Illuminate\Database\Schema\ForeignKeyDefinition; use Illuminate\Database\Schema\Grammars\SQLiteGrammar; +use Illuminate\Database\Schema\IndexDefinition; use Illuminate\Database\Schema\SQLiteBuilder; use Mockery as m; use PHPUnit\Framework\TestCase; @@ -998,6 +1002,163 @@ public function testDroppingColumnsWorks() $this->assertEquals(['alter table "users" drop column "name"'], $blueprint->toSql($this->getConnection(), $this->getGrammar())); } + public function testBlueprintInitialState() + { + $db = new Manager; + + $db->addConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => 'prefix_', + ]); + + $connection = $db->getConnection(); + $grammar = new SQLiteGrammar(); + $grammar->setConnection($connection); + $grammar->setTablePrefix($connection->getTablePrefix()); + + $schema = $connection->getSchemaBuilder(); + + $schema->create('users', function (Blueprint $table) { + $table->string('name'); + $table->string('email'); + }); + + $schema->create('posts', function (Blueprint $table) { + $table->increments('id'); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->integer('parent_id')->nullable()->index('parent_id'); + $table->string('status')->default('A'); + $table->boolean('is_active')->virtualAs('"status" = \'A\''); + $table->boolean('has_parent')->storedAs('"parent_id" IS NOT NULL'); + $table->string('title')->collation('RTRIM'); + }); + + $blueprint = new Blueprint('posts', null, $connection->getTablePrefix()); + $state = new BlueprintState($blueprint, $connection, $grammar); + + $this->assertCount(7, $state->getColumns()); + $this->assertCount(1, $state->getIndexes()); + $this->assertCount(1, $state->getForeignKeys()); + $this->assertSame(['id'], $state->getPrimaryKey()->get('columns')); + $this->assertSame( + [ + [ + 'name' => 'id', + 'type' => 'integer', + 'full_type_definition' => 'integer', + 'nullable' => false, + 'default' => null, + 'autoIncrement' => true, + 'collation' => null, + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => null, + ], + [ + 'name' => 'user_id', + 'type' => 'integer', + 'full_type_definition' => 'integer', + 'nullable' => false, + 'default' => null, + 'autoIncrement' => false, + 'collation' => null, + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => null, + ], + [ + 'name' => 'parent_id', + 'type' => 'integer', + 'full_type_definition' => 'integer', + 'nullable' => true, + 'default' => null, + 'autoIncrement' => false, + 'collation' => null, + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => null, + ], + [ + 'name' => 'status', + 'type' => 'varchar', + 'full_type_definition' => 'varchar', + 'nullable' => false, + 'default' => "'A'", + 'autoIncrement' => false, + 'collation' => null, + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => null, + ], + [ + 'name' => 'is_active', + 'type' => 'tinyint', + 'full_type_definition' => 'tinyint(1)', + 'nullable' => true, + 'default' => null, + 'autoIncrement' => false, + 'collation' => null, + 'comment' => null, + 'virtualAs' => '"status" = \'A\'', + 'storedAs' => null, + ], + [ + 'name' => 'has_parent', + 'type' => 'tinyint', + 'full_type_definition' => 'tinyint(1)', + 'nullable' => true, + 'default' => null, + 'autoIncrement' => false, + 'collation' => null, + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => '"parent_id" IS NOT NULL', + ], + [ + 'name' => 'title', + 'type' => 'varchar', + 'full_type_definition' => 'varchar', + 'nullable' => false, + 'default' => null, + 'autoIncrement' => false, + 'collation' => 'rtrim', + 'comment' => null, + 'virtualAs' => null, + 'storedAs' => null, + ], + ], + array_map( + fn (ColumnDefinition $definition) => array_replace($definition->toArray(), [ + 'default' => $definition->value('default') ? $definition->value('default')->getValue($grammar) : $definition->value('default'), + ]), + $state->getColumns() + ) + ); + $this->assertSame( + [ + [ + 'name' => 'index', + 'index' => 'parent_id', + 'columns' => ['parent_id'], + ], + ], + array_map(fn (IndexDefinition $definition) => $definition->toArray(), $state->getIndexes()) + ); + $this->assertSame( + [ + [ + 'columns' => ['user_id'], + 'on' => 'users', + 'references' => ['id'], + 'onUpdate' => 'no action', + 'onDelete' => 'cascade', + ], + ], + array_map(fn (ForeignKeyDefinition $definition) => $definition->toArray(), $state->getForeignKeys()) + ); + } + protected function getConnection() { $connection = m::mock(Connection::class); diff --git a/tests/Foundation/Configuration/MiddlewareTest.php b/tests/Foundation/Configuration/MiddlewareTest.php index 8fde56c7a9c1..d4ede8561a05 100644 --- a/tests/Foundation/Configuration/MiddlewareTest.php +++ b/tests/Foundation/Configuration/MiddlewareTest.php @@ -178,6 +178,7 @@ public function testTrustHeaders() Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO | + Request::HEADER_X_FORWARDED_PREFIX | Request::HEADER_X_FORWARDED_AWS_ELB, $method->invoke($middleware)); $property->setValue($middleware, Request::HEADER_X_FORWARDED_AWS_ELB); diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 2ebb6a99118d..898334c7ab56 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -377,6 +377,14 @@ public function testGetTableNameFromModel() $this->assertEquals($this->table, $this->getTable(ProductStub::class)); $this->assertEquals($this->table, $this->getTable(new ProductStub)); $this->assertEquals($this->table, $this->getTable($this->table)); + $this->assertEquals('all_products', $this->getTable((new ProductStub)->setTable('all_products'))); + } + + public function testGetTableConnectionNameFromModel() + { + $this->assertSame(null, $this->getTableConnection(ProductStub::class)); + $this->assertSame(null, $this->getTableConnection(new ProductStub)); + $this->assertSame('mysql', $this->getTableConnection((new ProductStub)->setConnection('mysql'))); } public function testGetTableCustomizedDeletedAtColumnName() diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php index 8f19c8e7d379..eecae811997b 100644 --- a/tests/Foundation/FoundationViteTest.php +++ b/tests/Foundation/FoundationViteTest.php @@ -1305,7 +1305,7 @@ public function testItCanPrefetchEntrypoint() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml(); + $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'], @@ -1381,7 +1381,7 @@ public function testItHandlesSpecifyingPageWithAppJs() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js', 'resources/js/Pages/Auth/Login.vue'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml(); + $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js', 'resources/js/Pages/Auth/Login.vue'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'], @@ -1411,7 +1411,7 @@ public function testItCanSpecifyWaterfallChunks() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->useWaterfallPrefetching(concurrency: 10)->toHtml(); + $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 10)->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'], @@ -1447,7 +1447,7 @@ public function testItCanPrefetchAggressively() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->useAggressivePrefetching()->toHtml(); + $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch()->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'], @@ -1501,7 +1501,7 @@ public function testAddsAttributesToPrefetchTags() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall'))->useCspNonce('abc123')->toHtml(); + $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3))->useCspNonce('abc123')->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'], @@ -1537,7 +1537,7 @@ public function testItNormalisesAttributes() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->usePreloadTagAttributes([ + $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->usePreloadTagAttributes([ 'key' => 'value', 'key-only', 'true-value' => true, @@ -1580,7 +1580,7 @@ public function testItPrefetchesCss() $this->makeViteManifest($manifest, $buildDir); app()->usePublicPath(__DIR__); - $html = (string) ViteFacade::withEntryPoints(['resources/js/admin.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml(); + $html = (string) ViteFacade::withEntryPoints(['resources/js/admin.js'])->useBuildDirectory($buildDir)->prefetch(concurrency: 3)->toHtml(); $expectedAssets = Js::from([ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'], @@ -1652,6 +1652,47 @@ public function testItPrefetchesCss() $this->cleanViteManifest($buildDir); } + public function testSupportCspNonceInPrefetchScript() + { + $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json')); + $buildDir = Str::random(); + $this->makeViteManifest($manifest, $buildDir); + app()->usePublicPath(__DIR__); + + $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])) + ->useCspNonce('abc123') + ->useBuildDirectory($buildDir) + ->prefetch() + ->toHtml(); + $this->assertStringContainsString('