CakePHP 3 - How to still contain() soft-deleted entities? - cakephp

I have an Orders and a Users table, such that Orders belongsTo Users.
I want users to be able to soft-delete their account, so I added a deleted field and modified the delete method. The user info is needed for administrative purposes, hence no hard deletes.
I also overrode the default finder of UsersTable such that deleted users will not pop up in lists or as results of Users->get():
public function findAll(Query $query, array $options)
{
return $query->where(['Users.deleted' => false]);
}
I am satisfied with the way it works, mostly that I now cannot forget to exclude deleted users as the default finder already does the job.
The problem is I still want to include the user when it is contained from an order:
$order = $this->Orders->get($id, ['contain' => 'Users']);
And apparently when using contain() findAll() is used, because this does not include the deleted user.
How can I still include the soft-deleted entities in a contain()?
Is it possible to set a different default finder for contains?

You can for example use the finder option for contain to specify which finder to use, like:
$this->Orders->get($id, [
'contain' => [
'Users' => [
'finder' => 'withDeleted'
]
]
]);
or modify the query directly:
$this->Orders->get($id, [
'contain' => [
'Users' => function (\Cake\ORM\Query $query) {
return $query->find('withDeleted');
}
]
]);
See also Cookbook > Database Access & ORM > Query Builder > Passing Conditions to Contain
However any custom finder would circumvent your modified all finder, which shows a flaw in your approach, once a query uses a different finder, and doesn't also explicitly use the all finder, your conditions will not be applied, which you most likely wouldn't want to happen so easily.
A better approach would probably be to use the Model.beforeFind event/callback. Here's a basic, rather strict example that uses an options approach:
public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \ArrayObject $options)
{
if (!isset($options['findWithDeleted']) ||
$options['findWithDeleted'] !== true
) {
$query->where(['Users.deleted' => false]);
}
}
public function findWithDeleted(\Cake\ORM\Query $query, array $options)
{
return $query->applyOptions(['findWithDeleted' => true]);
}
This would ensure that only when the findWithDeleted option is present, and set to true, the condition would not be applied.
You might also want to have a look at plugins that can handle this, like for example https://github.com/usemuffin/trash.

Related

Sorting on nested relationship

I have the following relationship:
Rounds hasMany Results
Results belongsTo Drivers
Results are shown on Rounds's view page: rounds/view/{id}. I want to sort Results based on Drivers.name.
This is my RoundsController::view method:
public function view($id = null)
{
$this->paginate = [
'sortableFields' => [
'Results.Drivers.name',
]
];
$round = $this->Rounds->get($id, [
'contain' => ['Championships' => ['Teams'], 'Tracks', 'Results' => ['Drivers', 'Constructors']],
]);
$this->set(compact('round'));
}
And in my rounds/view.ctp file, I have this:
<?= $this->Paginator->sort('Results.Drivers.name', __('Driver')); ?>
However, no matter what I change, it doesn't sort by driver name. Also, when I click it multiple times, the direction stays asc.
Why is it not working?
Like #ndm mentioned, your code is showing only one specific Round. View method/function is receiving an $id arg, this $id is related to the round id you want to show in view page.
You can not order this (you can do it but it doesnt make sense) because you are receiving only one Round, and it doesnt make sense to order only one row.
This view method is usually used with this goal: show individual/personal information about an Entity (Round entity in your case). Please, note I use the word USUALLY.
I think you could use the index method for this purpose (this method/function is usually used to list entities from a model and its association data). You could create a new method/function for your porpuse as well if you already are using index method for another goal.
I suggest something like this, assuming you will use the index function:
public function index()
{
$rounds = $this->Rounds->find('all')
->contain([
'Championships' => ['Teams'],
'Tracks',
'Results' => [
'Constructors'
[
'Drivers' => function (Query $q) {
return $q->order('Drivers.name');;
}
]
]
]);
$rounds= $this->paginate($rounds);
$this->set(compact('rounds'));
}
You could pass conditions into the contain or do the same into paginate method. I always use the first (just preferences).
NOTE: this code may not work, it is an idea of what you could do.
Finders documentation: https://book.cakephp.org/4/en/orm/retrieving-data-and-resultsets.html#using-finders-to-load-data
Passing conditions to contains: https://book.cakephp.org/4/en/orm/query-builder.html#passing-conditions-to-contain

Cakephp 3: Countercache with conditions on the target model

