belongsToMany: allow and reuse an associated entry with existing UNIQUE title - cakephp

What I have
My belongsToMany association is similar to the one from CakePHP Cookbook. However, I have set the UNIQUE constraint on tag titles.
(Another difference, which may be irrelevant, is I have added a site_id field next to every tag in the Tags table, and another composite UNIQUE constraint is set on both tag and site_id.)
What doesn't work
Submitting a duplicate tag title results in an error.
When I debug my new Article entity before saving it, I can see that the duplicate tag titles are rejected after a validation attempt.
'tags' => [
// This submitted tag title already exists in Tags
(int) 0 => object(App\Model\Entity\Tag) id:1 {
'site_id' => (int) 2
'[new]' => true
'[accessible]' => [
'site_id' => true,
'title' => true,
'created' => true,
'modified' => true,
'site' => true,
'articles' => true,
]
'[dirty]' => [
'site_id' => true,
]
'[original]' => [
]
'[virtual]' => [
]
'[hasErrors]' => true
'[errors]' => [
'title' => [
'unique' => 'The provided value is invalid', // ← error
],
]
'[invalid]' => [
'title' => 'test',
]
'[repository]' => 'Tags'
},
// …
// This submitted tag title does *not* already exist in Tags
(int) 3 => object(App\Model\Entity\Tag) id:4 {
'title' => 'tag'
'site_id' => (int) 2
'[new]' => true
'[accessible]' => [
'site_id' => true,
'title' => true,
'created' => true,
'modified' => true,
'site' => true,
'articles' => true,
]
'[dirty]' => [
'title' => true, // ← no error
'site_id' => true,
]
'[original]' => [
]
'[virtual]' => [
]
'[hasErrors]' => false
'[errors]' => [
]
'[invalid]' => [
]
'[repository]' => 'Tags'
},
]
How I expected it to work?
The behaviour I'm looking for is if a tag already exists, then take its ID and just link the submitted article entry to that existing ID. So ON DUPLICATE KEY clause, in a way.
Is there a flag that I'm missing that would tell/allow the ORM to do this, or should I maybe start trying some ->epilog() tricks?

There is no such functionality for the ORM saving process, no, and you cannot make use of epilog() with the default ORM save() process, you'd have to actually create the insert query manually then, however you cannot really use entities then, and it wouldn't solve the validation problem, and you'd have to more or less manually apply validation and application rules (you don't want to blindly insert data into insert queries, even tough Query::values() binds data).
I'd probably suggest to check if a solution that modifies the data before marshalling would be a good fit, that would integrate transparently into process. You could use your unique index columns to look up existing rows, and inject their primary key values into the request data, then the patching/marshalling process will be able to properly look up the existing records and update them accordingly.
Depending on the specific use case this could be more work than manually constructing insert queries, but it will IMHO integrate nicer. In your specific case it's probably easier, as using manual insert queries would require you to insert data for all the different tables separately, as you cannot make use of the ORM's association saving functionality with manually constructed insert queries.
To finish things off, here's some untested quick & dirty example code to illustrate the concept:
// in ArticlesTable
public function beforeMarshal(
\Cake\Event\EventInterface $event,
\ArrayAccess $data,
\ArrayObject $options
): void {
// extract lookup keys from request data
$keys = collection($data['tags'])
->extract(function ($row) {
return [
$row['tag'],
$row['site_id'],
];
})
->toArray();
// query possibly existing rows based on the extracted lookup keys
$query = $this->Tags
->find()
->select(['id', 'tag', 'site_id'])
->where(
new \Cake\Database\Expression\TupleComparison(
['tag', 'site_id'],
$keys,
['string', 'integer'],
'IN'
)
)
->disableHydration();
// create a map of lookup keys and primary keys from the queried rows
$map = $query
->all()
->combine(
function ($row) {
return $row['tag'] . ';' . $row['site_id'];
},
'id'
)
->toArray();
// inject primary keys based on whether lookup keys exist in the map
$data['tags'] = collection($data['tags'])
->map(function ($row) use ($map) {
$key = $row['tag'] . ';' . $row['site_id'];
if (isset($map[$key])) {
$row['id'] = $map[$key];
}
return $row;
})
->toArray();
}
With the primary keys of existing records injected, marshalling, validation, rules and saving should be able to properly distinguish what's to update and what's to insert, ie you should be able to continue using the default ORM saving process just like you're used to.
See also
Cookbook > Database Access & ORM > Saving Data > Modifying Request Data Before Building Entities
Cookbook > Database Access & ORM > Query Builder > Inserting Data
Cookbook > Database Access & ORM > Table Objects > Lifecycle Callbacks > beforeMarshal

Related

CakePHP 3.x not giving validation error when 'return false' supplied

