We are trying to detect the changes in Laravel related models at attribute level, as we have to keep audit trail of all the changes which are made via the application.
We can track the changes via isDirty method on the Eloquent model for single model that is not related to any other model, but there is no way that we can track the changes on the related eloquent models. isDirty doesn't work on related models attributes. Can some one please help us on this?
Update to original question:
Actually we are trying to track changes on the pivot table that has extra attributes as well defined on it. IsDirty method doesn't work on those extra attributes which are defined in the pivot table.
Thanks
As much I understand your question, It's can achieve through Model Event and some sort of extra code with current and relation model.
Laravel Model Events
If you dont want to use any additional stuff, you can just use the Laravel Model Events (that in fact Ardent is wrapping in the hooks). Look into the docs http://laravel.com/docs/5.1/eloquent#events
Eloquent models fire several events, allowing you to hook into various
points in the model's lifecycle using the following methods: creating,
created, updating, updated, saving, saved, deleting, deleted,
restoring, restored.
Whenever a new item is saved for the first time, the creating and
created events will fire. If an item is not new and the save method is
called, the updating / updated events will fire. In both cases, the
saving / saved events will fire.
If false is returned from the creating, updating, saving, or deleting
events, the action will be cancelled:
Finally, reffering to your question you can utilize the above approaches in numerous ways but most obviously you can combine it (or not) with the Eloquent Models' getDirty() api docs here method and getRelation() api docs here method
It will work for example with the saving event.
Model::saving(function($model){
foreach($model->getDirty() as $attribute => $value){
$original= $model->getOriginal($attribute);
echo "Changed";
}
$relations = $model->getRelations();
foreach($relations as $relation){
$relation_model = getRelation($relation);
foreach($relation_model->getDirty() as $attribute => $value){
$original= $relation_model->getOriginal($attribute);
echo "Relation Changed";
}
}
return true; //if false the model wont save!
});
Another Thought might help you. when you saving
save() will check if something in the model has changed. If it hasn't it won't run a db query.
Here's the relevant part of code in Illuminate\Database\Eloquent\Model#performUpdate:
protected function performUpdate(Builder $query, array $options = [])
{
$dirty = $this->getDirty();
if (count($dirty) > 0)
{
// runs update query
}
return true;
}
The getDirty() method simply compares the current attributes with a copy saved in original when the model is created. This is done in the syncOriginal() method:
public function __construct(array $attributes = array())
{
$this->bootIfNotBooted();
$this->syncOriginal();
$this->fill($attributes);
}
public function syncOriginal()
{
$this->original = $this->attributes;
return $this;
}
check model is dirty isDirty():
if($user->isDirty()){
// changes have been made
}
Or check certain attribute:
if($user->isDirty('price')){
// price has changed
}
I did not check this code but hopeful to use as your answer by thoughts, if you have any confusion to deal such requirement or something need to optimize or change please let me know.
Related
Here is my current situation:
I have a Task model.
Tasks have owners (a belongsTo relationship)
Owners have accounts (yet another belongsTo relationship)
I'd like to set up a "belongsToThrough" relationship from Tasks to Accounts.
My first solution was to define a relationship in the Tasks model, like this:
public function account(): BelongsTo
{
return $this->owner->account();
}
With it I could call $task->account and retrieve a task's account easily. The problem is that this doesn't work with load/with, which in turn causes problems because I can't refresh() a task that has had the account loaded in (because refresh uses load). The error just states Trying to call account() on null which was honestly expected.
My second solution was to change the relationship method to:
public function account(): BelongsTo
{
return $this->owner()->first()->account();
}
With this, I can also simply call $task->account and retrieve the model, and when loading, it doesn't work (returns null), but also doesn't throw any errors. I don't need to load this relationship in, it just happens that sometimes I need to refresh models and having the load method throw an error is not ok.
In summary
What I'm looking for is kind of a BelongsToThrough, as a Task would BelongTo an Account through an Owner (User). Is there a way to do this that works using both $task->account and $task->load('account'). Before you tell me I can load it using owner.account, I know that, but refresh() will do it automatically with load('account') so I need it to work like that, not with the dot notation.
To get it working with load(), you'll need to define an account relationship on the owner model, if you haven't done so already. Like this:
public function account() :BelongsTo
{
return $this->belongsTo(AccountsTable);
}
Then use dot notation when calling load() on your task model like:
$task->load('owner.account');
You can do that using eager loading
public function account()
{
return $this->belongsTo('App\ParentModel', 'foreignkey', 'localkey');
}
After that you can easily fetch relation data with load/with.
Thanks,
imagine I have some doctrine Entity and I can have some records of this entity in the database which I dont want to be deleted, but I want them to be visible.
In general I can have entities, for which I have default records, which must stay there - must not be deleted, but must be visible.
Or for example, I want to have special User account only for CRON operations. I want this account to be visible in list of users, but it must not be deleted - obviously.
I was searching and best what I get was SoftDeletable https://github.com/Atlantic18/DoctrineExtensions/blob/v2.4.x/doc/softdeleteable.md It prevents fyzical/real deletion from DB, but also makes it unvisible on the Front of the app. It is good approach - make a column in the Entity's respective table column - 1/0 flag - which will mark what can not be deleted. I would also like it this way because it can be used as a Trait in multiple Entities. I think this would be good candidate for another extension in the above Atlantic18/DoctrineExtensions extension. If you think this is good idea (Doctrine filter) what is the best steps to do it?
The question is, is this the only way? Do you have a better solution? What is common way to solve this?
EDIT:
1. So, we know, that we need additional column in a database - it is easy to make a trait for it to make it reusable
But
2. To not have any additional code in each repository, how to accomplish the logic of "if column is tru, prevent delete" with help of Annotation? Like it is in SoftDeletable example above.
Thank you in advance.
You could do this down at the database level. Just create a table called for example protected_users with foreign key to users and set the key to ON DELETE RESTRICT. Create a record in this table for every user you don't want to delete. That way any attempt to delete the record will fail both in Doctrine as well as on db level (on any manual intervention in db). No edit to users entity itself is needed and it's protected even without Doctrine. Of course, you can make an entity for that protected_users table.
You can also create a method on User entity like isProtected() which will just check if related ProtectedUser entity exists.
You should have a look at the doctrine events with Symfony:
Step1: I create a ProtectedInterface interface with one method:
public function isDeletable(): boolean
Step2: I create a ProtectionTrait trait which create a new property. This isDeletable property is annotated with #ORM/Column. The trait implements the isDeletable(). It only is a getter.
If my entity could have some undeletable data, I update the class. My class will now implement my DeleteProtectedInterface and use my ProtectionTrait.
Step3: I create an exception which will be thrown each time someone try to delete an undeletable entity.
Step4: Here is the tips: I create a listener like the softdeletable. In this listener, I add a condition test when my entity implements the ProtectedInterface, I call the getter isDeleteable():
final class ProtectedDeletableSubscriber implements EventSubscriber
{
public function onFlush(OnFlushEventArgs $onFlushEventArgs): void
{
$entityManager = $onFlushEventArgs->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
foreach ($unitOfWork->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof ProtectedInterface && !$entity->isDeletable()) {
throw new EntityNotDeletableException();
}
}
}
}
I think that this code could be optimized, because it is called each time I delete an entity. On my application, users don't delete a lot of data. If you use the SoftDeletable component, you should replace it by a mix between this one and the original one to avoid a lot of test. As example, you could do this:
final class ProtectedSoftDeletableSubscriber implements EventSubscriber
{
public function onFlush(OnFlushEventArgs $onFlushEventArgs): void
{
$entityManager = $onFlushEventArgs->getEntityManager();
$unitOfWork = $entityManager->getUnitOfWork();
foreach ($unitOfWork->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof ProtectedInterface && !$entity->isDeletable()) {
throw new EntityNotDeletableException();
}
if (!$entity instance SoftDeletableInterface) {
return
}
//paste the code of the softdeletable subscriber
}
}
}
Well the best way to achieve this is to have one more column in the database for example boolean canBeDeleted and set it to true if the record must not be deleted. Then in the delete method in your repository you can check if the record that is passed to be deleted can be deleted and throw exception or handle the situation by other way. You can add this field to a trait and add it to any entity with just one line.
Soft delete is when you want to mark a record as deleted but you want it to stay in the database.
We have a COMMON database and then tenant databases for each organization that uses our application. We have base values in the COMMON database for some tables e.g.
COMMON.widgets. Then in the tenant databases, IF a table called modified_widgets exists and has values, they are merged with the COMMON.widgets table.
Right now we are doing this in controllers along the lines of:
public function index(Request $request)
{
$widgets = Widget::where('active', '1')->orderBy('name')->get();
if(Schema::connection('tenant')->hasTable('modified_widgets')) {
$modified = ModifiedWidget::where('active', '1')->get();
$merged = $widgets->merge($modified);
$merged = array_values(array_sort($merged, function ($value) {
return $value['name'];
}));
return $merged;
}
return $countries;
}
As you can see, we have model for each table and this works OK. We get the expected results for GET requests like this from controllers, but we'd like to merge at the Laravel MODEL level if possible. That way id's are linked to the correct tables and such when populating forms with these values. The merge means the same id can exist in BOTH tables. We ALWAYS want to act on the merged data if any exists. So it seems like model level is the place for this, but we'll try any suggestions that help meet the need. Hope that all makes sense.
Can anyone help with this or does anyone have any ideas to try? We've played with overriding model constructors and such, but haven't quite been able to figure this out yet. Any thoughts are appreciated and TIA!
If you put this functionality in Widget model you will get 2x times of queries. You need to think about Widget as an instance, what I am trying to say is that current approach does 2 queries minimum and +1 if tenant has modified_widgets table. Now imagine you do this inside a model, each Widget instance will pull in, in a best case scenario its equivalent from different database, so for bunch of Widgets you will do 1 (->all())+n (n = number of ModifiedWidgets) queries - because each Widget instance will pull its own mirror if it exists, no eager load is possible.
You can improve your code with following:
$widgets = Widget::where('active', '1')->orderBy('name')->get();
if(Schema::connection('tenant')->hasTable('modified_widgets')) {
$modified = ModifiedWidget::where('active', '1')->whereIn('id', $widgets->pluck('id'))->get(); // remove whereIn if thats not the case
return $widgets->merge($modified)->unique()->sortBy('name');
}
return $widgets;
OK, here is what we came up with.
We now use a single model and the table names MUST be the same in both databases (setTable does not seem to work even though in exists in the Database/Eloquent/Model base source code - that may be why it's not documented). Anyway = just use a regular model and make sure the tables are identical (or at least the fields you are using are):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Widget extends Model
{
}
Then we have a generic 'merge controller' where the model and optional sort are passed in the request (we hard coded the 'where' and key here, but they could be made dynamic too). NOTE THIS WILL NOT WORK WITH STATIC METHODS THAT CREATE NEW INSTANCES such as $model::all() so you need to use $model->get() in that case:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class MergeController extends Controller
{
public function index(Request $request)
{
//TODO: add some validations to ensure model is provided
$model = app("App\\Models\\{$request['model']}");
$sort = $request['sort'] ? $request['sort'] : 'id';
$src_collection = $model->where('active', '1')->orderBy('name')->get();
// we setup the tenants connection elsewhere, but use it here
if(Schema::connection('tenant')->hasTable($model->getTable())) {
$model->setConnection('tenant');
$tenant_collection = $model->get()->where('active', '1');
$src_collection = $src_collection->keyBy('id')->merge($tenant_collection->keyBy('id'))->sortBy('name');
}
return $src_collection;
}
}
If you dd($src_collection); before returning it it, you will see the connection is correct for each row (depending on data in the tables). If you update a row:
$test = $src_collection->find(2); // this is a row from the tenant db in our data
$test->name = 'Test';
$test->save();
$test2 = $src_collection->find(1); // this is a row from the tenant db in our data
$test2->name = 'Test2'; // this is a row from the COMMON db in our data
$test2->save();
dd($src_collection);
You will see the correct data is updated no matter which table the row(s) came from.
This results in each tenant being able to optionally override and/or add to base table data without effecting the base table data itself or other tenants while minimizing data duplication thus easing maintenance (obviously the table data and population is managed elsewhere just like any other table). If the tenant has no overrides then the base table data is returned. The merge and custom collection stuff have minimal documentation, so this took some time to figure out. Hope this helps someone else some day!
What would be the best way to create a relationship if it doesn’t exist already, within Eloquent, or at least a central location.
This is my dilemma. A User must have a Customer model relationship. If for whatever reason that customer record doesn’t exist (some bug that stopped it from being created) - I don’t want it to throw errors when I try to retrieve it, but I also request the customer object in multiple locations so I don’t want to test for existence in all those places.
I thought of trying the following in the User model:
public function getCustomerAttribute($value) {
// check $value and create if null
}
But that doesn’t work on relationships, $value is null.
EDIT
I already create a customer upon user creation, but I have come across a situation where it wasn't created and caused exceptions in many places, so I want to fallback.
User::created(function($user) {
$customer = Customer::create([
'user_id' => $user->id
]);
});
Is it possible for you to assume when a user is created that a customer needs to be created as well? If the rest of your system depends on this assumption I would make a model event.
use App\{User, Customer}; // assuming php7.0
UserServiceProvider extends ServiceProvider
{
/**
* Boot
*/
public function boot()
{
// on a side note, we're using "created" not "creating" because the $user->id needs to exist in order to save the relationship.
User::created(function($user) {
$customer = Customer::create([
'user_id' => $user->id
]);
});
}
}
(link to previous question just in case: Struggling with one-to-many relation in an admin form)
I have this many-to-many relation in my Symfony-1.3 / Propel-1.4 project between User and Partner. When the User is being saved, if it has certain boolean flag being true, I want to clear all the links to the partners. Here is what I do at the moment and it doesn't work:
// inside the User model class
public function save(PropelPDO $con = null) {
if ($this->getIsBlaBla()) {
$this->setStringProperty(NULL);
$this->clearUserPartners();
}
parent::save($con);
}
Setting the string property to NULL works; looking at the DB clearly shows it. Thing is however, the USER_PARTNER table still holds the relations between the users and the partners. So I figured I have to clear the links one by one, like this:
foreach($this->getUserPartners() as $user_partner) {
$user_partner->delete();
//UserPartnerPeer::doDelete($user_partner); // tried that too
}
Both don't do the trick.
As I mentioned in my previous question, I am just monkey-learning Symfony via trial and error, so I evidently miss something very obvious. Please point me in the right direction!
EDIT: Here is how I made it work:
Moved the code to the Form class, like so:
public function doSave(PropelPDO $con = null) {
parent::doSave($con);
if ($this->getObject()->getIsSiteOwner()) {
$this->getObject()->setType(NULL);
$this->getObject()->save();
foreach($this->getObject()->getUserPartners() as $user_partner) {
$user_partner->delete();
}
}
return $this->getObject();
}
public function updateObject($values = null) {
$obj = parent::updateObject($values);
if ($obj->getIsSiteOwner()) {
$obj->clearUserPartners();
}
return $this->object;
}
What this does is:
When the boolean flag `is_site_owner` is up, it clear the `type` field and **saves** the object (ashamed I have not figured that out for so long).
Removes all existing UserPartner many-to-many link objects.
Clears newly associated (via the DoubleList) UserPartner relations.
Which is what I need. Thanks to all who participated.
Okey so now you have a many-to-many relation where in database terms is implemented into three tables (User , Parter and UserPartner). Same thing happens on Symfony and Propel, so you need to do something like this on the doSave method that should declare in UserForm:
public function doSave($con = null)
{
parent::doSave($con); //First all that's good and nice from propel
if ($this->getValue('please_errase_my_partners_field'))
{
foreach($this->getObject()->getUserPartners() as $user_partner_relation)
{
$user_partner_relation->delete();
}
}
return $this->getObject();
}
Check the method name "getUserPartners" that should be declared on the BaseUser.class.php (lib/model/om/BaseUser.class.php)
If you are learning Symfony, I suggest you use Doctrine instead of Propel because, I think Doctrine is simplier and more "beautiful" than Propel.
For your problem, I think you are on the good way. If I were you, I will keep my function save() I will write an other function in my model User
public function clearUserPartners(){
// You have to convert this query to Propel query (I'm sorry, but I don't know the right syntax)
"DELETE FROM `USER_PARTNER` WHERE user_id = '$this->id'"
}
With this function, you don't must use a PHP foreach.
But I don't understand what is the attribute StringProperty...
UserPartnerQuery::create()->filterByUser( $userObject )->delete();
or
UserPartnerQuery::create()->filterByUser( $partnerObject )->delete();
Had the same problem. This is a working solution.
The thing is that your second solution, ie. looping over the related objects and calling delete() on them should work. It's the documented way of doing things (see : http://www.symfony-project.org/book/1_0/08-Inside-the-Model-Layer#chapter_08_sub_saving_and_deleting_data).
But instead of bombing the DB with delete queries, you could just as well delete them in one go, by adding a method to your Peer class that performs the deletion using a simple DB query.