I am trying to count the number of Spots associated with a Plan, but limited to Spots downloaded after the plans renewal date. Hope that makes sense. I would image something like this, but it doesn't work:
class SpotsTable extends Table
{
public function initialize(array $config)
{
$this->addBehavior('CounterCache', [
'Plan' => [
'creditsUsed' => [
'conditions' => [
'downloaded >' => 'Plan.renewed'
]
]
]
]);
...
}
...
}
Basically right now it acts as though Plan.renewed means NULL.
Is this possible, or am I on the wrong track?
Two problems
1. Identifiers cannot be passed as string values
When using the key => value format, the value side will always be subject to binding/escaping/casting unless it's an expression object, so since the downloaded column is probably a date/time type, you'll end up with Plan.renewed being bound as a string, thus the final SQL will be something like:
downloaded > 'Plan.renewed'
which probably always results in false. Long story short, use for example an identifier expression:
'Spots.downloaded >' => new \Cake\Database\Expression\IdentifierExpression('Plan.renewed')
2. The counter query doesn't have access to the associated table
Plan.renewed will not be accessible in the query generated by the counter cache behavior, it will not automatically contain/join associations, it will create a simple query with a condition based on the foreign key value in the currently processed Spot entity.
So you have to use a custom/modified query, for example using a custom finder, something like this:
'creditsUsed' => [
'finder' => 'downloadedAfterPlanRenewal'
]
// in SpotsTable
public function findDownloadedAfterPlanRenewal(\Cake\ORM\Query $query, array $options)
{
return $query
->innerJoinWith('Plan')
->where([
'Spots.downloaded >' => $query->identifier('Plan.renewed')
]);
}
This will properly join in the association, so that you can compare with a field from Plan. The original primary key conditions generated by the behavior will already be applied on the given query object.
See also
Cookbook > Database Access & ORM > Behaviors > CounterCache > Advanced Usage
Cookbook > Database Access & ORM > Retrieving Data & Results Sets > Custom Finder Methods

CakePHP 3.5 Auth use multiple tables

I have an Auth process which works fine with one userModel. But not only because of my DB schema I need to have one login method/action which works with multiple models.
So far I've tried everything I was able to think of or find online - for example editing this Cake 1.3 solution into Cake 3 and a few more hints I was able to find.
However, I'm not able to figure it out.
Thank you for any answer.
My AppController component load:
$this->loadComponent('ExtendedAuth', [
'authenticate' => [
'Form' => [
//'userModel' => 'Admins',
'fields' => [
'username' => 'email',
'password' => 'password'
]
]
],
'loginAction' => [
'controller' => 'Admins',
'action' => 'login'
],
// If unauthorized, return them to page they were just on
'unauthorizedRedirect' => $this->referer(),
]);
My ExtendedAuthComponent:
class ExtendedAuthComponent extends AuthComponent
{
function identify($user = null, $conditions = null) {
$models = array('Admins', 'Users');
foreach ($models as $model) {
//$this->userModel = $model; // switch model
parent::setConfig('authenticate', [
AuthComponent::ALL => [
'userModel' => $model
]
]);
$result = parent::identify(); // let cake do its thing
if ($result) {
return $result; // login success
}
}
return null; // login failure
}
}
EDIT1: Description of situation
I have two separate tables (Admins, Users). I need just one login action which tries to use Admins table prior to Users. Because of the application logic I can't combine them to one table with something like 'is_admin' flag. So basically what I need is instead of one specific userModel set in Auth config, I need a set of models. Sounds simple and yet I'm not able to achieve it.
EDIT2: Chosen solution
Based on the answer below, I decided to update my schema. Auth users table is just simplified table with login credentials and role and other role-specific fields are then in separate tables which are used as a connection for other role-specific tables. Even though the answer is not exactly a solution for the asked question, it made me think more about any possible changes of the schema and I found this solution because of it so I'm marking it as a solution. I appreciate all comments as well.
As Mark already said in a comment: Don't use two users tables. Add a type field or role or whatever else and associated data in separate tables if it's different like admin_profiles and user_profiles.
Don't extend the Auth component. I wouldn't recommend to use it anymore any way because it's going to get deprecated in the upcoming 3.7 / 4.0 release. Use the new official authentication and authorization plugins instead.
If you insist on the rocky path and want to make your life harder, well go for it but then you should still not extend the auth component but instead write a custom authentication adapter. This is the right place to implement your custom 2-table-weirdness. Read this section of the manual on how to do it.

