Insert/Delete methods for self-referencing belongsToMany association - cakephp

I’m struggling with a self-referencing belongsToMany association. To be clear I have a Models table and each model can have multiple accessories, which are also models. So I have a linking table, Accessories, with a model_id (the “parent” model) and an accessory_id (the “child” model).
I finally found how to declare this in the ModelsTable :
$this->belongsToMany('AccessoryModels', [
'className' => 'Models',
'through' => 'Accessories',
'foreignKey' => 'model_id',
'targetForeignKey' => 'accessory_id'
]);
$this->belongsToMany('ParentAccessoryModels', [
'className' => 'Models',
'through' => 'Accessories',
'foreignKey' => 'accessory_id',
'targetForeignKey' => 'model_id'
]);
I also got it working to retrieve these data in the Models view.
But I now have some issues for the addAccessory (and deleteAccessory) method in the Models controller and views.
Here is it in the controller :
public function addAccessory($id = null)
{
$model = $this->Models->get($id, [
'contain' => []
]);
if ($this->getRequest()->is(['patch', 'post', 'put'])) {
$accessory = $this->getRequest()->getData();
if ($this->Models->link($model, [$accessory])) {
return $this->redirect(['action' => 'view', $model->id]);
}
}
$models = $this->Models
->find('list', ['groupField' => 'brand', 'valueField' => 'reference'])
->order(['brand' => 'ASC', 'reference' => 'ASC']);
$this->set(compact('model', 'models'));
}
The view is only a select dropdown with the list of all available models (I'm using a plugin, AlaxosForm, but it takes the original CakePHP control() function behaviour) :
echo $this->AlaxosForm->label('accessory_id', __('Accessory'), ['class' => 'col-sm-2 control-label']);
echo $this->AlaxosForm->control('accessory_id', ['options' => $models, 'empty' => true, 'label' => false, 'class' => 'form-control']);
echo $this->AlaxosForm->button(___('Submit'), ['class' => 'btn btn-default']);
The problem is that the addAccessory() function won't work when getting the submitted data from the form. I see the problem, as when posting the inserted values, only an array with one accessory_id is given (for example ['accessory_id' => 1] and the link() doesn't know what to do with it. So I think it’s an issue about data formatting but don’t see how to get it correctly.

Saving links (like any other ORM saving methods) requires to pass entities, anything else will either be ignored, or trigger errors.
So you have to use the accessory_id to obtain an entity first, something like:
$accessoryId = $this->getRequest()->getData('accessory_id');
$accessory = $this->Models->AccessoryModels->get($accessoryId);
Furthermore you need to use the model/table that corresponds to the target entities (second argument) that you want to link (to the first argument), ie you'd have to use AccessoryModels, like:
$this->Models->AccessoryModels->link($model, [$accessory])
See also
Coobook > Database Access & ORM > Saving Data > Associate Many To Many Records

Related

CakePHP3: cannot get matching working properly

If I write the following query:
$sites = $this->Sites->find()
->contain(['Agthemes'])
->matching('Agthemes', function ($q) {
return $q->where([
'Agthemes.site_id IS NOT' => null
]);
})
->all();
I only get Sites which have existing Agthemes.
Now I write a similar query but with one additional association level:
$users = $this->Users->find('all')
->contain([
'Sites.Agthemes'
])
->matching('Sites.Agthemes', function ($q) {
return $q->where([
'Agthemes.site_id IS NOT' => null
]);
})
->distinct(['Users.id'])
->limit(5)
->all();
And in that case, I also get Sites with empty Agthemes.
Could you tell me why?
EDIT
I add the relationships
SitesTable
$this->hasMany('Agthemes', [
'dependent' => true,
'cascadeCallbacks' => true,
]);
$this->belongsToMany('Users', [
'joinTable' => 'sites_users',
]);
UsersTable
$this->belongsToMany('Sites', [
'targetForeignKey' => 'site_id',
'joinTable' => 'sites_users',
]);
AgthemesTable
$this->belongsTo('Sites');
In DebugKit, look at the queries being run. Using 'contain' often runs completely separate queries, then combines the results (depends on the association type).
If you want to be sure to limit the results based on conditions against an associated model, use 'joins' instead of 'contain'.
See this page for details about how the associations use (or don't use) joins, and how you can change the join strategy...etc:
https://book.cakephp.org/3.0/en/orm/associations.html

Creating Association with condition using other association in CakePHP 3

I'm building a cake php 3 app. My app model includes 3 Tables (amongst others):
Structures
MeasuringPoints
DeviceTypes
where each Strcuture can have multiple MeasuringPoints:
// StrcuturesTable.php
...
public function initialize(array $config)
{
parent::initialize($config);
...
$this->hasMany('MeasuringPoints', [
'foreignKey' => 'structure_id'
]);
}
Further, each measuring point is of a certain device type:
// MeasuringPointsTable.php
...
public function initialize(array $config)
{
parent::initialize($config);
...
$this->belongsTo('DeviceTypes', [
'foreignKey' => 'device_type_id',
'joinType' => 'INNER'
]);
}
What i'm lookong for, is how to create a 'SpecialMeasuringPoints' association in the Structure table.
Somewhat like:
// MeasuringPointsTable.php
...
$this->hasMany('SpecialMeasuringPoints',[
'className' => 'MeasuringPoints',
'foreignKey' => 'structure_id',
'conditions' => ['MeasuringPoints.DeviceTypes.id'=>1]
]);
As you may see, I want only those measuring points, whose associated device type has the id 1.
However, the previous association condition is not valid; and i have no clue how to correctly implement this.
Any help is appreciated.
Correct, that condition is invalid, for a number of reasons. First of all paths aren't supported at all, and even if they were, you already are in MeasuringPoints, respectively SpecialMeasuringPoints, so there would be no need to indicate that again.
While it would be possible to pass a condition like:
'DeviceTypes.id' => 1
That would require to alawys contain DeviceTypes when retrieving SpecialMeasuringPoints.
I would suggest to use a finder, that way you can easily include DeviceTypes and match against your required conditions. Something like:
$this->hasMany('SpecialMeasuringPoints',[
'className' => 'MeasuringPoints',
'foreignKey' => 'structure_id',
'finder' => 'specialMeasuringPoints'
]);
In your MeasuringPoints class define the appropriate finder, for example using matching(), and you should be good:
public function findSpecialMeasuringPoints(\Cake\ORM\Query $query) {
return $query
->matching('DeviceTypes', function (\Cake\ORM\Query $query) {
return $query
->where([
'DeviceTypes.id' => 1
]);
});
}
Similar could be done via the conditions option when passing a callback, which however is less DRY:
$this->hasMany('SpecialMeasuringPoints',[
'className' => 'MeasuringPoints',
'foreignKey' => 'structure_id',
'conditions' => function (
\Cake\Database\Expression\QueryExpression $exp,
\Cake\ORM\Query $query
) {
$query
->matching('DeviceTypes', function (\Cake\ORM\Query $query) {
return $query
->where([
'DeviceTypes.id' => 1
]);
return $exp;
}
]);
It should be noted that in both cases you need to be aware that such constructs are not compatible with cascading/dependent deletes, so do not try to unlink/delete via such associations!
See also
Cookbook > Database Access & ORM > Retrieving Data & Results Sets > Custom Finder Methods
Cookbook > Database Access & ORM > Retrieving Data & Results Sets > Filtering by Associated Data

CakePhp 3 Save data in belongsTo

So, i have problem with saving data to DB
Model Table is
...
$this->belongsTo('Users', [
'foreignKey' => 'user_id',
'joinType' => 'INNER'
]);
...
I want to store new Address in row and data structure is
object(App\Model\Entity\Address) {
'type' => (int) 1,
'Users' => [
'user_id' => '9'
],
'short_name' => 'Test',
'long_name' => 'test',
'additional_name' => 'test',
I have tried in controller
if ($this->request->is('post')) {
$address = $this->Addresses->patchEntity($address, $this->request->data, ['associated' => ['Users.user_id']]);
if ($this->Addresses->save($address)) {
$this->Flash->success(__('The address has been saved.'));
return $this->redirect(['action' => 'index']);
} else {
$this->Flash->error(__('The address could not be saved. Please, try again.'));
}
And in my template view this is input field
echo $this->Form->input('Users.user_id', ['type' => 'select', 'options' => $groups, 'class' => 'form-control']);
echo $this->Form->input('short_name', ['class' => 'form-control']);
echo $this->Form->input('long_name', ['class' => 'form-control']);
So, what can I do? How to reorganize data stucture?
Thank you!
You are using the CakePHP <= 2.x styled naming conventions for your inputs, that's not how it works with CakePHP 3.x anymore.
Please refer to the docs on how to create inputs for associated data:
Cookbook > Views > Helpers > Form > Creating Inputs for Associated Data
You have to use the appropriate property name of the association, just as it appears in the entity when reading data. For belongsTo associations, this is by default the singular, underscored variant of the association alias, so in your case user, ie
echo $this->Form->input('user.user_id', [
'type' => 'select',
'options' => $groups,
'class' => 'form-control'
]);
See also
Cookbook > Database Access & ORM > Associations > BelongsTo Associations
Cookbook > Database Access & ORM > Saving Data > Saving BelongsTo Associations
Cookbook > Views > Helpers > Form > Field Naming Conventions

Using limit() on contained model

The Code
Say I have two models, named Product and Image, which are linked by Product hasMany Image and Image belongsTo Product.
Now, say I want to fetch all products with the first image each. I would use this code:
$this->Products->find('all')
->contain([
'Images' => function($q) {
return $q
->order('created ASC')
->limit(1);
}
]);
Looks about right, right? Except now only one of the products contains an image, although actually each product contains at least one image (if queried without the limit).
The resulting Queries
The problem seems to be with the limit, since this produces the following two queries (for example):
SELECT
Products.id AS `Products__id`,
FROM
products Products
and
SELECT
Images.id AS `Images__id`,
Images.product_id AS `Images__product_id`,
Images.created AS `Images__created`
FROM
images Images
WHERE
Images.product_id in (1,2,3,4,5)
ORDER BY
created ASC
LIMIT 1
Looking at the second query, it is quite obvious how this will always result in only one image.
The Problem
However, I would have expected the Cake ORM to limit the images to 1 per product when I called limit(1).
My question: Is this an error in how I use the ORM? If so, how should I limit the number of images to one per image?
The cleanest way you can do this is by creating another association:
$this->hasOne('FirstImage', [
'className' => 'Images',
'foreignKey' => 'image_id',
'strategy' => 'select',
'sort' => ['FirstImage.created' => 'DESC'],
'conditions' => function ($e, $query) {
$query->limit(1);
return [];
}
])
Check it ,this one is my code
$this->Orders->hasOne('Collections', [
'className' => 'Collections',
'foreignKey' => 'order_id',
'strategy' => 'select',
'conditions' => function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) {
$query->order(['Collections.id' => 'ASC']);
return [];
}
]);
$Lists = $this->Orders->find('all')->where($condition)->contain(['Collections'])->order(['Orders.due_date DESC']);
$this->set(compact('Lists'));
If the reason you are limiting it to one image is that you want to have a defaulted image. You might consider adding a default field in the image table and doing a Alias like this:
var $hasOne = array(
'CoverImage' => array(
'className' => 'Image',
'conditions' => array('CoverImage.default' => true),
),
It looks like you are using Cake v3 so you can just add the equivalent association as the above is a 2.x sample.
In the Model definition we would do the following:
public $hasMany = [
'ModelName' => [
'limit' => 1,
'order' => 'ModelName.field DESC'
],
];
as described in the docs: https://book.cakephp.org/2/en/models/associations-linking-models-together.html#hasmany

Self "HABTM" or "HasMany Through" concept confusion

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.

Resources