This question somewhat follows on from a previous question I asked about implementing validation when performing a search in a Cake 3.x application: CakePHP 3.x search form with 11 different models
I've been reading the documentation on Using Custom Validation Rules.
I have added the following to my Model/Table/EcsTable.php:
public function validationSearch($validator)
{
$extra = 'Some additional value needed inside the closure';
$validator->add('value', 'custom', [
'rule' => function ($value, $context) use ($extra) {
return false;
},
'message' => 'The title is not valid'
]);
return $validator;
}
This is a copy of what is given in the docs except I have added return false because I'm trying to test the validation method producing an error.
In my Controller I have the following:
$ecs_entity = TableRegistry::get('Ecs')->newEntity(
$this->request->getData(),
[
'validate' => 'search', // tells cake to use validateSearch
]
);
I cannot get this to generate a validation error. If I submit my form (so that the request data is submitted) and then do debug($ecs_entity); the errors property is an empty array:
object(Cake\ORM\Entity) {
'ecs' => [
'value' => '124'
],
// ...
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[invalid]' => [],
'[repository]' => 'Ecs'
}
Why is this? I am planning on writing logic inside my closure inside validationSearch which validates the data passed. However, I can't even get it to produce an error so haven't gone this far with it. Am I implementing this in the wrong way?
As per the original question I'm trying to do things in the proper "Cake way" of writing validation. I'm getting to the point where I'm seriously considering abandoning it and just sticking everything in the Controller, because it's so tedious to pass the data around - and involves more lines of code when I could just validate it right there in the controller.
The data you are passing to the newEntity call is not in valid format.
It looks like you are passing something like
[
'ecs' => [
'value' => 123
]
]
When it should be:
[ 'value' => 123 ]
The debug output for a valid Ecs entity should look like this:
object(Cake\ORM\Entity) {
'value' => '123',
'[new]' => true,
'[accessible]' => [
'*' => true
],
'[dirty]' => [
'value' => true
],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[invalid]' => [],
'[repository]' => 'Esc'
}
As you can see value is a direct property of the object.
Without seeing your Form I can guess you have created it like:
$this->Form->control( 'ecs.value')
instead of
$this->Form->control( 'value' );

CakePHP - Add custom values to User Object

I have added 4 more columns to my CakePHP Users table and I am trying to figure out how I can include these columns in the $this->Auth->user() object.
I've added the column information to my Users Model and Entity but still no joy. Currently my user object looks like this;
[
'id' => (int) 1,
'username' => 'admin',
'name' => 'Web Admin',
'email' => 'webteam#',
'role' => 'admin',
'created' => object(Cake\I18n\Time) {
'time' => '2016-02-09T16:04:46+00:00',
'timezone' => 'UTC',
'fixedNowTime' => false
},
'modified' => object(Cake\I18n\Time) {
'time' => '2016-02-12T08:53:16+00:00',
'timezone' => 'UTC',
'fixedNowTime' => false
}
]
Where is this object created and is there a way I can add my custom values to it, without editing core CakePHP files?
Thanks for your help .
By default the built-in authenticators will fetch all fields in the tables schema.
You most probably just forgot to clear your cache (tmp/cache/models), which you should do whenever you make changes to your schemas.
In case one would want to specify what fields are being fetched, a custom finder would be needed.
See Cookbook > Controllers > Components > Authentication > Customizing Find Query
$this->loadComponent('Auth', [
'authenticate' => [
'Form' => [
'finder' => 'auth'
]
],
]);
In your UsersTable class
public function findAuth(\Cake\ORM\Query $query, array $options)
{
return $query
->select(['id', 'username', 'password', 'column_x', 'column_y']);
}
It should be noted that the fields required for authentication must always be included, ie like username and password!

Cakephp 3, Updating associated belongsto Data

