Incoherence between eloquent `isDirty()` and `getChanges()` - laravel-5

I am currently working on a Laravel 5.8 project and when updating a model noticed that even though there aren't any changes to the model I'm saving the same model back into the database.
My thinking to avoid this was the following:
$model = Model::find($id);
$model->fill([
"name" => $request->name,
...
]);
if($model->isDirty){
$model->save()
}
Problem is that even though I don't change values in my model I'm still entering the if() condition and saving the model. I tried using a temp variable and debugged $model->getChanges() and I get an empty array.
Is this expected behavior?

There is a difference yes.
Related code:
https://github.com/laravel/framework/blob/6.x/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php#L1060
isDirty code
/**
* Determine if the model or any of the given attribute(s) have been modified.
*
* #param array|string|null $attributes
* #return bool
*/
public function isDirty($attributes = null)
{
return $this->hasChanges(
$this->getDirty(), is_array($attributes) ? $attributes : func_get_args()
);
}
getChanges() & getDirty code
/**
* Get the attributes that have been changed since last sync.
*
* #return array
*/
public function getDirty()
{
$dirty = [];
foreach ($this->getAttributes() as $key => $value) {
if (! $this->originalIsEquivalent($key, $value)) {
$dirty[$key] = $value;
}
}
return $dirty;
}
/**
* Get the attributes that were changed.
*
* #return array
*/
public function getChanges()
{
return $this->changes;
}
To summarize.
Answer used from this post: https://laracasts.com/discuss/channels/eloquent/observer-column-update-isdirty-or-waschanged
isDirty (and getDirty) is used BEFORE save, to see what attributes
were changed between the time when it was retrieved from the database
and the time of the call, while wasChanged (and getChanges) is used
AFTER save, to see that attributes were changed/updated in the last
save (from code to the database).
The reason you get in the isDirty check is that before the check you do a fill(). I think this will auto-fill updated_at. So, in fact, the model, in this case, has been changed.

Related

Having some issue with laravel collection and a callback function

