How to join two tables in SilverStripe - 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.

Related

Silverstripe 4 Upgrade - Unversioned DataObjects in ModelAdmin lose their image objects

Been pulling my hair out over this for a day and exhausted my google foo. I have inherited a Silverstripe 3.4 site that we have upgraded to 4.4. But something odd has been going on with certain images after running MigrateFilesTask.
I think this is something to do with a file being attached to an unversioned objects that are accessed via ModelAdmin. But I have not been able to find a definitive solution.
Code for this object below. Problems experienced are under it.
<?php
use SilverStripe\Assets\Image;
use gorriecoe\Link\Models\Link;
use SilverStripe\Security\Member;
use SilverStripe\Control\Controller;
use SilverStripe\View\Parsers\URLSegmentFilter;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\FieldGroup;
use gorriecoe\LinkField\LinkField;
use SilverStripe\TagField\TagField;
use SilverStripe\ORM\DataObject;
use SilverStripe\SelectUpload\SelectUploadField;
class Person extends DataObject
{
private static $db = array(
'FirstName' => 'Varchar(128)',
'LastName' => 'Varchar(128)',
'Role' => 'Varchar(128)',
'DirectDialNumber' => 'Varchar(128)',
'Email' => 'Varchar(128)',
'CellphoneNumber' => 'Varchar(30)',
'DirectDial' => 'Varchar(30)',
'UrlSegment' => 'Varchar(255)',
'Blurb' => 'HTMLText',
'SortOrder' => 'Int'
);
private static $has_one = array(
'Image' => Image::class,
'Office' => 'Office',
'LinkedIn' => Link::class,
'Member' => Member::class
);
private static $many_many = array(
'Interests' => 'Section'
);
private static $belongs_many_many = array(
'ElementCollection' => 'ElementCollection'
);
static $sort_fields = array(
'FirstName' => 'First name',
'LastName' => 'Last name',
'Role' => 'Role'
);
private static $summary_fields = array(
'Name' => 'Name',
'Role' => 'Role',
'Office.Name' => 'Office'
);
private static $searchable_fields = array(
'FirstName',
'LastName',
'Role'
);
// For use with the ElementCollection
public static $templates = array(
'ElementPeople' => 'Default',
'ElementPeopleAlternative' => 'Alternative'
);
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName( ['SortOrder', 'ElementCollection', 'FirstName', 'LastName', 'Interests'] );
$firstname = TextField::create('FirstName', 'First name');
$lastname = TextField::create('LastName', 'Last name');
$fields->addFieldsToTab('Root.Main', FieldGroup::create($firstname, $lastname)->setTitle('Name')->setName('Name'), 'Role');
$image = UploadField::create('Image', 'Photo');
$image->setFolderName('Uploads/People');
$image->setCanSelectFolder(false);
$fields->addFieldToTab('Root.Main', $image);
$linkedin = LinkField::create('LinkedIn', 'LinkedIn', $this);
$fields->addFieldToTab('Root.Main', $linkedin);
$interests = TagField::create(
'Interests',
'Interests Tags',
Section::get(),
$this->Interests()
)->setShouldLazyLoad(true)
->setCanCreate(false);
$fields->addFieldToTab('Root.Main', $interests);
return $fields;
}
public function onBeforeWrite()
{
$count = 1;
$this->UrlSegment = $this->generateURLSegment();
while (!$this->validURLSegment()) {
$this->UrlSegment = preg_replace('/-[0-9]+$/', null, $this->UrlSegment) . '-' . $count;
$count++;
}
parent::onBeforeWrite();
}
}
Problem #1 is after running MigrateFileTask, ALL existing images attached to instances of this class get moved from /assets/Uploads/People to /assets/.protected/Uploads/People. The confusing part here is that there is one other class called Company that is structurally near identical, yet images for that remain in /assets/Uploads/Companies as expected.
Problem #2 is if I create a new Person object and attach an image, that image is in Draft, sitting in /assets/.protected/Uploads/People with no method of actually publishing it. Meanwhile, if I do the same with a Company object, the image is still in Draft, but I can see it in the CMS.
Can someone offer some guidance on the above? At this point I'd be happy to just be able for images to be published when the DO is and I'll manually go through every single Person record and hit save myself just to get this upgrade over the line.
You should be able to fix this issue by adding the image to your DataObejct's owns property. Basically add this:
private static $owns = [
'Image'
];
Basically owns tells a DataObject which objects to publish when it is saved:
More info in the docs: https://docs.silverstripe.org/en/4/developer_guides/model/versioning/#defining-ownership-between-related-versioned-dataobjects
The cause of issue #1 was found. Leaving this here in case it helps someone in future:
The database table File has a row for every File and Folder in the system. This table has a column called "CanViewType". It exists in both Silverstripe 3 and 4.
For the particular Folder that was causing trouble during the Migration process, I found it was the only one with that column set to "OnlyTheseUsers". The rest were set to "Inherit". This was the state of the table before the upgrade.
I'm unsure how or by what mechanism that row is ever changed, but the solution to problem #1 was to manually change that field to "Inherit" before running FileMigrationTask.
Issue #2 persists, but it looks like there are two very different issues here.
OK. So sorted problem #2 finally (see other answer for solution to #1), but it's put a massive dent in our confidence in Silverstripe and sparked a meeting here.
Code for future readers:
In your unversioned DataObject, add this. In this case, my file object is called "Image". If you had more than one file to publish on save, you would have to add one of these IF blocks for each.
public function onAfterWrite()
{
if ($this->Image()->exists() && !$this->Image()->isPublished()) {
$this->Image()->doPublish();
}
parent::onAfterWrite();
}
SIDENOTE:
This Object/File relationship really is a strange design choice. Are there actually any situations where you would want to attach a file or image to a data object and NOT publish that file at the same time as you save/publish the object/page? Developers even need to explicitly define that on Versioned objects using $owns - which I'm happy to bet that most developers have to add more times than NOT. Which should really tell a us something is around wrong way.
Adding an image to a CMS system shouldn't be hard. It should take reading basic docs at the most. Not Googling, deep API doc dives (which don't answer much) or posting on StackOverlfow (where no one really knows the answer) over three days. It's an image. A core function of the product.
I've been working with SS since v2.4 and seen all the hard lessons learned to get to v4. But this appears to be a textbook case of the simple being over-engineered.