So I have 2 models: Plugins and Imports.
The association is Plugins hasOne Imports:
$this->hasOne('Imports', [
'foreignKey' => 'plugin_id'
]);
and Imports belongsTo Plugins:
$this->belongsTo('Plugins', [
'foreignKey' => 'plugin_id',
'joinType' => 'INNER'
]);
My plugins-table dump looks like this:
CREATE TABLE `plugins` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`type` varchar(50) NOT NULL,
`pluginkey` varchar(100) NOT NULL,
`pluginname` varchar(255) DEFAULT NULL,
`description` text,
`integrationinstructions` text,
`date_available` datetime DEFAULT NULL,
`date_last_changed` datetime DEFAULT NULL,
`author` varchar(255) DEFAULT NULL,
`version` varchar(10) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
and my imports table is:
CREATE TABLE imports (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
plugintype VARCHAR(50) NOT NULL,
importedpluginkey VARCHAR(255) NOT NULL,
imported DATETIME NULL DEFAULT NULL,
plugin_id INT UNSIGNED NOT NULL,
FOREIGN KEY plugin_key (plugin_id) REFERENCES plugins(id)
);
Now i baked both controllers and everything works fine. I can save an import(will later be used to import xml files) and it is linked to a plugin.
But now I want to let the Plugindata update through data I add in an import.
For example let's say i have a Plugin called "sale" with the type Wordpress and the key "wp_sale". Now i want to update this plugin by adding an import. For example changing it's description or name.
If i understand this right, I would only need to pass the data I want to change in the plugins alongside the id.
But when I check the patchEntity that is going to be safed the Plugin Entity is always '[new]'=> true, and the id I passed is gone.
This is what my controller looks like:
public function add()
{
$import = $this->Imports->newEntity();
if ($this->request->is('post')) {
$import = $this->Imports->patchEntity($import, $this->request->data, ['associated' => ['Plugins']]);
if ($this->Imports->save($import)) {
$this->Flash->success(__('The import has been saved.'));
return $this->redirect(['action' => 'index']);
} else {
$this->Flash->error(__('The import could not be saved. Please, try again.'));
}
}
$plugins = $this->Imports->Plugins->find('list', ['limit' => 200]);
$this->set(compact('import', 'plugins'));
$this->set('_serialize', ['import']);
}
And this is what my form looks like:
echo $this->Form->input('plugintype');
echo $this->Form->input('importedpluginkey');
echo $this->Form->input('imported');
echo $this->Form->input('plugin_id', ['options' => $plugins]);
echo $this->Form->input('plugin.pluginkey');
echo $this->Form->input('plugin.type');
echo $this->Form->input('plugin.id', ['value' => 1]);
I gave the plugin.id the value 1 just for the sake of testing, because I wanted to pass an id for the Plugin.
My postdata looks like this:
[
'plugintype' => 'wordpress',
'importedpluginkey' => 'wp_sale',
'imported' => [
'year' => '2015',
'month' => '11',
'day' => '24',
'hour' => '10',
'minute' => '38'
],
'plugin_id' => '1',
'plugin' => [
'pluginkey' => 'wp_sale',
'type' => 'wordpress',
'id' => '1'
]
]
And the patched Entity looks like this:
object(App\Model\Entity\Import) {
'plugintype' => 'wordpress',
'importedpluginkey' => 'wp_sale',
'imported' => object(Cake\I18n\Time) {
'time' => '2015-11-24T11:04:00+0000',
'timezone' => 'UTC',
'fixedNowTime' => false
},
'plugin_id' => (int) 1,
'plugin' => object(App\Model\Entity\Plugin) {
'pluginkey' => 'wp_sale',
'type' => 'wordpress',
'[new]' => true,
'[accessible]' => [
'*' => true
],
'[dirty]' => [
'pluginkey' => true,
'type' => true
],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[repository]' => 'Plugins'
},
'[new]' => true,
'[accessible]' => [
'*' => true
],
'[dirty]' => [
'plugintype' => true,
'importedpluginkey' => true,
'imported' => true,
'plugin_id' => true,
'plugin' => true
],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[repository]' => 'Imports'
}
Many Questions on this site are about association saving. But what i want is update associated data when adding new data in the import model.
The reasoning behind it is, that I want to be able to update my Plugins without editing everyone of them, and just use the xml import.
If the Question still is unclear feel free to ask and I will explain myself further. Thanks in advance!
Edit: To be clear, I basically need to change the data of a plugin using the import controller.
I now got the plugin entity in another function now, and i can save that with the import entity. However, it doesnt change anything in the plugin, so basically that does nothing.
The example in the linked docs seem to assume that the entities are set up in a way that they allow the primary key to be mass assigned, see
Cookbook > Database Access & ORM > Entities > Mass Assignment
The primary key needs to be accessible
By default, baked entities do not allow the primary key to be mass assigned, so you have to take care of that, for example by overriding the fields accessible state via the patchEntitity() options, like
$import = $this->Imports->patchEntity(
$import,
$this->request->data,
[
'associated' => [
'Plugins' => [
'accessibleFields' => ['id' => true]
]
]
]
);
See also Cookbook > Database Access & ORM > Saving Data > Changing Accessible Fields
Note that while the entity will still be flagged as "new", the saving process will (as long as the checkExisting option is not being set to false) test whether a record with the given primary key exists, and in case necessary mark the entity as "non-new" before proceeding, so that you'll end up with an update instead of an insert.
See also Cookbook > Database Access & ORM > Saving Data > Saving Entities
Just add dependent here, I think it will work then.
$this->hasOne('Imports', [
'foreignKey' => 'plugin_id',
'dependent' => true,
]);
Please see Cascading Deletes

