diff --git a/src/Eloquent/CustomRelations/HasCleverRelationships.php b/src/Eloquent/CustomRelations/HasCleverRelationships.php index b2235ed..45f2848 100644 --- a/src/Eloquent/CustomRelations/HasCleverRelationships.php +++ b/src/Eloquent/CustomRelations/HasCleverRelationships.php @@ -397,6 +397,27 @@ public function __construct( $relationName ); } + + /** + * @inheritdoc + */ + protected function migratePivotAttributes(Model $model): array + { + $values = []; + + 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)] = $model->getAttributeFromArray($key); + + unset($model->$key); + } + } + + return $values; + } }; } diff --git a/src/Http/Controllers/ResourceControllerTrait.php b/src/Http/Controllers/ResourceControllerTrait.php index cfc1e2d..bc86e56 100644 --- a/src/Http/Controllers/ResourceControllerTrait.php +++ b/src/Http/Controllers/ResourceControllerTrait.php @@ -155,13 +155,21 @@ public function update(string $identifier, Request $request): JsonResponse $all = $request->all(); try { - $model = $this->resourceService->get($identifier, appendIndex: false); - $model->fill(GeneralHelper::filterDataByKeys($all, \array_diff( + $toFill = GeneralHelper::filterDataByKeys($all, \array_diff( $this->resourceService->getModelColumns(), $this->resourceService->getIgnoreExternalUpdateFor() - ))); + )); + + if ($toFill === []) { + return GeneralHelper::app(JsonResponse::class, [ + 'data' => $this->resourceService->get($identifier)->toArray(), + 'status' => 200 + ]); + } + + $model = $this->resourceService->get($identifier, appendIndex: false)->fill($toFill); /** $request can contain also files so, overwrite this function to handle them */ - $request->forceReplace($model->getDirty()); + $request->forceReplace($model->getDirty(\array_keys($toFill))); return GeneralHelper::app(JsonResponse::class, [ 'data' => $this->resourceService->update($identifier, $this->validateUpdateRequest($request)) diff --git a/src/Models/BaseModel.php b/src/Models/BaseModel.php index 9469d20..c9eb269 100644 --- a/src/Models/BaseModel.php +++ b/src/Models/BaseModel.php @@ -3,6 +3,9 @@ namespace MacropaySolutions\LaravelCrudWizard\Models; use Carbon\Carbon; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; +use Illuminate\Database\Connection; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; @@ -46,6 +49,19 @@ abstract class BaseModel extends Model protected $hidden = [ 'laravel_through_key' ]; + + /** + * 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; + private array $incrementsToRefresh = []; /** @@ -381,7 +397,7 @@ public function getAttributeValue($key): mixed $this->incrementsToRefresh = []; } - $return = parent::getAttributeValue($key); + $return = $this->transformModelValue($key, $this->getAttributeFromArray($key, true)); if ( $return !== null @@ -478,7 +494,13 @@ protected function initializeActiveRecordSegregationProperties(): void */ public function __call($method, $parameters) { - if (\in_array(\strtolower($method), ['incrementeach', 'decrementeach'], true)) { + $lowerMethod = \strtolower($method); + + if ($lowerMethod === 'getattributefromarray') { + return $this->$method(...$parameters); + } + + if (\in_array($lowerMethod, ['incrementeach', 'decrementeach'], true)) { /** \MacropaySolutions\MaravelRestWizard\Models\BaseModel::incrementBulk can be used instead */ throw new \BadMethodCallException(\sprintf( 'Call to unscoped method %s::%s(). Use $model->newQuery()->getQuery()->%s()' . @@ -555,4 +577,328 @@ public function unique($key = null, $strict = false): Collection } }; } + + /** + * Get all of the current attributes on the model. + * @param bool $withoutMergeAttributesFromCachedCasts + * @return array + */ + public function getAttributes(): array + { + if (true !== (\func_get_args()[0] ?? null)) { + $this->mergeAttributesFromCachedCasts(); + } + + return $this->attributes; + } + + /** + * @inheritdoc + */ + protected function getAttributesForInsert(): array + { + return $this->tmpOriginalBeforeAfterEvents = $this->getAttributes(); + } + + /** + * @inheritdoc + */ + public function syncOriginalAttributes($attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + foreach ($attributes as $attribute) { + $this->original[$attribute] = $this->getAttributeFromArray($attribute); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function isDirty($attributes = null): bool + { + return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); + } + + /** + * Get the attributes that have been changed since the last sync. + * @param string|array $attributes + * @return array + */ + public function getDirty(): array + { + $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 = []; + + 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; + } + } + + return $dirty; + } + + /** + * @inheritdoc + */ + public function save(array $options = []): bool + { + $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 ($this->exists) { + /** $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 ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } + + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + } finally { + $this->tmpOriginalBeforeAfterEvents = null; + } + + return $saved; + } + + /** + * @inheritdoc + */ + protected function finishSave(array $options): void + { + $this->fireModelEvent('saved', false); + + if ($options['touch'] ?? true) { + $this->touchOwners(); + } + + if (isset($this->tmpOriginalBeforeAfterEvents)) { + $this->original = $this->tmpOriginalBeforeAfterEvents; + $this->tmpOriginalBeforeAfterEvents = null; + + return; + } + + $this->syncOriginal(); + } + + /** + * @inheritdoc + */ + protected function performUpdate(Builder $query): bool + { + // If the updating event returns false, we will cancel the update operation so + // developers can hook Validation systems into their models and cancel this + // operation if the model does not pass validation. Otherwise, we update. + if ($this->fireModelEvent('updating') === false) { + return false; + } + + // First we need to create a fresh query instance and touch the creation and + // 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(); + } + + // Once we have run the update operation, we will fire the "updated" event for + // this model instance. This will allow developers to hook into these after + // models are updated, giving them a chance to do any special processing. + $dirty = $this->getDirtyForUpdate(); + + if ([] !== $dirty) { + $this->setKeysForSaveQuery($query)->update($dirty); + + $this->syncChanges(); + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + + $this->fireModelEvent('updated', false); + } + + return true; + } + + /** + * @inheritdoc + */ + protected function insertAndSetId(Builder $query, $attributes): void + { + $id = $query->insertGetId($attributes, $keyName = $this->getKeyName()); + + $this->setAttribute($keyName, $id); + + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + } + + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate(): array + { + return $this->getDirty(); + } + + /** + * Get an attribute from the $attributes array without transformation + * @see self::getAttributeValue + * + * @param string $key + * @param bool $mergeAllAttributesFromCachedCasts + * @return mixed + */ + protected function getAttributeFromArray($key): mixed + { + 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; + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromClassCasts(): void + { + $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( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromAttributeCasts(): void + { + $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) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + } + } } diff --git a/src/Models/ExcessiveSetOptimizerOnSaveTrait.php b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php new file mode 100644 index 0000000..b94d4e5 --- /dev/null +++ b/src/Models/ExcessiveSetOptimizerOnSaveTrait.php @@ -0,0 +1,374 @@ +$method(...$parameters); + } + + if (\in_array($lowerMethod, ['incrementeach', 'decrementeach'], true)) { + /** \MacropaySolutions\MaravelRestWizard\Models\BaseModel::incrementBulk can be used instead */ + throw new \BadMethodCallException(\sprintf( + 'Call to unscoped method %s::%s(). Use $model->newQuery()->getQuery()->%s()' . + ' for unscoped or $model->newQuery()->%s() for scoped behavior.', + static::class, + $method, + $method, + $method, + )); + } + return parent::__call($method, $parameters); + } + + /** + * Get all of the current attributes on the model. + * @param bool $withoutMergeAttributesFromCachedCasts + * @return array + */ + public function getAttributes(): array + { + if (true !== (\func_get_args()[0] ?? null)) { + $this->mergeAttributesFromCachedCasts(); + } + + return $this->attributes; + } + + /** + * @inheritdoc + */ + protected function getAttributesForInsert(): array + { + return $this->tmpOriginalBeforeAfterEvents = $this->getAttributes(); + } + + /** + * @inheritdoc + */ + public function syncOriginalAttributes($attributes): static + { + $attributes = is_array($attributes) ? $attributes : func_get_args(); + + foreach ($attributes as $attribute) { + $this->original[$attribute] = $this->getAttributeFromArray($attribute); + } + + return $this; + } + + /** + * @inheritdoc + */ + public function isDirty($attributes = null): bool + { + return [] !== $this->getDirty(\is_array($attributes) ? $attributes : \func_get_args()); + } + + /** + * Get the attributes that have been changed since the last sync. + * @param string|array $attributes + * @return array + */ + public function getDirty(): array + { + $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 = []; + + 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; + } + } + + return $dirty; + } + + /** + * @inheritdoc + */ + public function save(array $options = []): bool + { + $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 ($this->exists) { + /** $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 ( + '' === (string)$this->getConnectionName() && + ($connection = $query->getConnection()) instanceof Connection + ) { + $this->setConnection($connection->getName()); + } + + if ($saved) { + $this->finishSave($options + ['touch' => $isDirty]); + } + } finally { + $this->tmpOriginalBeforeAfterEvents = null; + } + + return $saved; + } + + /** + * @inheritdoc + */ + protected function finishSave(array $options): void + { + $this->fireModelEvent('saved', false); + + if ($options['touch'] ?? true) { + $this->touchOwners(); + } + + if (isset($this->tmpOriginalBeforeAfterEvents)) { + $this->original = $this->tmpOriginalBeforeAfterEvents; + $this->tmpOriginalBeforeAfterEvents = null; + + return; + } + + $this->syncOriginal(); + } + + /** + * @inheritdoc + */ + protected function performUpdate(Builder $query): bool + { + // If the updating event returns false, we will cancel the update operation so + // developers can hook Validation systems into their models and cancel this + // operation if the model does not pass validation. Otherwise, we update. + if ($this->fireModelEvent('updating') === false) { + return false; + } + + // First we need to create a fresh query instance and touch the creation and + // 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(); + } + + // Once we have run the update operation, we will fire the "updated" event for + // this model instance. This will allow developers to hook into these after + // models are updated, giving them a chance to do any special processing. + $dirty = $this->getDirtyForUpdate(); + + if ([] !== $dirty) { + $this->setKeysForSaveQuery($query)->update($dirty); + + $this->syncChanges(); + + $this->tmpDirtyIfAttributesAreSyncedFromCashedCasts = null; + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + + $this->fireModelEvent('updated', false); + } + + return true; + } + + /** + * @inheritdoc + */ + protected function insertAndSetId(Builder $query, $attributes): void + { + $id = $query->insertGetId($attributes, $keyName = $this->getKeyName()); + + $this->setAttribute($keyName, $id); + + $this->tmpOriginalBeforeAfterEvents = $this->attributes; + } + + /** + * Get the attributes that have been changed since the last sync for an update operation. + * + * @return array + */ + protected function getDirtyForUpdate(): array + { + return $this->getDirty(); + } + + public function getAttributeValue($key): mixed + { + return $this->transformModelValue($key, $this->getAttributeFromArray($key, true)); + } + + /** + * Get an attribute from the $attributes array without transformation + * @see self::getAttributeValue + * + * @param string $key + * @param bool $mergeAllAttributesFromCachedCasts + * @return mixed + */ + protected function getAttributeFromArray($key): mixed + { + 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; + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromClassCasts(): void + { + $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( + $this->attributes, + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Merge the cast class attributes back into the model. + * @param string|array $keys + * @return void + */ + protected function mergeAttributesFromAttributeCasts(): void + { + $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) { + continue; + } + + $callback = $attribute->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, + $callback($value, $this->attributes) + ) + ); + } + } +} \ No newline at end of file