cakephp3: Save associated data - associations

I want to write a easy Tagging-Plugin for cakephp3 applications. So lets say we have a model books and a model reviews. For each of this models it should be possible to attach Tags - just by adding a behavior (in a plugin): $this->addBehavior('Tag.Taggable').
I created two Tables in the Database: tags, tagged_tags.
Table tagged_tags:
id | tag_id | tagged_id |
1 | 1 | 1 |
2 | 2 | 1 |
tagged_id is the id of the tagged entity. The information which model it belongs to is in the other table.
Table Tags:
id | tag | model |
1 | book | App\Model\Table\BooksTable |
2 | nobook | App\Model\Table\ReviewsTable|
Obviously, only the first Tag belongs to a book.
class TaggableBehavior extends Behavior
{
// Some more code here
public function __construct(Table $table, array $config = [])
{
parent::__construct($table, $config);
$this->_table = $table;
$this->_table->belongsToMany('Tag.Tags', [
'joinTable' => 'tagged_tags',
'foreignKey' => 'tagged_id',
'targetForeignKey' => 'tag_id',
'conditions' => [
'Tags.model' => get_class($this->_table);
]
]);
}
}
Retrieving the data works perfectly. But saving is an issue:
Error: SQLSTATE[42S22]: Column not found: 1054 Unknown column
'Tags.model' in 'where clause'
SQL Query:
SELECT TaggedTags.id AS TaggedTags__id, TaggedTags.tagged_id AS
TaggedTags__tagged_id, TaggedTags.tag_id AS TaggedTags__tag_id
FROM tagged_tags TaggedTags WHERE (tagged_id = :c0 AND Tags.model =
:c1)
I'm not so sure why cakephp performs a SELECT-query here, and I don't really care. Why this query causes an error is clear. But where is my mistake here? It has to do with the 'conditions' => ['Tags.model' => get_class($this->_table);. Without this, I can save data (but cant say which Tag belongs to a book or not)
EDIT: Some Additional Info
Here is the complete sql statement, displayed in the debug kit http://freetexthost.com/tc3s46nugi
controller code:
public function add()
{
$book = $this->Books->newEntity();
if ($this->request->is('post')) {
$book = $this->Books->patchEntity($book, $this->request->data);
if ($this->Books->save($book)) {
$this->Flash->success(__('The book has been saved.'));
return $this->redirect(['action' => 'index']);
} else {
$this->Flash->error(__('The book could not be saved. Please, try again.'));
}
}
In the Behavior I have some logic (copy/pasted) form the Bookmarks-tutorial
public function beforeSave($event, $entity, $options)
{
if ($entity->tag_string) {
$entity->tags = $this->_buildTags($entity->tag_string);
}
}
protected function _buildTags($tagString)
{
$new = array_unique(array_map('trim', explode(',', $tagString)));
$out = [];
$query = $this->_table->Tags->find()
->where([
'Tags.tag IN' => $new,
'Tags.model' => $this->name()
]);
// Remove existing tags from the list of new tags.
foreach ($query->extract('tag') as $existing) {
$index = array_search($existing, $new);
if ($index !== false) {
unset($new[$index]);
}
}
// Add existing tags.
foreach ($query as $tag) {
$tag['count'] = $tag['count']+1;
$out[] = $tag;
}
// Add new tags.
foreach ($new as $tag) {
$out[] = $this->_table->Tags->newEntity(['tag' => $tag, 'model' => $this->name(), 'count' => 0]);
}
return $out;
}

Easy tagging plugin you will make if you create Tags model, and create a HABTM relationship with other models.
Here are simple guidelines.
add.ctp
Omit multiple select tags field, instead put tagging text field (eg. tagsinput). Add some jquery tagging plugin. When adding a new tag (keyword), it is immediately stored in the tags table via jquery post methods. If the keyword exists in the tags table, then do not store it again.
Behavior
In the beforeSave method processing value from tagging field.
If the value separated by a comma, you can use something like this (CakePHP 2):
public function beforeSave($options = array())
{
if(!empty($this->data['Article']['tagsinput'])) {
$tags = explode(',', $this->data['Article']['tagsinput']);
foreach ($tags as $tag) {
$tagKey = $this->Tag->find('first',array(
'recursive' => -1,
'fields' => array('id'),
'conditions' => array('Tag.name' => $tag)
));
$this->data['Tag']['Tag'][] = $tagKey['Tag']['id'];
}
}
return true;
}
This creates HABTM relationship and stores such values in the table articles_tags.
edit.ctp
Create a comma separated values:
<?php
$extract_tags = Hash::extract($this->data['Tag'],'{n}.name');
$tags = implode(',', $extract_tags);
echo $this->Form->input('tagsinput',
array(
'id' => 'tags',
'div'=>true,
'label'=>__('Tags'),
'class'=>'form-control input-lg',
'placeholder'=>__('Add keywords here'),
'value' => $tags
)
);
?>

Related

Translate a nested Paragraph programmatically

i have translated nodes with a paragraph field (content already exists, all entities are translatable). I've added a nested paragraph field on the first paragraph field. My goal is to programmatically add translated nested paragraphs.
node (
* first paragraph field
** nested paragraph field
But when i run my update, the paragraphs on the translated nodes are still in the original language (not translated). What am i doing wrong?
$node = $nodeStorage->load($nid);
$paragraphs = $node->get('field_content')->referencedEntities();
// Initialize new $nested items - THIS PART WORKS.
foreach($paragraphs as $paragraph) {
$nested = Paragraph::create(['type' => 'my_type',]);
$nested->set('field_text', 'Some Value.');
$nested->save();
$paragraph->get('field_paragraphs')->appendItem($nested);
$paragraph->save();
$node->save();
}
}
// Add $nested translations - THIS PART DOESN'T WORK
$languages = $node->getTranslationLanguages();
foreach ($languages as $language) {
$langcode = $language->getId();
$translation = $node->getTranslation($langcode);
$paragraphs = $translation->get('field_content')->referencedEntities();
// Set first picture values.
foreach ($paragraphs as $paragraph) {
$paragraph_translation = $paragraph->getTranslation($langcode);
$nested = $picture->get('field_paragraphs')->referencedEntities();
$nested = reset($picture_item);
$newValues = [
'field_text => 'Translated value',
];
$nested->addTranslation($langcode, $values);
$nested->save();
$picture->save();
$translation->save();
}
}
Could you please try to access the field directly instead of using the get method?
like this:
// Add $nested translations
$languages = $node->getTranslationLanguages();
foreach ($languages as $language) {
$langcode = $language->getId();
$translation = $node->getTranslation($langcode);
$paragraphs = $translation->field_content->referencedEntities();
// Set first picture values.
foreach ($paragraphs as $paragraph) {
$paragraph_translation = $paragraph->getTranslation($langcode);
$nested = $picture->field_paragraphs->referencedEntities();
$nested = reset($picture_item);
$newValues = [
'field_text' => 'Translated value',
];
$nested->addTranslation($langcode, $values);
$nested->save();
$picture->save();
$translation->save();
}
}

How to join two tables in SilverStripe

I've been searching all over the place and couldn't find a solution. I want to join two tables in SilverStripe. It's very simple:
class Module extends DataObject {
...
static $has_one = array(
'Website' => 'Website'
);
...
}
class Website extends DataObject {
...
static $has_many = array(
'Modules' => 'Module'
);
...
}
I want to join these two, and get all the attributes in one DataList. The leftJoin() function won't do anything, and it's mentioned in their website that
Passing a $join statement to will filter results further by the JOINs performed against the foreign table. It will not return the additionally joined data.
I tried to use raw query
DB::query('SELECT * FROM "Module" LEFT JOIN "Website" ON "Website"."ID" = "Module"."WebsiteID"');
but all I got was this
MySQLQuery Object ( [handle:protected] => mysqli_result Object ( [current_field] => 0 [field_count] => 19 [lengths] => [num_rows] => 5 [type] => 0 ) [currentRecord:protected] => [rowNum:protected] => -1 [queryHasBegun:protected] => )
Anyone has any idea how to do that? Thanks!
I found a way around. It's not the perfect solution for joining two tables, especially when there are a lot of attributes, but it does give me what I want for now.
$modules = Module::get();
$list = new ArrayList();
foreach($modules as $module) {
$website = Website::get()->filter(array(
'ID' => $module->WebsiteID
))->first();
$array = array("mName" => $module->Name,
"mDes" => $module->Description,
"wName" => $website->Name);
$list->push($array);
}
You indeed cannot get the joined data through the default ORM. If you, however, choose to decide to use the DB::Query(), you can easily fetch them as an array.
An example:
$items = DB::Query("
SELECT
Module.Title,
Website.URL
FROM Module
LEFT JOIN Website ON Website.ID = Module.WebsiteID
");
if($items) {
$i = 0;
foreach($items as $item) {
$moduleTitle = $item['Title'];
$websiteURL = $item['URL'];
}
}
This option is faster than the workaround you suggested. If you need an ArrayList because you want to use the data in a template, build the ArrayList yourself, or use a snippet like this.

Filter ModelAdmin by many_many relation

I'm managing the DataObject class 'trainer' with ModelAdmin. A trainer has a many_many relation to my other class 'language'.
On my 'trainer' class I'm manipulating the 'searchableFields' function to display a ListboxField in the filters area.
public function searchableFields() {
$languagesField = ListboxField::create(
'Languages',
'Sprachen',
Language::get()->map()->toArray()
)->setMultiple(true);
return array (
'Languages' => array (
'filter' => 'ExactMatchFilter',
'title' => 'Sprachen',
'field' => $languagesField
)
);
}
That works like expected and shows me the wanted ListboxField. The Problem is, after selecting 1 or 2 or whatever languages and submitting the form, I'm receiving
[Warning] trim() expects parameter 1 to be string, array given
Is it possible here to filter with an many_many relation? And if so, how? Would be great if someone could point me in the right direction.
Update:
Full Error Message: http://www.sspaste.com/paste/show/56589337eea35
Trainer Class: http://www.sspaste.com/paste/show/56589441428d0
You need to define that logic within a $searchable_fields parameter instead of the searchableFields() which actually constructs the searchable fields and logic.
PHP would be likely to throw an error if you go doing fancy form stuff within the array itself, so farm that form field off to a separate method in the same DataObject and simply call upon it.
See my example, I hope it helps.
/* Define this DataObjects searchable Fields */
private static $searchable_fields = array(
'Languages' => array (
'filter' => 'ExactMatchFilter',
'title' => 'Sprachen',
'field' => self::languagesField()
)
);
/* Return the searchable field for Languages */
public function languagesField() {
return ListboxField::create(
'Languages',
'Sprachen',
Language::get()->map()->toArray()
)->setMultiple(true);
}
Yes, it's possible. You just need to override two methods - one in Trainer data object and one in TrainerModelAdmin. First one will make a field, second one will do filtering.
Trainer Data Object:
public function scaffoldSearchFields($_params = null)
{
$fields = parent::scaffoldSearchFields($_params);
// get values from query, if set
$query = Controller::curr()->request->getVar('q');
$value = !empty($query['Languages']) && !empty($query['Languages']) ? $query['Languages'] : array();
// create a field with options and values
$lang = ListboxField::create("Languages", "Sprachen", Language::get()->map()->toArray(), $value, null, true);
// push it to field list
$fields->push($lang);
return $fields;
}
Trainer Model Admin
public function getList()
{
$list = parent::getList();
// check if managed model is right and is query set
$query = $this->request->getVar('q');
if ($this->modelClass === "Trainer" && !empty($query['Languages']) && !empty($query['Languages']))
{
// cast all values to integer, just to be sure
$ids = array();
foreach ($query['Languages'] as $lang)
{
$ids[] = (int)$lang;
}
// make a condition for query
$langs = join(",", $ids);
// run the query and take only trainer IDs
$trainers = DB::query("SELECT * FROM Trainer_Languages WHERE LanguageID IN ({$langs})")->column("TrainerID");
// filter query on those IDs and return it
return $list->filter("ID", $trainers);
}
return $list;
}

Generate url aliasing based on taxonomy term

I have a vocab category and four terms within it. what i want to do is if content is tagged with a termin in particular say "term1" to have the url generated as word1/[node:title] and for all the other tags just the standard url formatting.
If i wanted the term in the url obviously id use pattern replacement but i want another word to be used if a particular tag is used
I can't think of an easy plug-and-play way of achieving this. You may have to create your own token for the "Default path pattern" in Pathauto's URL alias settings:
/**
* Implementation of hook_token_info().
*/
function MODULE_token_info() {
$info['tokens']['node']['node-term-path'] = array(
'name' => t('Node path by term'),
'description' => t('The path to a node based on its taxonomy terms.'),
);
return $info;
}
/**
* Implementation of hook_tokens().
*/
function MODULE_tokens($type, $tokens, array $data = array(), array $options = array()) {
$replacements = array();
if ($type == 'node' && !empty($data['node'])) {
$node = $data['node'];
foreach ($tokens as $name => $original) {
switch ($name) {
case 'node-term-path':
$items = field_get_items('node', $node, 'TAXONOMY_FIELD_NAME');
foreach ($items as $item) {
$tids[] = $item['tid'];
}
if (in_array(TID_OF_TERM1, $tids)) {
// Path for nodes with term1
$replacements[$original] = 'word1/'. pathauto_cleanstring($node->title);
}
else {
// Path for other nodes
$replacements[$original] = 'content/'. pathauto_cleanstring($node->title);
}
break;
}
}
}
return $replacements;
}
Found a simple way actually to anyone who need a similar solution use the module Entity Reference.
http://drupal.org/project/entityreference
I just created a new field for the user account select entity reference then you can choose any entity within drupal to reference.
(ie so you can select a term/content/anything)

Search hook for filtering results?

I have been going through the docs and source code looking for something without luck.
Is there a Drupal 6 hook that gets called after hook_search(), but before the $results gets handed off to the template system?
I need to do a fairly custom pruning and reordering of results that get returned. I could just reimplement hook_search(), but this seems like overkill.
Thanks.
There isn't; search_view() (which displays the results) calls search_data(), which invokes hook_search() then immediately themes the results. Re-implementing hook_search() is probably the most straightforward route.
With that said, you could instead implement hook_menu_alter() and have the search page call your custom function instead of calling search_view() (and subsequently calling search_data()). Something like:
function test_menu_alter(&$items) {
$items['search']['page callback'] = 'test_search_view';
foreach (module_implements('search') as $name) {
$items['search/' . $name . '/%menu_tail']['page callback'] = 'test_search_view';
}
}
// Note: identical to search_view except for --- CHANGED ---
function test_search_view($type = 'node') {
// Search form submits with POST but redirects to GET. This way we can keep
// the search query URL clean as a whistle:
// search/type/keyword+keyword
if (!isset($_POST['form_id'])) {
if ($type == '') {
// Note: search/node can not be a default tab because it would take on the
// path of its parent (search). It would prevent remembering keywords when
// switching tabs. This is why we drupal_goto to it from the parent instead.
drupal_goto('search/node');
}
$keys = search_get_keys();
// Only perform search if there is non-whitespace search term:
$results = '';
if (trim($keys)) {
// Log the search keys:
watchdog('search', '%keys (#type).', array('%keys' => $keys, '#type' => module_invoke($type, 'search', 'name')), WATCHDOG_NOTICE, l(t('results'), 'search/'. $type .'/'. $keys));
// Collect the search results:
// --- CHANGED ---
// $results = search_data($keys, $type);
// Instead of using search_data, use our own function
$results = test_search_data($keys, $type);
// --- END CHANGED ---
if ($results) {
$results = theme('box', t('Search results'), $results);
}
else {
$results = theme('box', t('Your search yielded no results'), search_help('search#noresults', drupal_help_arg()));
}
}
// Construct the search form.
$output = drupal_get_form('search_form', NULL, $keys, $type);
$output .= $results;
return $output;
}
return drupal_get_form('search_form', NULL, empty($keys) ? '' : $keys, $type);
}
// Note: identical to search_data() except for --- CHANGED ---
function test_search_data($keys = NULL, $type = 'node') {
if (isset($keys)) {
if (module_hook($type, 'search')) {
$results = module_invoke($type, 'search', 'search', $keys);
if (isset($results) && is_array($results) && count($results)) {
// --- CHANGED ---
// This dsm() is called immediately after hook_search() but before
// the results get themed. Put your code here.
dsm($results);
// --- END CHANGED ---
if (module_hook($type, 'search_page')) {
return module_invoke($type, 'search_page', $results);
}
else {
return theme('search_results', $results, $type);
}
}
}
}
}
You can use hook_search_page() to reorder or format the search result.
Hook search_execute allows you to modify the query in the way you needed. You can even fire new queries with custom sql, for example:
function mymodule_search_execute($keys = NULL, $conditions = NULL) {
// Do some query here.
$result = my_fancy_query();
// Results in a Drupal themed way for search.
$results[] = array(
'link' => (string) $result->U,
'title' => $title,
'snippet' => $snippet,
'keys' => check_plain($keys),
'extra' => array($extra),
'date' => NULL,
);

Resources