Bounty:
+500 rep bounty to a GOOD solution. I've seriously banged my head against this wall for 2 weeks now, and am ready for help.
Tables/Models (simplified to show associations)
nodes
id
name
node_type_id
node_associations
id
node_id
other_node_id
node_types
id
name
General Idea:
A user can create node types (example "TV Stations", "TV Shows", and "Actors"...anything). If I knew ahead of time what the node types were and the associations between each, I'd just make models for them - but I want this to be very open-ended so the user can create any node-types they want. Then, each node (of a specific node-type) can relate to any other node of any other node-type.
Description and what I've tried:
Every node should be able to be related to any/every other node.
My assumption is that to do that, I must have an association table - so I made one called "node_associations" which has node_id and other_node_id.
Then I set up my association (using hasMany through I believe):
(below is my best recollection of my set-up... it might be slightly off)
//Node model
public $hasMany = array(
'Node' => array(
'className' => 'NodeAssociation',
'foreignKey' => 'node_id'
),
'OtherNode' => array(
'className' => 'NodeAssociation',
'foreignKey' => 'other_node_id'
)
);
//NodeAssociation model
public $belongsTo = array(
'Node' => array(
'className' => 'Node',
'foreignKey' => 'node_id'
),
'OtherNode' => array(
'className' => 'Node',
'foreignKey' => 'other_node_id'
)
);
At first, I thought I had it - that this made sense. But then I started trying to retrieve the data, and have been banging my head against the wall for the past two weeks.
Example Problem(s):
Lets say I have a the following nodes:
NBC
ER
George Clooney
Anthony Edwards
Tonight Show: Leno
Jay Leno
Fox
Family Guy
How can I set up my data structure to be able to pull the all TV Stations, and contain their TV Shows, which contain their Actors (as example)? This would be SIMPLE with normal model setup:
$this->TvStation->find('all', array(
'contain' => array(
'TvShow' => array(
'Actor'
)
)
));
And then, maybe I want to retrieve all male Actors and contain the TV Show which contain the TV Station. Or TV Shows that start at 9pm, and contain it's actor(s) and it's station...etc etc.
But - with HABTM or HasMany Through self (and more importantly, and unknown data set), I wouldn't know which field (node_id or other_node_id) the model is, and overall just can't wrap my head around how I'd get the content.
The Idea
Let's try to solve this with convention, node_id will be the model who's alias comes alphabetically first and other_node_id will be the one that comes second.
For each contained model, we create a HABTM association on-the-fly to Node class, creating an alias for each association (see bindNodes and bindNode method).
Each table we query we add an extra condition on node_type_id to only return results for that type of node. The id of NodeType is selected via getNodeTypeId() and should be cached.
For filtering results using condition in deeply related associations, you would need to manually add extra join, creating a join for each jointable with a unique alias and then joining each node type itself with an alias to be able to apply the conditions (ex. selecting all TvChannels that have Actor x). Create a helper method for this in Node class.
Notes
I used foreignKey for node_id and associationForeignKey for other_node_id for my demo.
Node (incomplete)
<?php
/**
* #property Model NodeType
*/
class Node extends AppModel {
public $useTable = 'nodes';
public $belongsTo = [
'NodeType',
];
public function findNodes($type = 'first', $query = []) {
$node = ClassRegistry::init(['class' => 'Node', 'alias' => $query['node']]);
return $node->find($type, $query);
}
// TODO: cache this
public function nodeTypeId($name = null) {
if ($name === null) {
$name = $this->alias;
}
return $this->NodeType->field('id', ['name' => $name]);
}
public function find($type = 'first', $query = []) {
$query = array_merge_recursive($query, ['conditions' => ["{$this->alias}.node_type_id" => $this->nodeTypeId()]]);
if (!empty($query['contain'])) {
$query['contain'] = $this->bindNodes($query['contain']);
}
return parent::find($type, $query);
}
// could be done better
public function bindNodes($contain) {
$parsed = [];
foreach($contain as $assoc => $deeperAssoc) {
if (is_numeric($assoc)) {
$assoc = $deeperAssoc;
$deeperAssoc = [];
}
if (in_array($assoc, ['conditions', 'order', 'offset', 'limit', 'fields'])) {
continue;
}
$parsed[$assoc] = array_merge_recursive($deeperAssoc, [
'conditions' => [
"{$assoc}.node_type_id" => $this->nodeTypeId($assoc),
],
]);
$this->bindNode($assoc);
if (!empty($deeperAssoc)) {
$parsed[$assoc] = array_merge($parsed[$assoc], $this->{$assoc}->bindNodes($deeperAssoc));
foreach($parsed[$assoc] as $k => $v) {
if (is_numeric($k)) {
unset($parsed[$assoc][$k]);
}
}
}
}
return $parsed;
}
public function bindNode($alias) {
$models = [$this->alias, $alias];
sort($models);
$this->bindModel(array(
'hasAndBelongsToMany' => array(
$alias => array(
'className' => 'Node',
'foreignKey' => ($models[0] === $this->alias) ? 'foreignKey' : 'associationForeignKey',
'associationForeignKey' => ($models[0] === $alias) ? 'foreignKey' : 'associationForeignKey',
'joinTable' => 'node_associations',
)
)
), false);
}
}
Example
$results = $this->Node->findNodes('all', [
'node' => 'TvStation', // the top-level node to fetch
'contain' => [ // all child associated nodes to fetch
'TvShow' => [
'Actor',
]
],
]);
I think you have incorrect relations between your models. I guess it will be enough with:
// Node Model
public $hasAdBelongsToMany = array(
'AssociatedNode' => array(
'className' => 'Node',
'foreignKey' => 'node_id'
'associationForeignKey' => 'associated_node_id',
'joinTable' => 'nodes_nodes'
)
);
// Tables
nodes
id
name
node_type_id
nodes_nodes
id
node_id
associated_node_id
node_types
id
name
Then you can try using ContainableBehavior to fetch your data. For Example, to find all TVShows belonging to a TVStation:
$options = array(
'contain' => array(
'AssociatedNode' => array(
'conditions' => array(
'AssociatedNode.node_type_id' => $id_of_tvshows_type
)
)
),
conditions => array(
'node_type_id' => $id_of_tvstations_type
)
);
$nodes = $this->Node->find('all', $options);
EDIT :
You can even have second level conditions (see last example on this section, look at the 'Tag' model conditions). Try this:
$options = array(
'contain' => array(
'AssociatedNode' => array(
'conditions' => array(
'AssociatedNode.node_type_id' => $id_of_tvshows_type
),
'AssociatedNode' => array(
'conditions' => array( 'AssociatedNode.type_id' => $id_of_actors_type)
)
)
),
conditions => array(
'node_type_id' => $id_of_tvstations_type
)
);
$nodes = $this->Node->find('all', $options);
I think unfortunately part of the problem is that you want your solution to contain user data in the code. Since all your nodes types are user data, you want to avoid trying to use those as the classes methods in your application, as there could be infinite of them. Instead I would try and create methods that model the data operations you want to have.
One omission I see in the provided data model is a way to record the relationships between types. In your example you mention a relationship between TvStation -> TvShows -> Actor etc. But where are these data relationships defined/stored? With all of your node types being user defined data, I think you'll want to/need to record store those relationships somewhere. It seems like node_types needs some additional meta data about what the valid or desired child types for a given type are. Having this recorded somewhere might make your situation a bit simpler when creating queries. It might help to think of all the questions or queries you're going to ask the database. If you cannot answer all those questions with data that is in the database, then you are probably missing some tables. Model associations are just a proxy for data relations that already exist in your tables. If there are gaps there are probably gaps in your data model.
I don't think this is the answer you're looking for but hopefully it helps you find the right one.
Why don't you create a method in the node model?
Something like:
<?php
// first argument is a nested array filled with integers
(corresponding to node_type_id)
//second one id of a node
//third one corresponds to the data you want(empty at beginning in most case)
public function custom_find($conditions,$id,&$array){
//there may several type of nodes wanted: for instances actors and director of a serie, so we loop
foreach($conditions as $key_condition=>$condition){
//test to know if we have reached the 'bottom' of the nested array: if yes it will be an integer '2', if no it will be an array like '2'=>array(...)
if(is_array($condition))){
//this is the case where there is deeper levels remaining
//a find request: we ask for the node defined by its id,
//and the child nodes constrained by their type: ex: all actors of "Breaking Bad"
$this->id=$id;
$result=$this->find('all',array(
'contain' => array(
'OtherNode' => array(
'conditions'=>array('node_type_id'=>$key_condition)
)
)
)
);
//we add to $array the nodes found. Ex: we add all the actors of the serie, with type_id as key
$array[$key_condition]=$result['OtherNode'];
//Then on each node we just defined we call the function recursively. Note it's $condition not $conditions
foreach($array[$key_condition] as &$value){
$this->custom_find($condition,$value['Node']['id'],$value);
}
}else{
//if we simply add data
$this->id=$id;
$result=$this->find('all',array(
'contain' => array(
'OtherNode' => array(
'conditions'=>array('node_type_id'=>$value)
)
)
)
);
$array[$condition]=$result['OtherNode'];
}
}
}
That code is almost certainly wrong, it's just to give you an idea of what I mean.
Edit:
What it does:
it's a recursive function that takes a nested array of conditions and the id of a node and gives back nested array of nodes.
For instance: $conditions=array('2','4'=>array('5','6'=>array('4')))
How it works:
For a single node it gives back all the child nodes corresponding to the condition in the array: then it does the same for the children with the conditions one level deeper, until there is no more levels left.
Related
I have a User model with two relations:
HasAndBelongsToMany
public $hasAndBelongsToMany = array(
'group' => array(
'className' => 'group',
'foreignKey' => 'user_id'
)
);
HasMany
public $hasMany = array(
'phonenumber' => array(
'className' => 'phonenumber',
'foreignKey' => 'user_id'
)
);
Phonenumber and Group have set
public $belongsTo = array(
'User' => array(
'className' => 'User',
'foreignKey' => 'user_id'
)
);
When I use
$this->saveAll(array(
'User' => $data,
'phonenumber' => $numbers, //array with data
'group' => $groups //array with data
)
);
The data gets saved in the tabels but User_id is "0" in phonenumber and group table.
How can I get the correct ID saved ? (CakePHP v 2.5)
FWIW saveAll() should work as advertised, populating the new user_id in the child tables in one fell swoop.
Have you paid attention to the relevant options: atomic & deep?
Especially if database does not support transactions, you'll need to pass in atomic:
$this->saveAll(array(
'User' => $data,
'phonenumber' => $numbers, //array with data
'group' => $groups //array with data
),
array('atomic' => false)
);
Considering the CakePHP documentation you will find this hint:
When working with associated models, it is important to realize that
saving model data should always be done by the corresponding CakePHP
model. If you are saving a new Post and its associated Comments, then
you would use both Post and Comment models during the save operation
(http://book.cakephp.org/2.0/en/models/saving-your-data.html#saving-related-model-data-hasone-hasmany-belongsto)
Based on that information I suggest you try the following:
$this->User->save($data); // first save the user
Assuming you have multiple numbers:
foreach($numbers as $key => $nbr) {
// set user_id related data
$numbers[ $key ]['Phonenumber']['user_id'] = $this->User->id;
}
Finally save your related data:
$this->User->Phonenumber->saveAll($numbers);
Since this code is untested you may need to take some adjustments. Always ensure to follow the Cake-Conventions and use CamelCase ModelNames.
I want to display a drop-down list of the list of student name with its value, student_user_id. But the problem is student name is stored in a different model which is hard to align together in find('list'), a find function of Cakephp.
I have some code of the model project that already joins tables. When I code the getStudentList, it does not know the User.user_display_user field.
Model
public $belongsTo = array(
'User' => array(
'className' => 'User',
'foreignKey' => false,
'conditions' => array('User.user_id = Project.student_user_id')
)
);
function getStudentList()
{
$entry = $this->find('list',array('fields' => array('Project.student_user_id','User.user_display_name')));
return $entry;
}
So: How I can handle this issue?
You should use containable behavior and tell CakePHP that in this case you want to join those two tables:
$entry = $this->find('list', array(
'fields' => array('Project.student_user_id', 'User.user_display_name'),
'contain' => array('User')
));
By default joins should not be made until specifically requested - for performance reasons.
See http://book.cakephp.org/2.0/en/core-libraries/behaviors/containable.html
Note:
$recursive = 0 would also work but is discouraged to use.
Basically i have 3 tables users , jobs and users_jobs.
users_jobs is id,job_id,user_id and is basically used for keeping track of what jobs a user has assigned.
Jobs can be assigned.unassigned by adding/removing entries in the users_jobs tables.
In terms of cakephp im struggling to understand how to model this.
So for i have a Job model that has the attribute
public $hasAndBelongsToMany = array(
'User' => array(
'className' => 'User',
'joinTable' => 'users_jobs',
'foreignKey' => 'job_id',
'associationForeignKey' => 'user_id'
)
);
My User model has the attribute
public $hasAndBelongsToMany = array(
'Job' => array(
'className' => 'Job',
'joinTable' => 'users_jobs',
'foreignKey' => 'user_id',
'associationForeignKey' => 'job_id'
)
);
In my JobsController.php i have the function unassign which is designed to unassign a user from a job. how can i modify the users_jobs table a remove the relation without remove the user or job ?
lets say you have three fields on user_jobs:
status, user_id and job_id
Assuming your table user_jobs has one field status which is defines assigned or unassigned
status=1 => Unassigned
status=0 => assigned
Step 1: Create one methods in JobsController.php
function userjobs($jobId, $userId, $assign = 1){
# $assign is 1 means true and 0 means unassigned
# if you want to assign a job to user
$data['UserJob']['user_id'] = $userId;
$data['UserJob']['job_id'] = $jobId;
App::import('model','UserJob');
$UserJob = new UserJob();
if($assign == 1){
$UserJob->save($data);
}else{
$UserJob->updateAll(
array('UserJob.status'=>1),
array('UserJob.user_id'=>$userId,'UserJob.job_id'=>$jobId));
}
}
Step 2: call this method on ajax.
It will do the desired thing.
I understand that this has been asked 100,000 times but I've pretty much read all 100,000 replies and none of them seem to match what I'm after. I've tried every possible combination (obviously not) and I'm afraid I'm getting beat at something so relatively simple. This is my 2nd Cake project, so I'm by no means an expert.
Profile -> BelongsTo -> Store, Store -> BelongsTo -> Region, Region -> HasMany -> Stores
Profile.php
class Profile extends AppModel {
public $belongsTo = array(
'Store' => array(
'className' => 'Store',
'foreignKey' => 'store_id'....
Store.php
class Store extends AppModel {
public $belongsTo = array(
'Region' => array(
'className' => 'Region',
'foreignKey' => 'region_id'....
Region.php
class Region extends AppModel {
public $hasMany = array(
'Store' => array(
'className' => 'Store',
'foreignKey' => 'region_id'....
ProfileController.php
$this->Paginator->settings = array(
'conditions' => array('Profile.job_title_id' => '1'),
'contain' => array(
'Store'=> array(
'Region'
)
)
);
$UserArds = $this->Paginator->paginate('Profile');
$this->set(compact('UserArds'));
view.php
<th><?php echo $this->Paginator->sort('Region.name', 'Region'); ?></th>
All I'm looking to do, is to be able to sort by Region name when using paginator. No matter what combination I use, I just can't seem to be able to sort Region.name. The order By clause is omitted with all other 2-level-deep associations but works fine any other time (with same level or 1st level).
Can anyone suggest a fix for this simple mistake?
Look at the SQL debug output, the regions are being retrieved using separate queries, that's how Cakes ORM currently works.
In your case there's a relatively simple workaround. Just create a proper association on the fly, for example Profile belongsTo Region.
Here's an (untested) example, it adds a belongsTo association with the foreignKey option set to false in order to disable the automatic foreign key generation by the ORM, this is necessary as it would otherwise look for something like Profile.region_id. Note that consequently the contain config has to be changed too!
$this->Profile->bindModel(array(
'belongsTo' => array(
'Region' => array(
'foreignKey' => false,
'conditions' => array('Region.id = Store.region_id')
),
)
));
$this->Paginator->settings = array(
'conditions' => array('Profile.job_title_id' => '1'),
'contain' => array('Store', 'Region')
);
That should generate a proper query that includes the stores as well as the regions table, making it possible to sort on Region.name. Note that the resulting array will be a little different, Region will not be nested in Store, everything will be placed in the same level, ie:
Array
(
[Profile] => Array
(
....
)
[Store] => Array
(
....
)
[Region] => Array
(
....
)
)
Either you modify your view code accordingly, or you transform the retrieved data before passing it to the view. A third option would be to include a nested Region in the contain config, however that would result in additional queries that are totally unnecessary as the data was already fetched with the main query, so I wouldn't recommend that.
I am trying to do a search, using pagination for posts which have a specific tag or tags (for example, if a user was to select two tags, then posts containing either tag would be returned).
I have the relationship defined in my Posts table
public $hasAndBelongsToMany = array('Tags' => array(
'className' => 'Tags',
'joinTable' => 'posts_tags',
'foreignKey' => 'post_id',
'associationForeignKey' => 'tag_id',
'unique' => 'keepExisting'));
How do I use Find to retrieve rows with a given tag (name or ID would be fine)
Trying:
// other pagination settings goes here
$this->paginate['conditions']['Tags.id'] = 13;
gives me an error that the relationship does not exist.
Looking at the debug info it appears that the tables are not joining the Posts_Tags and Tags table, however, when I debug the data making it to the view, the Posts objects contain the tags data.
Most of the documentation I can find for this seems to revolve around earlier versions of CakePHP, any help would be appreciated.
Could not find a satisfying solution myself.
I created a behavior to take care of this.
Create a file called HabtmBehavior.php and put it in your app/Model/Behavior folder.
Put the block of code in there and save file.
Add the behavior to your model: eg public $actsAs = array('Habtm');
Here is a usage example with find.
<?php $this->Entry->find('all', array('habtm'=>array('Tag'=>array('Tag.title'=>'value to find'))) ?>
Paginate would look something like this:
$this->paginate['Entry']['habtm']['Tag'] = array('Tag.title'=>'value to find');
You are free to add as many relations as you want by adding additional Model Names in the habtm array.
(Just be careful not to make it to complex since this could start slowing down your find results.)
<?php
class HabtmBehavior extends ModelBehavior {
public function beforeFind(Model $model, $options) {
if (!isset($options['joins'])) {
$options['joins'] = array();
}
if (!isset($options['habtm'])) {
return $options;
}
$habtm = $options['habtm'];
unset($options['habtm']);
foreach($habtm as $m => $scope){
$assoc = $model->hasAndBelongsToMany[$m];
$bind = "{$assoc['with']}.{$assoc['foreignKey']} = {$model->alias}.{$model->primaryKey}";
$options['joins'][] = array(
'table' => $assoc['joinTable'],
'alias' => $assoc['with'],
'type' => 'inner',
'foreignKey' => false,
'conditions'=> array($bind)
);
$bind = $m.'.'.$model->{$m}->primaryKey.' = ';
$bind .= "{$assoc['with']}.{$assoc['associationForeignKey']}";
$options['joins'][] = array(
'table' => $model->{$m}->table,
'alias' => $m,
'type' => 'inner',
'foreignKey' => false,
'conditions'=> array($bind) + (array)$scope,
);
}
return $options;
}
}
Hope this helps.
Happy baking.
I think the best solution is apply find function on join table Model. I try this before and it's work fine.
in your PostTag model :
/**
* #see Model::$actsAs
*/
public $actsAs = array(
'Containable',
);
/**
* #see Model::$belongsTo
*/
public $belongsTo = array(
'Post' => array(
'className' => 'Post',
'foreignKey' => 'post_id',
),
'Tags' => array(
'className' => 'Tag',
'foreignKey' => 'tag_id',
),
);
in your controller :
// $tagsId = tags ids
$posts = $this->PostTag->find('all', array('conditions' => array('PostTag.tag_id' => $tagsId),'contain' => array('Post')));
also is better follow cake naming convention, if you have tags(plural), post_tags(first singular second plural),posts(plural) tables you must have Tag,PostTag,Post Models.