Getting entities' dirty fields after saving with associations

I'm trying to log each action (insert/update/delete) in the application and I'm doing this by getting the dirty and original values after saving the entity. The problem is that all values of the associated entities are returned as dirty and even is_new flag is set to true but actually I'm updating. What causes this behavior and how can I avoid it?
Example:
$data = [
'name' => $name,
'something' => $something,
'Table1' => [
'id' => $idWhereUpdatingTable1,
'field1' => $field1,
'field2' => $field2,
],
'Table2' => [
'id' => $idWhereUpdatingTable2,
'field3' => $field3,
'field4' => $field4,
],
];
$options = ['associated' => ['Table1', 'Table2']];
$updatedEntity = $this->patchEntity($entity, $data, $options);
$save = $this->save($updatedEntity);
// Successfully logging the changes in the main entity
// Trying to log the changes in the associated entities
foreach($save->table1 as $entity)
{
// everything here is set to dirty (even ID field but it's not an insert) and I'm not able to fetch the updated fields only. Also getOriginal() doesn't return the old values.
}
I did some digging into the dirty() function within an Entity and according to the API if you do not explicitly ask it to check a property then it will just tell you if the Entity has any dirty properties.
So doing
$entity->dirty('title'); Tells you if the tile is dirty but running $entity->dirty(); will just tell you if any property in the entity is dirty.
http://api.cakephp.org/3.1/class-Cake.ORM.Entity.html#_dirty
You may want to make code conditional based on whether or not fields have changed in an entity.
For example, you may only want to validate fields when they change:
// See if the title has been modified. CakePHP version 3.5 and above
$entity->isDirty('title');
// CakePHP 3.4 and Below use dirty()
$entity->dirty('title');

CakePHP: Can I ignore a field when reading the Model from the DB?

In one of my models, I have a "LONGTEXT" field that has a big dump of a bunch of stuff that I never care to read, and it slows things down, since I'm moving much more data between the DB and the web app.
Is there a way to specify in the model that I want CakePHP to simply ignore that field, and never read it or do anything with it?
I really want to avoid the hassle of creating a separate table and a separate model, only for this field.
Thanks!
Daniel
As #SpawnCxy said, you'll need to use the 'fields' => array(...) option in a find to limit the data you want to retrieve. If you don't want to do this every time you write a find, you can add something like this to your models beforeFind() callback, which will automatically populate the fields options with all fields except the longtext field:
function beforeFind($query) {
if (!isset($query['fields'])) {
foreach ($this->_schema as $field => $foo) {
if ($field == 'longtextfield') {
continue;
}
$query['fields'][] = $this->alias . '.' . $field;
}
}
return $query;
}
Regarding comment:
That's true… The easiest way in this case is probably to unset the field from the schema.
unset($this->Model->_schema['longtextfield']);
I haven't tested it, but this should prevent the field from being included in the query. If you want to make this switchable for each query, you could move it to another variable like $Model->_schemaInactiveFields and move it back when needed. You could even make a Behavior for this.
The parameter fields may help you.It doesn't ignore fields but specifies fields you want:
array(
'conditions' => array('Model.field' => $thisValue), //array of conditions
'fields' => array('Model.field1', 'Model.field2'), //list columns you want
)
You can get more information of retrieving data in the cookbook .
Another idea:
Define your special query in the model:
function myfind($type,$params)
{
$params['fields'] = array('Model.field1','Model.field2',...);
return $this->find($type,$params);
}
Then use it in the controller
$this->Model->myfind($type,$params);
Also try containable behaviour will strip out all unwanted fields and works on model associations as well.
Containable
class Post extends AppModel { <br>
var $actsAs = array('Containable'); <br>
}
where Post is your model?
You can add a beforeFilter function in your Table and add a select to the query
Excample:
public function beforeFind(Event $event, Query $query){
$protected = $this->newEntity()->hidden;
$tableSchema = $event->subject()->schema();
$fields = $tableSchema->columns();
foreach($fields as $key => $name){
if(in_array($name,$protected)){
unset($fields[$key]);
}
}
$query->select($fields);
return $event;
}
In this excample I took the hidden fields from the ModelClass to exclude from result.
Took it from my answer to a simular question here : Hidden fields are still listed from database in cakephp 3

Resources