How do you Filter CSV exports on silverstripe CMS?

I am trying to export a CSV of my data which is currently displayed in a section of my Silverstripe CMS as filtered by a particular date range . It works fine at the moment when exporting the entire contents but I would like to be able to filter the results that are exported so that it returns all results within a particular date range.
My Database has a column thats records the date created - in the format 'D-M-Y; H-M-S' which I think could be used to do the filtering but I cant figure out how to set up the search filter. I understand that if you use the searchable fields and then export, you only export the filtered search results so would assume thats the best way of doing it but can't figure out how to implement it.
Any suggestions would be greatly appreciated.
-- disclaimer - I would have liked to put this on the silverstripe forum but I am completely unable to sign up for some reason - I never receive the email confirmations. ---
<?php
namespace AffiliateProgram;
use SilverStripe\Forms\GridField\GridField;
use UndefinedOffset\SortableGridField\Forms\GridFieldSortableRows;
use SilverStripe\Security\Permission;
use SilverStripe\ORM\DataObject;
class MemberBonus extends DataObject
{
private static $db = [
'Amount' => 'Currency',
'Confirmed' => 'Boolean',
'Level' => 'Int',
'Percentage' => 'Int'
];
private static $has_one = [
'Member' => 'AffiliateProgram\Member',
'MemberPayment' => 'AffiliateProgram\MemberPayment',
'PaymentType' => 'AffiliateProgram\PaymentType',
'ProgramType' => 'AffiliateProgram\ProgramType'
];
private static $summary_fields = [
'Amount' => 'Amount (USD)',
'Member.Email' => 'Email',
'Level',
'MemberPayment.PaymentType.Symbol' => 'Recieved As',
'Percentage' => 'Percentage Bonus Applied',
'ProgramType.Name' => 'Program Type',
'MemberPayment.Created' => 'Payment Date',
'Confirmed' => 'Confirmed?',
'MemberPayment.ID' => 'Payment ID'
];
}
There is also a DateCreated column on the table.
You can add custom search fields to a ModelAdmin via getSearchContext(), and customise the query based on them with getList(). See this section of the SilverStripe documentation.
Here's an example of excluding results that have a CreatedAt value below the date provided in a search field (assuming your ModelAdmin only manages MemberBonus):
<?php
use SilverStripe\Admin\ModelAdmin;
use SilverStripe\Forms\DatetimeField;
class MemberBonusAdmin extends ModelAdmin
{
...
public function getSearchContext()
{
$context = parent::getSearchContext();
$context->getFields()->push(new DatetimeField('q[CreatedAfter]', 'Created After'));
return $context;
}
public function getList()
{
$list = parent::getList();
$params = $this->getRequest()->requestVar('q');
if (!empty($params['CreatedAfter'])) {
$list = $list->exclude('CreatedAt:LessThan', $params['CreatedAfter']);
}
return $list;
}
}
To get a range working, you'd just need to add a CreatedBefore field and filter.