pro's, amateurs and php enthousiasts.
I am working on a Laravel task wicht envolved dynamic data, collections and graphs.
In order to see what is wrong i kinda need some help, since I can't see it clearly anymore. I should pause and work on something else but this is a bottleneck for me.
I have a collection called orders.
in those orders I have grouped them by date. So far so good. Example below is a die and dump.
Exactly what i need in this stage.
"2022-01-29" => Illuminate\Support\Collection {#4397 ▶}
Now comes the mweh part.
I have a class called Datahandler
in that class I have three methods in it
simplified version of it:
Abstract Class DataHandler
{
/**
* Handles the conversion to dataset for the chart
*
* #param string $label
* #return void
*/
public function handle(string $label):void
{
$this->chart->addDataset($this->process->map(
$this->bind([$this, 'dataLogic'])
)->toArray()
, $label
);
}
/**
* Binds callbacks for the Handler of the class
*
* #param array $callable
* #return Closure
*/
function bind(array $callable): Closure
{
return function () use ($callable) {
call_user_func_array($callable, func_get_args());
};
}
/**
* Defines the fields I need to return to the collection
*
* #param Collection $group
* #return array
*/
#[Pure] #[ArrayShape(['total' => "int"])]
protected function dataLogic(Collection $group): array
{
return [
'total' => $group->count()
];
}
}
So in the handle function you can see I am binding ($this->bind()) my $this->process (collection data) to a callback ( $this->dataLogic() ). The protected function dataLogic is protected because every child of this Abstract class needs to have it's own logic in there.
so this function is being executed from within the parent, this is good cause it should be the default behaviour unless the child has the same function. If i do a var_dump on $group in method dataLogic I also have the correct value and the $group->count() also presents the corrent count of said data.
however the return is null. I am not so well trained in the use of callbacks, has anyone an idea on what is going wrong or even a better solution then the one I am trying to create?
forgot to mention the result of my code:
"2022-01-29" => null
It should be
"2022-01-29" => 30
Kind Regards,
Marcel
I solved it by doing the following.
I completely removed the bind function and handled my function as a callable for it got the needed solution, is there a better one, sure there is somewhere so any ideas are still welcome, but for now i can continue further.
Abstract Class DataHandler
{
/**
* Handles the conversion to dataset for the chart
*
* #param string $label
* #return void
*/
public function handle(string $label):void
{
$this->chart->addDataset($this->process->map($this->dataLogic()
)->toArray()
, $label
);
}
/**
* Defines the fields I need to return to the collection
*
* #return array
*/
#[Pure] #[ArrayShape(['total' => "int"])]
protected function dataLogic(): callable
{
return function ($group) {
return $group->count();
};
}
}

Laravel Nova n+1 problem when running sql inside fields()

For some reason, I need dynamically add columns in the fields method. This is not only dynamic columns but also contains computed fields.
This is very simplified version of what I'm trying to do inside fields():
$additional_fields = [];
Product::visible()->each(function($attr) use (&$additional_fields, $request) {
$additional_fields[] = Text::make($attr->name, function() use ($attr, $request) {
$first_subscription = $this->subscriptions()->whereHas('product', function($q) {
return $q->where('visible', 1);
});
...
}
}
This, of course, causing the N+1 problem as statements for Product and Subscription are executed on every row.
I need to move this piece of code somewhere else and run it only once. I can't figure out how to do this yet.
Your can use indexQuery method in your resource to load relationships
/**
* #param NovaRequest $request
* #param \Illuminate\Database\Eloquent\Builder $query
* #return \Illuminate\Database\Eloquent\Builder
*/
public static function indexQuery(NovaRequest $request, $query)
{
$query = $query->with('relation');
return $query;
}

Yii2 - Generate common error in validation (not related with particular attribute)

I would like to apply a validator for ActiveRecord but not for particular field, I mean, I would like see the validator in the error summary of the form but not associated to a particular field.
When writing a custom validation, you need to use addError method of yii\base\Model (if you write it in model) or yii\validators\Validator (if you write separate validator).
Both methods require $attribute parameter (attribute name) to be passed, and as you can see from the sources, you can't leave it blank:
addError of yii\base\Model:
/**
* Adds a new error to the specified attribute.
* #param string $attribute attribute name
* #param string $error new error message
*/
public function addError($attribute, $error = '')
{
$this->_errors[$attribute][] = $error;
}
addError of yii\validators\Validator:
/**
* Adds an error about the specified attribute to the model object.
* This is a helper method that performs message selection and internationalization.
* #param \yii\base\Model $model the data model being validated
* #param string $attribute the attribute being validated
* #param string $message the error message
* #param array $params values for the placeholders in the error message
*/
public function addError($model, $attribute, $message, $params = [])
{
$params['attribute'] = $model->getAttributeLabel($attribute);
if (!isset($params['value'])) {
$value = $model->$attribute;
if (is_array($value)) {
$params['value'] = 'array()';
} elseif (is_object($value) && !method_exists($value, '__toString')) {
$params['value'] = '(object)';
} else {
$params['value'] = $value;
}
}
$model->addError($attribute, Yii::$app->getI18n()->format($message, $params, Yii::$app->language));
}
Possible options:
1) Select the most important relevant field and add error to it.
2) Select multiple important relevant fields and add the same error message to them (you can store and pass message in separate variable before passing to keep code DRY).
3) You can use not existing attribute name for adding error, let's say all, because attribute existence is not checked at that point.
class YourForm extends \yii\base\Model
{
/**
* #inheritdoc
*/
public function rules()
{
return [
['name', 'yourCustomValidationMethod'],
];
}
/**
* #return boolean
*/
public function yourCustomValidationMethod()
{
// Perform your custom validation here regardless of "name" attribute value and add error when needed
if (...) {
$this->addError('all', 'Your error message');
}
}
}
Note that you still need to attach validator to existing attribute(s) (otherwise exception will be thrown). Use the most relevant attribute.
As a result, you will see error only in error summary. You can display error summary in form like that:
<?= $form->errorSummary($model) ?>
But in most cases there are always one or multiple attributes related with error so I recommend to use option 1 or 2. Option 3 is kind of hack but I think still pretty elegant solution for your problem.

Model calls from Laravel transformer class