Saving records with a given uuid

I want to save a bunch of static records in my database with a given uuid, this is for testing purposes, so that on every system the application starts with the exact same dataset.
When inserting with SQL this is no problem but I wanted to use the CakePHP way ( I use a migrations file for this, but that does not matter).
The problem is that I give cake a data array like this and save it:
$data = [
['id' => '5cedf79a-e4b9-f235-3d4d-9fbeef41c7e8', 'name' => 'test'],
['id' => 'c2bf879c-072c-51a4-83d8-edbf2d97e07e', 'name' => 'test2']
];
$table = TableRegistry::get('My_Model');
$entities = $table->newEntities($data, [
'accessibleFields' => ['*' => true],
'validate' => false
]);
array_map([$table, 'save'], $entities );
Everything saves, but all my items have been given a different uuid, If I debug a record after saving it shows the original uuid in the entity
'new' => false,
'accessible' => [
'*' => true
],
'properties' => [
'id' => '6b4524a8-4698-4297-84e5-5160f42f663b',
'name' => 'test',
],
'dirty' => [],
'original' => [
'id' => '5cedf79a-e4b9-f235-3d4d-9fbeef41c7e8'
],
So why does cake generate a new uuid for me? and how do I prevent it
This doesn't work because primary keys are unconditionally being generated before the insert operation, see
https://github.com/cakephp/cakephp/blob/3.0.0/src/ORM/Table.php#L1486-L1490
// ...
$id = (array)$this->_newId($primary) + $keys;
$primary = array_combine($primary, $id);
$filteredKeys = array_filter($primary, 'strlen');
$data = $filteredKeys + $data;
// ...
$statement = $this->query()->insert(array_keys($data))
->values($data)
->execute();
// ...
Currently the UUID type is the only type that implements generating IDs, so providing custom IDs works with other types.
You can workaround this by for example overriding the _newId() method in your table so that it returns null, which effectively results in the existing primary key not being overwritten.
protected function _newId($primary)
{
// maybe add some conditional logic here
// in case you don't want to be required
// to always manually provide a primary
// key for your insert operations
return null;
}

CakePHP 3.0 association being marked as dirty when it's actually clean

I'm trying to Update/Save TradingPartner Entities (Customers and Suppliers) that can have many Addresses associated (TradingPartnerAddresses).
I have an array of changed TradingPartners and i'm using Cake's patchEntities() function to merge the existing records with the modified ones.
The issue is that Cake is incorrectly marking the associated addresses as dirty and when looping through the Entities to save them it's trying to re-insert the existing addresses - causing duplicate addresses.
Here's the code:
Trading Partners Controller
//Load the trading partners model
$tradingPartners = TableRegistry::get('TradingPartners');
//find all existing trading partners and their associated addresses
$currentTradingPartners = $tradingPartners->find('all')
->contain(['TradingPartnerAddresses']);
//Patch trading partner changes
//Cake automagically handles 1 level of associations
$patchedTpEntities = $tradingPartners->patchEntities($currentTradingPartners, $partners);
//loop through and save all dirty fields
foreach ($patchedTpEntities as $tpEntity) {
if ($tradingPartners->save($tpEntity)) {
} else {
//TODO
}
}
Trading Partners Table
public function initialize(array $config) {
$this->primaryKey('partner_code');
$this->hasMany('TradingPartnerAddresses', [
'foreignKey' => 'partner_code'
]);
}
Trading Partner Addresses Table
public function initialize(array $config) {
$this->entityClass('App\Model\Entity\TradingPartnerAddress');
$this->belongsTo('TradingPartners', [
'foreignKey' => 'partner_code'
]);
}
debug($patchedEtentites);
(int) 0 => object(App\Model\Entity\TradingPartner) {
.....
'trading_partner_addresses' => [],
'[new]' => false,
'[accessible]' => [
'*' => true
],
'[dirty]' => [
'trading_partner_addresses' => true
],
'[original]' => [],
'[virtual]' => [],
'[errors]' => [],
'[repository]' => 'TradingPartners'
Any Ideas why the trading_partner_addresses is being flagged as dirty when it's actually clean?
When u patch an entity with some data it's automatically flagged as dirty
$tradingPartners->patchEntities($currentTradingPartners, $partners);
It means you are updating $currentTradingPartners with $partners data so $currentTradingPartners is modified and marked to dirty.
If u want the original data u can use getOriginal method on your entiy's properties, u can also remove the dirty flag with the clean method.
If u got problem to patch entities with associations u must pass an array of options in patchEntity:
$article = $articles->get(1);
$articles->patchEntity($article, $this->request->data(), [
'associated' => ['Tags', 'Comments.Users']
]);

Resources