Rewriting a complex query using CakePHP - cakephp

I quickly wrote a quite complex (in terms of structure) SQL query manually in CakePHP initially, but now I am trying to rewrite it to run withing the CakePHP find method.
$sql = "SELECT
`users`.`username`,
(SELECT ROUND(SUM(`divisions`.`amount`), 2)
FROM `purchases`
INNER JOIN `divisions`
ON `purchases`.`id` = `divisions`.`purchase_id`
WHERE `purchases`.`user_id` = `users`.`id`
AND `divisions`.`user_id` = `users`.`id`
AND `purchases`.`group_id` = " . $group_id . "
) AS `owed_to`
FROM `users`
INNER JOIN `memberships` ON `users`.`id` = `memberships`.`user_id`
INNER JOIN `groups` ON `memberships`.`group_id` = `groups`.`id`
WHERE `memberships`.`group_id` = " . $group_id . " AND
`users`.`id` != " . $user_id . ";";
Because SQL allows you to apply the WHERE filter across the whole query it becomes very simple. In Cake you cannot just go:
$results = $this->User->find('all', array(
'conditions' => array(
'Membership.group_id =' => $id
),...
I have tried setting joins:
$joins = array(
array('table'=>'memberships',
'alias' => 'Membership',
'type'=>'inner',
'conditions'=> array(
'Membership.user_id = User.id', 'Membership.group_id' => $id)
),
Which works OK for a single layer of recursion, but then models related to membership (such as group) are not subject to the filter.
I can only imagine I am doing something completely wrong.
Basically I am confused, any help would be appreciated.
Futher information
User => HasMany => Purchase, Membership, Division
Membership => BelongsTo => Group, User
Group => HasMany => Membership
Purchase => HasMany => Division
Purchase => BelongsTo => User, Group
Division => BelongsTo => Purchase, User

You can write it like this:
$results = $this->User->find('all', array(
'conditions' => array(
'Membership.group_id' => $id
),...
If User has many Membership and Membership belongs to User, and $this->User->recursive = 1, then it should work

Related

CakePHP conditions in associated Models

I have a database with Classes, Semesters, Users, and Visits(Visits is a join table for Users and Classes)
The relations are:
User hasAndBelongsToMany Class (through Visits
Class belongsTo Semester
Now I want to view all Visits with Classes in an active Semester (The Semester table has a field is_active)
I read about a contain option for the find method and tried something like this:
$option = array(
"contain" => array(
"Class" => array(
"Semester" => array(
"conditions" => array("Semester.is_active" => true)
)
),
),
"conditions" => array(
"Visit.user_id" => $id,
)
);
But with this, classes in a not active semester are found, only the semester isn't.
Is something wrong with this? Or is there an other way?
Now I have a solution:
I used the joins option for the find method.
"joins" => array(
array(
"table" => "classes_semesters",
"alias" => "ClassesSemesters",
"type" => "inner",
"conditions" => array(
"Visit.class_id = ClassesSemesters.class_id"
)
),
array(
"table" => "semesters",
"alias" => "Semester",
"type" => "inner",
"conditions" => array(
"ClassesSemesters.semester_id = Semester.id"
)
)
),
"conditions" => array(
"Visit.user_id" => $id,
"Semester.is_active" => true
),
When you use contain, like in your case, the query is not a direct inner join, that is, Class inner joins with Semester.
the class records are pulled first, then there will be a second query on the semesters table with the condition (where class_id IN ( results of first query).
so even if there are no records found in the semesters table, cake will still return the Class records found.
query 1. $class = select * from classes where bla bla
query 2. select * from semesters where class_id in (result from query 1) and other conditions bla bla
cake then merges the result from the 2 queries together to produce 1 result.

HABTM selection seemingly ignores joinTable

UPDATE #2 -- SOLUTION FOUND:
Turns out my use of this lookup:
$this->User->Group->find(....)
was not what I needed. To pull out a user's groups I needed to use:
$this->User->find('all',array('conditions' => array('User.id' => $user_id)));
< /UPDATE #2>< PROBLEM>
I'm attempting to do a HABTM relationship between a Users table and Groups table. The problem is, that I when I issue this call:
$this->User->Group->find('list');
The query that is issued is:
SELECT [Group].[id] AS [Group__id], [Group].[name] AS [Group__name] FROM [groups] AS [Group] WHERE 1 = 1
I can only assume at this point that I have defined my relationship wrong as I would expect behavior to use the groups_users table that is defined on the database as per convention. My relationships:
class User extends AppModel {
var $name = 'User';
//...snip...
var $hasAndBelongsToMany = array(
'Group' => array(
'className' => 'Group',
'foreignKey' => 'user_id',
'associationForeignKey' => 'group_id',
'joinTable' => 'groups_users',
'unique' => true,
)
);
//...snip...
}
class Group extends AppModel {
var $name = 'Group';
var $hasAndBelongsToMany = array ( 'User' => array(
'className' => 'User',
'foreignKey' => 'group_id',
'associationForeignKey' => 'user_id',
'joinTable' => 'groups_users',
'unique' => true,
));
}
Is my understanding of HABTM wrong? How would I implement this Many to Many relationship where I can use CakePHP to query the groups_users table such that a list of groups the currently authenticated user is associated with is returned?
UPDATE
After applying the change suggested by ndm I still receive a large array return (Too big to post) which returns all groups and then a 'User' element if the user has membership to that group. I looked at the query CakePHP uses again:
SELECT
[User].[id] AS [User__id],
[User].[username] AS [User__username],
[User].[password] AS [User__password],
[User].[email] AS [User__email], CONVERT(VARCHAR(20),
[User].[created], 20) AS [User__created], CONVERT(VARCHAR(20),
[User].[modified], 20) AS [User__modified],
[User].[full_name] AS [User__full_name],
[User].[site] AS [User__site],
[GroupsUser].[user_id] AS [GroupsUser__user_id],
[GroupsUser].[group_id] AS [GroupsUser__group_id],
[GroupsUser].[id] AS [GroupsUser__id]
FROM
[users] AS [User] JOIN
[groups_users] AS [GroupsUser] ON (
[GroupsUser].[group_id] IN (1, 2, 3, 4, 5) AND
[GroupsUser].[user_id] = [User].[id]
)
Is there an easy way to refine that such that I only receive the group ids & names for the entries I have membership to? I was thinking of using:
array('conditions'=>array('GroupsUser.user_id'=>$user_id))
...but I receive an sql error on the groups table:
SELECT TOP 1 [Group].[name] AS [Group__name], CONVERT(VARCHAR(20), [Group].[created], 20) AS [Group__created], CONVERT(VARCHAR(20), [Group].[modified], 20) AS [Group__modified], [Group].[id] AS [Group__id] FROM [groups] AS [Group] WHERE [GroupsUser].[user_id] = 36 ORDER BY (SELECT NULL)
I think you just misunderstood what the list find type is ment to do.
The query is totally fine, the list find type is used for retreiving a list of records of a single model only, where the models primary key is used as index, and the display field as value.
http://book.cakephp.org/2.0/en/models/retrieving-your-data.html#find-list

conditions in find method

I have 3 models,
Mentor, MentorAttrib, Attrib where MentorAttrib is a join table. it lists Mentor.id -> Attrib.id
This is my find
$cond = array("Mentor.is_listed"=>1);
$contain_cond = array();
$contain = array(
'MentorAttrib' => array(
'fields' => array('MentorAttrib.id' ,'MentorAttrib.attrib_id'),
'Attrib'
)
);
if(! empty($this->request->data))
{
debug($this->request->data);
//skills
if(! empty($this->request->data['bookingSkills']))
{
$cond = array('MentorAttrib.attrib_id' => $this->request->data['bookingSkills']);
}
}
$this->request->data = $this->Mentor->find('all', array(
'conditions' => $cond,
'fields' => array('Mentor.id','Mentor.first_name','Mentor.last_name','Mentor.img'),
'contain' => $contain
));
I want to filter the result by the skills.
[bookingSkills] => Array
(
[0] => 2
[1] => 4
[2] => 10
)
The error im getting is
Column not found: 1054 Unknown column 'MentorAttrib.attrib_id' in 'where clause'
This is the data set
http://pastebin.com/85uBFEfF
You need a join
By default, CakePHP will only create joins for hasOne and belongsTo associations - any other type of association generates another query. As such you cannot filter results based on a condition from a hasMany or hasAndBelongsToMany association by just injecting conditions and using contain.
Forcing a join
On option to achieve the query you need is to use the joins key. As shown in the docs You can also add a join, easily, like so:
$options['joins'] = array(
array('table' => 'mentor_attribs',
'alias' => 'MentorAttrib',
'type' => 'LEFT',
'conditions' => array(
'Mentor.id = MentorAttrib.mentor_id',
)
)
);
$data = $this->Mentor->find('all', $options);
This allows the flexibility to generate any kind of query.

Pagination with Containable conditions work with hasOne, but not with hasMany

For example, I have this relationship:
UserContact hasMany Contact
Contact hasOne Info
Contact hasMany Response
And I need to paginate Contact, so I use Containable:
$this->paginate = array(
'limit'=>50,
'page'=>$page,
'conditions' =>array('Contact.id'=>$id),
'contain'=>array(
'Response',
'Info'
)
);
I want to add search by Info.name, and by Response.description. It works perfect for Info.name, but it throws an error if I try using Response.description, saying that the column doesn't exist.
Additionally, I tried changing the relationship to Contact hasOne Response, and then it filters correctly, but it only returns the first response and this is not the correct relationship.
So, for example, if I have a search key $filter I'd like to only return those Contacts that have a matching Info.name or at least one matching Response.description.
If you look at how CakePHP constructs SQL queries you'll see that it generates contained "single" relationships (hasOne and belongsTo) as join clauses in the main query, and then it adds separate queries for contained "multiple" relationships.
This makes filtering by a single relationship a breeze, as the related model's table is already joined in the main query.
In order to filter by a multiple relationship you'll have to create a subquery:
// in contacts_controller.php:
$conditionsSubQuery = array(
'Response.contact_id = Contact.id',
'Response.description LIKE' => '%'.$filter.'%'
);
$dbo = $this->Contact->getDataSource();
$subQuery = $dbo->buildStatement(array(
'fields' => array('Response.id'),
'table' => $dbo->fullTableName($this->Contact->Response),
'alias' => 'Response',
'conditions' => $conditionsSubQuery
), $this->Contact->Response);
$subQuery = ' EXISTS (' . $subQuery . ') ';
$records = $this->paginate(array(
'Contact.id' => $id,
$dbo->expression($subQuery)
));
But you should only generate the subquery if you need to filter by a Response field, otherwise you'll filter out contacts that have no responses.
PS. This code is too big and ugly to appear in the controller. For my projects I refactored it into app_model.php, so that each model can generate its own subqueries:
function makeSubQuery($wrap, $options) {
if (!is_array($options))
return trigger_error('$options is expected to be an array, instead it is:'.print_r($options, true), E_USER_WARNING);
if (!is_string($wrap) || strstr($wrap, '%s') === FALSE)
return trigger_error('$wrap is expected to be a string with a placeholder (%s) for the subquery. instead it is:'.print_r($wrap, true), E_USER_WARNING);
$ds = $this->getDataSource();
$subQuery_opts = array_merge(array(
'fields' => array($this->alias.'.'.$this->primaryKey),
'table' => $ds->fullTableName($this),
'alias' => $this->alias,
'conditions' => array(),
'order' => null,
'limit' => null,
'index' => null,
'group' => null
), $options);
$subQuery_stm = $ds->buildStatement($subQuery_opts, $this);
$subQuery = sprintf($wrap, $subQuery_stm);
$subQuery_expr = $ds->expression($subQuery);
return $subQuery_expr;
}
Then the code in your controller becomes:
$conditionsSubQuery = array(
'Response.contact_id = Contact.id',
'Response.description LIKE' => '%'.$filter.'%'
);
$records = $this->paginate(array(
'Contact.id' => $id,
$this->Contact->Response->makeSubQuery('EXISTS (%s)', array('conditions' => $conditionsSubQuery))
));
I can not try it now, but should work if you paginate the Response model instead of the Contact model.

Problem with CakePHP model joins, missing fields from joined table.

Im probably missing something painfully obvious here. Im trying to join multiple tables together in Cake, but Im only getting the fields from the first table. Consider the following code..
$joins = array();
$joins[] = array(
'table' => 'products',
'alias' => 'Product',
'type' => 'LEFT',
'conditions' => array(
'Device.id = Product.device_id'
)
);
$joins[] = array(
'table' => 'pricepoints',
'alias' => 'Pricepoints',
'type' => 'LEFT',
'conditions' => array(
'Pricepoints.product_id = Product.id'
)
);
$all_products = $this->Device->find('all', array("joins" => $joins);
And this returns the following SQL
SELECT `Device`.`id`, `Device`.`manufacturer_id`, `Device`.`name`, `Device`.`type_id`, `Manufacturer`.`id`, `Manufacturer`.`name` FROM `devices` AS `Device` LEFT JOIN products AS `Product` ON (`Device`.`id` = `Product`.`device_id`) LEFT JOIN pricepoints AS `Pricepoints` ON (`Pricepoints`.`product_id` = `Product`.`id`) LEFT JOIN `manufacturers` AS `Manufacturer` ON (`Device`.`manufacturer_id` = `Manufacturer`.`id`)
ie. it only returns the fields from the parent Model ie. Device. How do I get it to select ALL fields from the join? Im presuming its to do with the way I have my Model relationships set up, but I think I have these set up correctly.
Anyone any advice?
you can specify the fields in the find query:
$all_products = $this->Device->find('all', array("fields" => array('Device.*','Product.*','Pricepoints.*')
"joins" => $joins
);
Hope this helps
Do your join by calling bindModel() instead of manually specifying the join. See the cookbook for the details on creating and destroying associations on the fly

Resources