diff --git a/illuminate/Database/Console/ShowModelCommand.php b/illuminate/Database/Console/ShowModelCommand.php index 93606cf..afe7e3d 100644 --- a/illuminate/Database/Console/ShowModelCommand.php +++ b/illuminate/Database/Console/ShowModelCommand.php @@ -465,7 +465,7 @@ protected function getColumnType($column) */ protected function getColumnDefault($column, $model) { - $attributeDefault = $model->getAttributes()[$column->getName()] ?? null; + $attributeDefault = $model->getAttributeFromArray($column->getName()); return match (true) { $attributeDefault instanceof BackedEnum => $attributeDefault->value, diff --git a/illuminate/Database/Eloquent/Builder.php b/illuminate/Database/Eloquent/Builder.php index 38d5ccf..aaf00ab 100755 --- a/illuminate/Database/Eloquent/Builder.php +++ b/illuminate/Database/Eloquent/Builder.php @@ -1190,7 +1190,7 @@ protected function addUpdatedAtColumn(array $values) ) { $timestamp = $this->model->newInstance() ->forceFill([$column => $timestamp]) - ->getAttributes()[$column] ?? $timestamp; + ->getAttributeFromArray($column) ?? $timestamp; } $values = array_merge([$column => $timestamp], $values); diff --git a/illuminate/Database/Eloquent/Concerns/HasAttributes.php b/illuminate/Database/Eloquent/Concerns/HasAttributes.php index 71cf01b..ac3f053 100644 --- a/illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -64,6 +64,18 @@ trait HasAttributes */ protected $changes = []; + /** + * Temporary cache to avoid multiple getDirty calls generating multiple set calls for + * sync/merge casted attributes to objects to persist the possible changes made to those objects + */ + protected ?array $tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + + /** + * Temporary original cache to prevent changes in created,updated,saved events from getting + * into $original without being saved into DB + */ + protected ?array $tmpOriginalBeforeAfterEvents = null; + /** * The attributes that should be cast. * @@ -503,18 +515,30 @@ protected function throwMissingAttributeExceptionIfApplicable($key) */ public function getAttributeValue($key) { - return $this->transformModelValue($key, $this->getAttributeFromArray($key)); + return $this->transformModelValue($key, $this->getAttributeFromArray($key, true)); } /** - * Get an attribute from the $attributes array. + * Get an attribute from the $attributes array without transformation + * @see self::getAttributeValue * * @param string $key + * @param bool $mergeAllAttributesFromCachedCasts * @return mixed */ protected function getAttributeFromArray($key) { - return $this->getAttributes()[$key] ?? null; + if ( + true === (\func_get_args()[1] ?? false) + && ($this->hasGetMutator($key) || $this->hasAttributeGetMutator($key)) + ) { + return $this->getAttributes()[$key] ?? null; + } + + $this->mergeAttributesFromClassCasts($key); + $this->mergeAttributesFromAttributeCasts($key); + + return $this->attributes[$key] ?? null; } /** @@ -1798,12 +1822,18 @@ protected function mergeAttributesFromCachedCasts() /** * Merge the cast class attributes back into the model. - * + * @param string|array $keys * @return void */ protected function mergeAttributesFromClassCasts() { - foreach ($this->classCastCache as $key => $value) { + $k = \func_get_args()[0] ?? null; + + $classCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->classCastCache, \array_flip(\array_values((array)$k))) : + $this->classCastCache; + + foreach ($classCastCache as $key => $value) { $caster = $this->resolveCasterClass($key); $this->attributes = array_merge( @@ -1817,12 +1847,18 @@ protected function mergeAttributesFromClassCasts() /** * Merge the cast class attributes back into the model. - * + * @param string|array $keys * @return void */ protected function mergeAttributesFromAttributeCasts() { - foreach ($this->attributeCastCache as $key => $value) { + $k = \func_get_args()[0] ?? null; + + $attributeCastCache = \is_string($k) || \is_array($k) ? + \array_intersect_key($this->attributeCastCache, \array_flip(\array_values((array)$k))) : + $this->attributeCastCache; + + foreach ($attributeCastCache as $key => $value) { $attribute = $this->{Str::camel($key)}(); if ($attribute->get && !$attribute->set) { @@ -1857,12 +1893,14 @@ protected function normalizeCastClassResponse($key, $value) /** * Get all of the current attributes on the model. - * + * @param bool $withoutMergeAttributesFromCachedCasts * @return array */ public function getAttributes() { - $this->mergeAttributesFromCachedCasts(); + if (true !== (\func_get_args()[0] ?? null)) { + $this->mergeAttributesFromCachedCasts(); + } return $this->attributes; } @@ -1996,10 +2034,8 @@ public function syncOriginalAttributes($attributes) { $attributes = is_array($attributes) ? $attributes : func_get_args(); - $modelAttributes = $this->getAttributes(); - foreach ($attributes as $attribute) { - $this->original[$attribute] = $modelAttributes[$attribute]; + $this->original[$attribute] = $this->getAttributeFromArray($attribute); } return $this; @@ -2025,10 +2061,7 @@ public function syncChanges() */ public function isDirty($attributes = null) { - return $this->hasChanges( - $this->getDirty(), - is_array($attributes) ? $attributes : func_get_args() - ); + return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); } /** @@ -2098,14 +2131,36 @@ protected function hasChanges($changes, $attributes = null) /** * Get the attributes that have been changed since the last sync. - * + * @param string|array $attributes * @return array */ public function getDirty() { + $args = \func_get_args(); + $attributes = (array)($args[0] ?? []); + + if (isset($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts)) { + return [] !== $attributes ? + \array_intersect_key($this->tmpDirtyIfAttributesAreSyncedFromCashedCasts, \array_flip($attributes)) : + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts; + } + $dirty = []; - foreach ($this->getAttributes() as $key => $value) { + if ([] === $attributes) { + foreach ($this->getAttributes() as $key => $value) { + if (!$this->originalIsEquivalent($key)) { + $dirty[$key] = $value; + } + } + + return $dirty; + } + + foreach ($attributes as $key) { + /** This will call $this->mergeAttributesFromCachedCasts($key) before the if condition */ + $value = $this->getAttributeFromArray($key); + if (!$this->originalIsEquivalent($key)) { $dirty[$key] = $value; } diff --git a/illuminate/Database/Eloquent/Model.php b/illuminate/Database/Eloquent/Model.php index fe2f4e8..164f8de 100644 --- a/illuminate/Database/Eloquent/Model.php +++ b/illuminate/Database/Eloquent/Model.php @@ -995,7 +995,7 @@ function () use ($column) { $this->fireModelEvent('updated', false); - $this->syncOriginalAttribute($column); + $this->syncOriginalAttributes(\array_keys($this->changes)); } ); } @@ -1137,26 +1137,58 @@ public function saveQuietly(array $options = []) */ public function save(array $options = []) { - $this->mergeAttributesFromCachedCasts(); - $query = $this->newModelQuery(); // If the "saving" event returns false we'll bail out of the save and return // false, indicating that the save failed. This provides a chance for any // listeners to cancel save operations if validations fail or whatever. + /** Saving event might change the model so, no point in calling $this->mergeAttributesFromCachedCasts() before */ if ($this->fireModelEvent('saving') === false) { return false; } - // If the model already exists in the database we can just update our record - // that is already in this database using the current IDs in this "where" - // clause to only update this model. Otherwise, we'll just insert them. if ($this->exists) { - $saved = $this->isDirty() ? $this->performUpdate($query) : null; - } else { - // If the model is brand new, we'll insert it into our database and set the - // ID attribute on the model to the value of the newly inserted row's ID - // which is typically an auto-increment value managed by the database. + /** $this->getDirty() will merge/sync attributes from cached casts objects */ + $isDirty = [] !== ($dirty = $this->getDirty()); + + if (!$isDirty) { + /** don't call saved event like in Laravel: https://github.com/laravel/framework/issues/56254 */ + return true; + } + + try { + /** We will try to optimize the execution by caching $dirty array BUT, + multiple set calls will be needed (https://github.com/laravel/framework/discussions/31778) + WHEN $this->usesTimestamps() or WHEN there are listeners for updating event because they can do changes + so, $this->getDirtyForUpdate() and $this->syncChanges() will call $this->getDirty() which will call + getAttributes() */ + if (!$this->getEventDispatcher()->hasListeners('eloquent.updating: ' . $this::class)) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = $dirty; + unset($dirty); + } + + if ($this->performUpdate($query)) { + $this->finishSave($options + ['touch' => $isDirty]); + + return true; + } + + return false; + } finally { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = null; + } + } + + /** $this->isDirty() will merge/sync attributes from cached casts objects */ + $isDirty = $this->isDirty(); + + /** Multiple set calls are needed (https://github.com/laravel/framework/discussions/31778) because: + - $this->performInsert can do changes, + - creating event can do changes so, + $this->getAttributesForInsert() and $this->syncOriginal() will call $this->getAttributes() */ + + try { $saved = $this->performInsert($query); if ( @@ -1165,16 +1197,15 @@ public function save(array $options = []) ) { $this->setConnection($connection->getName()); } - } - // If the model is successfully saved, we need to do a few more things once - // that is done. We will call the "saved" method here to run any actions - // we need to happen after a model gets successfully saved right here. - if (true === $saved) { - $this->finishSave($options); + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + } finally { + $this->tmpOriginalBeforeAfterEvents = null; } - return $saved ?? true; + return $saved; } /** @@ -1200,10 +1231,17 @@ protected function finishSave(array $options) { $this->fireModelEvent('saved', false); - if ($this->isDirty() && ($options['touch'] ?? true)) { + if ($options['touch'] ?? true) { $this->touchOwners(); } + if (isset($this->tmpOriginalBeforeAfterEvents)) { + $this->original = $this->tmpOriginalBeforeAfterEvents; + $this->tmpOriginalBeforeAfterEvents = null; + + return; + } + $this->syncOriginal(); } @@ -1226,6 +1264,7 @@ protected function performUpdate(Builder $query) // update timestamp on the model which are maintained by us for developer // convenience. Then we will just continue saving the model instances. if ($this->usesTimestamps()) { + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; $this->updateTimestamps(); } @@ -1234,11 +1273,14 @@ protected function performUpdate(Builder $query) // models are updated, giving them a chance to do any special processing. $dirty = $this->getDirtyForUpdate(); - if (count($dirty) > 0) { + if ([] !== $dirty) { $this->setKeysForSaveQuery($query)->update($dirty); $this->syncChanges(); + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + $this->fireModelEvent('updated', false); } @@ -1332,6 +1374,8 @@ protected function performInsert(Builder $query) $query->insert($attributes); } + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + // We will go ahead and set the exists property to true, so that it is set when // the created event is fired, just in case the developer tries to update it // during the event. This will allow them to do so and run an update here. @@ -2356,7 +2400,13 @@ public function __call($method, $parameters) { $lowerMethod = \strtolower($method); - if (\in_array($lowerMethod, ['increment', 'decrement', 'incrementquietly', 'decrementquietly'], true)) { + if ( + \in_array( + $lowerMethod, + ['increment', 'decrement', 'incrementquietly', 'decrementquietly', 'getattributefromarray'], + true + ) + ) { return $this->$method(...$parameters); } diff --git a/illuminate/Database/Eloquent/Relations/BelongsToMany.php b/illuminate/Database/Eloquent/Relations/BelongsToMany.php index 8728177..2ca1e7b 100755 --- a/illuminate/Database/Eloquent/Relations/BelongsToMany.php +++ b/illuminate/Database/Eloquent/Relations/BelongsToMany.php @@ -1211,12 +1211,12 @@ protected function migratePivotAttributes(Model $model) { $values = []; - foreach ($model->getAttributes() as $key => $value) { + foreach (\array_keys($model->getAttributes(true)) as $key) { // To get the pivots attributes we will just take any of the attributes which // begin with "pivot_" and add those to this arrays, as well as unsetting // them from the parent's models since they exist in a different table. if (str_starts_with($key, 'pivot_')) { - $values[substr($key, 6)] = $value; + $values[substr($key, 6)] = $model->getAttributeFromArray($key); unset($model->$key); }