Sortable Sonata Type Model in Admin

Did someone tried the tutorial about Sortable Sonata Type Model in Admin.
I've followed it step by step without missing anything (I'm pretty sure) but can't get a good result at the end.
Basically what I'm trying to do is : I have 3 entities, Article, Tag and ArticleTag (eq to User, Expectation and UserHasExpectation in the tutorial)
Everything seems good until the UserHasExpectationAdmin:
protected function configureFormFields(FormMapper $formMapper){
// ...
$formMapper
->add('userHasExpectations', 'sonata_type_model', array(
'label' => 'User\'s expectations',
'query' => $this->modelManager->createQuery('UserBundle\Entity\Expectation'),
'required' => false,
'multiple' => true,
'by_reference' => false,
'sortable' => true,
))
;
$formMapper->get('userHasExpectations')->addModelTransformer(new ExpectationDataTransformer($this->getSubject(), $this->modelManager));}
I think an attribute 'class' => 'UserBundle\Entity\Expectation' should be added to 'userHasExpectations' field else Symfony says that it's an invalid value.
Then the other problem is in the dataTransformer:
It launch me the error:
Attempted to call an undefined method named "create" of class "Main\CoreBundle\Form\DataTransformer\TagDataTransformer"
I think a use statement should be added but I don't know which one. More over, suppose I have the right use statement I don't realize what the writer is aiming to do, if it's creating UserHasExpectation records why don't he add a userHasExpectations->setUser($this->User) ???
Also I want to add after "vardumping" $this->Subject before :
$formMapper->get('userHasExpectations')->addModelTransformer(new ExpectationDataTransformer($this->getSubject(), $this->modelManager));
It seems to have a proper Entity Object with all fields on NULL values...
FINALLY SOLVED IT!
So, the code of the tutorial contains many...mistakes
In spite of trying to create 'userHasExpectation' in the DataTransformer we just return the object userHasExpectation in the reverse DataTransformer then we create our records in the postPersist and postUpdate of our Admin Class that way :
/**
* {#inheritdoc}
*/
public function postUpdate($object)
{
$position = 0;
$uniqId = $this->getUniqId();
$request = $this->getRequest()->get($uniqId);
$qb = $this->modelManager->createQuery('MainCoreBundle:ArticleTag', 'at');
$TagsToRemove = $qb->where('at.article = :article')
->setParameter('article', $object)
->getQuery()
->getResult();
foreach ($TagsToRemove as $Tag) {
$this->modelManager->delete($Tag);
}
foreach($request["article_tags"] as $tag)
{
$Tag = $this->modelManager->find('MainCoreBundle:Tag', $tag);
$article_tags = new ArticleTag;
$article_tags->setTag($Tag);
$article_tags->setArticle($object);
$article_tags->setPosition($position++);
$this->modelManager->create($article_tags);
}
}
/**
* {#inheritdoc}
*/
public function postPersist($object)
{
$position = 0;
$uniqId = $this->getUniqId();
$request = $this->getRequest()->get($uniqId);
foreach($request["article_tags"] as $tag)
{
$Tag = $this->modelManager->find('MainCoreBundle:Tag', $tag);
$article_tags = new ArticleTag;
$article_tags->setTag($Tag);
$article_tags->setArticle($object);
$article_tags->setPosition($position++);
$this->modelManager->create($article_tags);
}
}
Hope this will help Somebody who has the same trouble.
#Sonata-admin-team : I hope you will read this and have time to update the tutorial in question.
Thanks,
Epixilog
For Sonata 3 adding the class attribute 'class'=> 'UserBundle\Entity\Expectation' resolved the problem for me.

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;
}

cakephp3: Save associated data

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
)
);
?>

Resources