I have been using Transformers(fractal) for transforming the data before it is send as an output for the API call.
So from controller I am calling the transformer class and passing the data like this
$data = $this
->myModelClass
->search($filters);
$data = $this
->listTransformer
->transform($data);
and in the transformer,
public function transform($result)
{
$resource = $this->factory->make($result, function ($item) {
return [
'id' => $item->id,
'name' => $item->name,
'category_name' => $this->anotherModel->getCategory($item->category_id),
'revenue' => $this->anotherModel->getRevenue($item->earnings)
];
});
$result = $this
->manager
->createData($resource)
->toArray();
return $result['data'];
}
So basically, I am calling models from the transformer. Is this the right way of doing it ?
I have seen another method which uses the includes, but if I have a number of items there in the array,
which needs to be passed to the model for getting details, I need to write a number of transformers.
Generally speaking, this is a bad practice as it violates the "Single Responsibility" principle of SOLID. Your transformer is no longer just responsible for transforming data, it now also queries data, which then ties it to your data layer, making it "tightly coupled".
There is no harm passing a model object to a transformer, which contains other related models, however the transformer should have one responsibility, transforming data.
Typically I would create a transformer per model, and then have the domain (aggregate) model use the other explicit transformers, this would involve you building an "domain" (aggregate) model before passing it to your transformer. This and means any changes to your data layer will have a minimal impact on your transformers.
A working example below:
class LeadTransformer extends Transformer implements LeadTransformerInterface {
/**
* #var CustomerTransformerInterface
*/
protected $customerTransformer;
/**
* #var EnquiryTypeTransformerInterface
*/
protected $enquiryTypeTransformer;
/**
* #var PlanTransformerInterface
*/
protected $planTransformer;
/**
* #param CustomerTransformerInterface $customerTransformer
* #param EnquiryTypeTransformerInterface $enquiryTypeTransformer
* #param PlanTransformerInterface $planTransformer
*/
public function __construct(CustomerTransformerInterface $customerTransformer, EnquiryTypeTransformerInterface $enquiryTypeTransformer, PlanTransformerInterface $planTransformer)
{
$this->customerTransformer = $customerTransformer;
$this->enquiryTypeTransformer = $enquiryTypeTransformer;
$this->planTransformer = $planTransformer;
}
/**
* Transforms a lead
*
* #param array $lead
* #return array
*/
public function transform($lead)
{
$data = [
// Do Transformation
];
if($lead->enquiry_type)
{
$data['enquiry_type'] = $this->enquiryTypeTransformer->transform($lead->enquiry_type);
}
if($lead->customer)
{
$data['customer'] = $this->customerTransformer->transform($lead->customer);
}
if($lead->plan)
{
$data['plan'] = $this->planTransformer->transform($lead->plan);
}
return $data;
}
}
In the above example, LeadTransformer has three other transformers injected as dependencies by laravel's IoC Container. When it comes to transforming the data in that related model, that models transformer is used.
This means should I ever need to manipulate the "Customer" model, I have no need to interfere with other aspects of my application, as it's all be abstracted out.
Hope this answers your questions, should you have any follow up questions please comment and I shall do my best to address them

Unable to override automatic model find method calls since upgrading to Laravel 5.1

I have a simple trait which I use to always include soft-deleted items for a few things:
trait OverrideTrashedTrait {
public static function find($id, $columns = ['*'])
{
return parent::withTrashed()->find($id, $columns);
}
}
However, since upgrading to Laravel 5.1, this no longer works. Soft-deleted items do not turn up in get() lists, and if I try to access a page where I've used route model bindings, I get the NotFoundHttpException.
Laravel's upgrade documentation states that:
If you are overriding the find method in your own models and calling parent::find() within your custom method, you should now change it to call the find method on the Eloquent query builder:
So I changed the trait accordingly:
trait OverrideTrashedTrait {
public static function find($id, $columns = ['*'])
{
return static::query()->withTrashed()->find($id, $columns);
}
}
But it appears that no matter what I write in there, it doesn't affect the results. I have also tried to put the overriding find() method directly in the model, but that doesn't appear to be working either. The only way anything changes is if I write invalid syntax. Even if I change the $id to a hardcoded id of an item that is not soft-deleted, I get the same result, and absolutely nothing happens if I e.g. try to dd('sdfg'), so I doubt the method is even called.
Edit: If I do trigger the method manually, it works just like intended.
How can I fix this?
Ok here it goes:
short version: Model binding does not use find.
longer version:
/**
* Register a model binder for a wildcard.
*
* #param string $key
* #param string $class
* #param \Closure|null $callback
* #return void
*
* #throws NotFoundHttpException
*/
public function model($key, $class, Closure $callback = null)
{
$this->bind($key, function ($value) use ($class, $callback) {
if (is_null($value)) {
return;
}
// For model binders, we will attempt to retrieve the models using the first
// method on the model instance. If we cannot retrieve the models we'll
// throw a not found exception otherwise we will return the instance.
$instance = new $class;
if ($model = $instance->where($instance->getRouteKeyName(), $value)->first()) {
return $model;
}
// If a callback was supplied to the method we will call that to determine
// what we should do when the model is not found. This just gives these
// developer a little greater flexibility to decide what will happen.
if ($callback instanceof Closure) {
return call_user_func($callback, $value);
}
throw new NotFoundHttpException;
});
}
Line 931 of Illuminate\Routing\Router says it does:
$instance->where($instance->getRouteKeyName(), $value)->first()
It uses the key from the model used in a where and loads the first result.
In Laravel 5.1 find() method can be found in Illuminate\Database\Eloquent\Builder
From your Model class you can override it like following:
namespace App;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
/**
* Overrides find() method in Illuminate\Database\Eloquent\Builder.
* Finds only active products.
*
* #param mixed $id
* #param array $columns
* #return \Illuminate\Database\Eloquent\Model|\Illuminate\Database\Eloquent\Collection|null
*/
public static function find($id, $columns = ['*']) {
// create builder from the Model
$builder = (new self)->newQuery();
// method customization
if (is_array($id)) {
// findMany() also should be customized
return self::findMany($id, $columns);
}
$builder->getQuery()->where("isActive", '=', 1)->where($builder->getModel()->getQualifiedKeyName(), '=', $id);
return $builder->first($columns);
}
}

Resources