I build a model side validation in Laravel 4 with the creating Model Event :
class User extends Eloquent {
public function isValid()
{
return Validator::make($this->toArray(), array('name' => 'required'))->passes();
}
public static function boot()
{
parent::boot();
static::creating(function($user)
{
echo "Hello";
if (!$user->isValid()) return false;
});
}
}
It works well but I have issues with PHPUnit. The two following tests are exactly the same but juste the first one pass :
class UserTest extends TestCase {
public function testSaveUserWithoutName()
{
$count = User::all()->count();
$user = new User;
$saving = $user->save();
assertFalse($saving); // pass
assertEquals($count, User::all()->count()); // pass
}
public function testSaveUserWithoutNameBis()
{
$count = User::all()->count();
$user = new User;
$saving = $user->save();
assertFalse($saving); // fail
assertEquals($count, User::all()->count()); // fail, the user is created
}
}
If I try to create a user twice in the same test, it works, but it's like if the binding event is present only in the first test of my test class. The echo "Hello"; is printed only one time, during the first test execution.
I simplify the case for my question but you can see the problem : I can't test several validation rules in different unit tests. I try almost everything since hours but I'm near to jump out the windows now ! Any idea ?
The issue is well documented in Github. See comments above that explains it further.
I've modified one of the 'solutions' in Github to automatically reset all model events during the tests. Add the following to your TestCase.php file.
app/tests/TestCase.php
public function setUp()
{
parent::setUp();
$this->resetEvents();
}
private function resetEvents()
{
// Get all models in the Model directory
$pathToModels = '/app/models'; // <- Change this to your model directory
$files = File::files($pathToModels);
// Remove the directory name and the .php from the filename
$files = str_replace($pathToModels.'/', '', $files);
$files = str_replace('.php', '', $files);
// Remove "BaseModel" as we dont want to boot that moodel
if(($key = array_search('BaseModel', $files)) !== false) {
unset($files[$key]);
}
// Reset each model event listeners.
foreach ($files as $model) {
// Flush any existing listeners.
call_user_func(array($model, 'flushEventListeners'));
// Reregister them.
call_user_func(array($model, 'boot'));
}
}
I have my models in subdirectories so I edited #TheShiftExchange code a bit
//Get all models in the Model directory
$pathToModels = '/path/to/app/models';
$files = File::allFiles($pathToModels);
foreach ($files as $file) {
$fileName = $file->getFileName();
if (!ends_with($fileName, 'Search.php') && !starts_with($fileName, 'Base')) {
$model = str_replace('.php', '', $fileName);
// Flush any existing listeners.
call_user_func(array($model, 'flushEventListeners'));
// Re-register them.
call_user_func(array($model, 'boot'));
}
}
Related
We have a Silverstripe 4 project which acts as a headless CMS returning a group of complex data models formatted as JSON.
Here's an example of the code:
class APIController extends ContentController
{
public function index(HTTPRequest $request)
{
$dataArray['model1'] = AccessPointController::getModel1();
$dataArray['model2'] = AccessPointController::getModel2();
$dataArray['model3'] = AccessPointController::getModel3();
$dataArray['model4'] = AccessPointController::getModel4();
$dataArray['model5'] = AccessPointController::getModel5();
$dataArray['model6'] = AccessPointController::getModel6();
$this->response->addHeader('Content-Type', 'application/json');
$this->response->addHeader('Access-Control-Allow-Origin', '*');
return json_encode($dataArray);
}
The problem we're having is the data models have got so complex the generation time for the JSON is running into seconds.
The JSON should only change when site content has been updates so ideally we'd like to cache the JSON & rather than dynamically generating it for each call.
What is the best way to cache the JSON in the above example?
Have you looked at the silverstripe docs about caching?
They do provide a programmatic way to store things in cache. And configuration options what back-ends are to be used to store the cache.
A simple example might be:
I've extended the cache live time here, but still you should note that this cache is not intended for storing generated static content, but rather to reduce load. Your application will still have to compute the api response every 86400 seconds (24 hours).
# app/_config/apiCache.yml
---
Name: apicacheconfig
---
# [... rest of your config config ...]
SilverStripe\Core\Injector\Injector:
Psr\SimpleCache\CacheInterface.apiResponseCache:
factory: SilverStripe\Core\Cache\CacheFactory
constructor:
namespace: "apiResponseCache"
defaultLifetime: 86400
<?php // app/src/FooController.php
class FooController extends \SilverStripe\Control\Controller {
public function getCache() {
return Injector::inst()->get('Psr\SimpleCache\CacheInterface.apiResponseCache');
// or your own cache (see below):
// return new MyCache();
}
protected function hasDataBeenChanged() {
// alternative to this method, you could also simply include Page::get()->max('LastEdited') or whatever in your cache key inside index(), but if you are using your own cache system, you need to handle deleting of old unused cache files. If you are using SilverStripe's cache, it will do that for you
$c = $this->getCache();
$lastCacheTime = $c->has('cacheTime') ? (int)$c->get('cacheTime') : 0;
$lastDataChangeTime = strtotime(Page::get()->max('LastEdited'));
return $lastDataChangeTime > $lastCacheTime;
}
public function index() {
$c = $this->getCache();
$cacheKey = 'indexActionResponse';
if ($c->has($cacheKey) && !$this->hasDataBeenChanged()) {
$data = $c->get($cacheKey);
} else {
$dataArray['model1'] = AccessPointController::getModel1();
$dataArray['model2'] = AccessPointController::getModel2();
$dataArray['model3'] = AccessPointController::getModel3();
$dataArray['model4'] = AccessPointController::getModel4();
$dataArray['model5'] = AccessPointController::getModel5();
$dataArray['model6'] = AccessPointController::getModel6();
$data = json_encode($dataArray);
$c->set($cacheKey, $data);
$c->set('cacheTime', time());
}
$this->response->addHeader('Content-Type', 'application/json');
$this->response->addHeader('Access-Control-Allow-Origin', '*');
return json_encode($dataArray);
}
}
If you are looking for a permanent/persistent cache, that will only ever update when data changed, I suggest you look for a different back-end or just implement a simple cache yourself and use that instead of the silverstripe cache.
class MyCache {
protected function fileName($key) {
if (strpos($key, '/') !== false || strpos($key, '\\') !== false) {
throw new \Exception("Invalid cache key '$key'");
}
return BASE_PATH . "/api-cache/$key.json";
}
public function get($key) {
if ($this->has($key)) {
return file_get_contents($this->fileName($key));
}
return null;
}
public function set($key, $val) {
file_put_contents($this->fileName($key), $val);
}
public function has($key) {
$f = $this->fileName($key);
return #file_exists($f);
}
I've been using Symfony 3.1 with new Cache Component (https://symfony.com/doc/current/components/cache.html), I'm using the redis adapter
config.yml
cache:
app: cache.adapter.redis
default_redis_provider: "redis://127.0.0.1:6379"
Basically, I save data in redis when I do a GET to a specific resource, and I remove it from redis, when I do a POST..
With symfony in dev mode data is stored/removed from cache as I expected.
But when I change it to prod, the 'deleteItem' no longer removes the item from redis cache..
I cannot find any error in logs, so I'm getting a little bit lost with it..
This is a sample of how I'm using the cache
protected function getCache(){
return $this->get('cache.app');
}
public function getAction(){
$cacheItem = $this->getCache()->getItem('example-key');
$data = ... // Check cacheItem isHit() ...
$cacheItem->expiresAfter($this->defaultCacheTime);
$cacheItem->set($data);
$this->getCache()->save($cacheItem);
}
public function postAction() {
...
$this->getCache()->deleteItem('example-key');
}
Update - I've found what might be causing this issue
This is part of the code of symfony AbstractAdapter and RedisAdapter:
public function deleteItem($key)
{
return $this->deleteItems(array($key));
}
public function deleteItems(array $keys)
{
$ids = array();
foreach ($keys as $key) {
$ids[$key] = $this->getId($key);
unset($this->deferred[$key]);
}
try {
if ($this->doDelete($ids)) {
return true;
}
} catch (\Exception $e) {
}
$ok = true;
// When bulk-delete failed, retry each item individually
foreach ($ids as $key => $id) {
try {
$e = null;
if ($this->doDelete(array($id))) {
continue;
}
} catch (\Exception $e) {
}
CacheItem::log($this->logger, 'Failed to delete key "{key}"', array('key' => $key, 'exception' => $e));
$ok = false;
}
return $ok;
}
protected function doDelete(array $ids)
{
if ($ids) {
$this->redis->del($ids);
}
return true;
}
This is part of code from Predis StreamConnection.php:
public function writeRequest(CommandInterface $command)
{
$commandID = $command->getId();
$arguments = $command->getArguments();
$cmdlen = strlen($commandID);
$reqlen = count($arguments) + 1;
$buffer = "*{$reqlen}\r\n\${$cmdlen}\r\n{$commandID}\r\n";
for ($i = 0, $reqlen--; $i < $reqlen; $i++) {
$argument = $arguments[$i];
$arglen = strlen($argument);
$buffer .= "\${$arglen}\r\n{$argument}\r\n";
}
$this->write($buffer);
}
When I call deleteItem('example-key'), it then calls the deleteItems(..) to remove that key..
The thing is, deleteItems() is calling doDelete() and passing an array like
'example-key' => 'prefix_example-key'
The doDelete(), then calls the Redis client, passing that same array string => string, when I think it should be, index => string,eg: [0] => 'prefix_example-key' instead of ['example-key'] => 'prefix_example-key'
Then the redis client when processing the command to execute, receives that array as $arguments, and in the for loop, it does this:
$argument = $arguments[$i]; since the array is in string => string format, it won't work, in dev mode, it shows Notice undefined offset 0 error
This is the strange part
In 'dev' mode, it will throw an error, and so, the deleteItems() will catch it, and try to delete the item again, this time, sending the arguments properly
In 'prod' mode, the Notice undefined offset 0 don't know why, but it does not throw an exception, so deleteItems(..) won't catch it, returns right there..
I've found a way to make it work for me, if I add array_values in the doDelete method, it works:
protected function doDelete(array $ids)
{
if ($ids) {
$this->redis->del(array_values($ids));
}
return true;
}
I don't know if all of this is making sense or not, I think I'll open an issue in symfony bug tracker
My bad, this was caused by an outdated version of Predis, I tought I had the latest version of Predis, but I didn't
Everything is working fine with the latest version
I'm searching for a way to create a custom action button which allows me to make a new DataObject with pre-filled content from another DataObject. As a simple example: When I have an email and click the "answer"-button in my email-client, I get a new window with pre-filled content from the email before. I need exactly this functionality for my button. This button should appear next to each DataObject in the GridField.
So I know how to make a button and add it to my GridField (--> https://docs.silverstripe.org/en/3.2/developer_guides/forms/how_tos/create_a_gridfield_actionprovider/) and I know how to go to a new DataObject:
Controller::curr()->redirect($gridField->Link('item/new'));
I also found out that there is a duplicate function for DataObjects:
public function duplicate($doWrite = true) {
$className = $this->class;
$clone = new $className( $this->toMap(), false, $this->model );
$clone->ID = 0;
$clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite);
if($doWrite) {
$clone->write();
$this->duplicateManyManyRelations($this, $clone);
}
$clone->invokeWithExtensions('onAfterDuplicate', $this, $doWrite);
return $clone;
}
Perhaps it's easier than I think but at the moment I just don't get how to rewrite this to get what I need. Can somebody give me a hint?
That's for sure not the cleanest solution but I think it should do the trick.
At first let's create the custom gridfield action. Here we will save all accessible records in a session and add a query string to the url so that we'll know which object we want to "clone"
public function getColumnContent($gridField, $record, $columnName) {
if(!$record->canEdit()) return;
$field = GridField_FormAction::create(
$gridField,
'clone'.$record->ID,
'Clone',
'clone',
array('RecordID' => $record->ID)
);
$values = Session::get('ClonedData');
$data = $record->data()->toMap();
if($arr = $values) {
$arr[$record->ID] = $data;
} else {
$arr = array(
$record->ID => $data
);
}
Session::set('ClonedData', $arr);
return $field->Field();
}
public function getActions($gridField) {
return array('clone');
}
public function handleAction(GridField $gridField, $actionName, $arguments, $data) {
if($actionName == 'clone') {
$id = $arguments['RecordID'];
Controller::curr()->redirect($gridField->Link("item/new/?cloneID=$id"));
}
}
after adding this new component to our gridfield,
$gridField->getConfig()->addComponent(new GridFieldCustomAction());
we'll need to bring the data into the new form. To do so, add this code directly above "return $fields" on your getCMSFields function so it will be executed every time we'll open this kind of object.
$values = Session::get('ClonedData');
if($values) {
Session::clear('ClonedData');
$json = json_encode($values);
$fields->push(LiteralField::create('ClonedData', "<div id='cloned-data' style='display:none;'>$json</div>"));
}
At the end we need to bring the content back into the fields. We'll do that with a little bit of javascript so at first you need to create a new script.js file and include it in the ss backend (or just use an existing one).
(function($) {
$('#cloned-data').entwine({
onmatch: function() {
var data = JSON.parse($(this).text()),
id = getParameterByName('cloneID');
if(id && data) {
var obj = data[id];
if(obj) {
$.each(obj, function(i, val) {
$('[name=' + i + ']').val(val);
});
}
}
}
});
// http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript#answer-901144
function getParameterByName(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results === null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
})(jQuery);
And that's it ... quite tricky. Hope it will solve your problem.
I'm currently working on a Silverstripe 3.1 website that has dozens of random header images.
I can easily setup a "HeaderImage" databobjectset, but manually adding every image via the CMS would be a tedious headache.
Is there a simple way to have a dataobjectset automatically populated by the contents of a folder.
For example every image file in /assets/header-images/ automatically becomes a "HeaderImage" object. I want to be able to easily add or remove images.
Any ideas would be appreciated.
some details about the proposed solutions.
1) Like #3dgoo mentioned, using the GridFieldBulkEditingTools module. Download the latest master of best via composer "colymba/gridfield-bulk-editing-tools": "dev-master". This will let you upload a bunch of images and will create a DataObject for each one. Use the Bulk upload button. Here is how to have it set up in ModelAdmin:
class HeaderAdmin extends ModelAdmin
{
private static $managed_models = array('HeaderImage');
private static $url_segment = 'header-admin';
private static $menu_title = 'Header admin';
public function getEditForm($id = null, $fields = null)
{
$form = parent::getEditForm($id, $fields);
$gridField = $form->Fields()->fieldByName($this->sanitiseClassName('HeaderImage'));
if ( $gridField )
{
$gridField->getConfig()->addComponent(new GridFieldBulkImageUpload());
}
return $form;
}
}
2) Another solution, which would require a lot more work, is create a BuildTask and sort out the logic in run():
class ImportHeaderImagesTask extends BuildTask
{
protected $title = 'Import Header Images';
protected $description = 'Import Header Images......';
/**
* Check that the user has appropriate permissions to execute this task
*/
public function init()
{
if( !Director::is_cli() && !Director::isDev() && !Permission::check('ADMIN') )
{
return Security::permissionFailure();
}
parent::init();
}
/**
* Do some stuff
*/
public function run($request)
{
// this is where files are uploaded manually
$TempFTPFolder = ASSETS_PATH . '/FTP';
// This is the folder where files will be moved
$LiveFolderPath = 'assets/path/to/final/live/folder/';
$LiveFolder = DataObject::get_one('File', "Filename = '$LiveFolderPath'");
if ( file_exists( $TempFTPFolder ) && $LiveFolder->ID ) // if the FTP upload folder exist and the destination live folder exist
{
$FTPList = scandir( $TempFTPFolder ); // get the FTP folder content
foreach ($FTPList as $FileFolder)
{
$FTPFile = $TempFTPFolder . '/' . $FileFolder;
if ( is_file( $FTPFile ) ) // process files only
{
// Create File object for the live version
$NewFile = new File();
$NewFile->setParentID( $LiveFolder->ID );
$NewFile->setName( $FileFolder );
// get target name/path
$RenameTarget = $NewFile->getFullPath();
if ( $RenameTarget )
{
$moved = false;
try {
$moved = rename( $FTPFile, $RenameTarget ); // move the FTP file to the live folder
} catch (Exception $e) {}
if ( $moved )
{
$NewFile->write();
// create DataObject and add image relation
$HeaderImage = HeaderImage::create();
$HeaderImage->ImageID = $NewFile->ID;
$HeaderImage->write();
}
}
}
}
}
}
}
You can run this tasks via the dev/ url or via the command line or a CRON job. Note that I adapted the run() logic from something I've done a while ago, so not guaranteed it will work by just copy/pasting.
Have recently been using Symfony2 after using ZF for some time.
I am having problems trying to do something relatively simple, I think.
The following code is within a controller:
private $current_setid = "";
public function __construct() {
$current_set = $this->getCurrentSet();
if ($current_set == "") {
return $this->redirect($this->generateUrl('selectset'));
}
$this->current_setid = $current_set;
}
public function getCurrentSet() {
$session = $this->get("session");
$set = $session->get('set');
return $set;
}
public function setCurrentSet($setid) {
$session = $this->get("session");
$session->set('set', "$setid");
}
If I use __construct() I get errors like:
Fatal error: Call to a member function get() on a non-object in
I have tried using __init() and init() both of which do not seem to get called.
Can anyone point me in the right direction? Is there a simple way to do this or do I have to look into event listeners?
Have you tried getting your session like they do in official documentation?
$session = $this->getRequest()->getSession();
$foo = $session->get('foo');
Basically get fetch dependencies from container and container in the Controller is injected using setter dependency injection. You just not have container in the time of __construct yet.
Just ended up opting for placing a check in every method in the class. Seems silly to have to do that but I find I often have to do that in Symfony2 with the lack of init, postDispatch type methods like ZF has.
Even trying to remove the check to another method was counter productive as I still had to check the return from that method as $this->redirect does not seem to work unless it is within an Action method. For example:
public function isSetSet() {
$current_set = $this->getCurrentSet();
if ($current_set == "") {
$url = $this->generateUrl('selectset');
return $this->redirect($url);
}
return TRUE;
}
public function someAction() {
$check = $this->isSetSet();
if($check != TRUE){
return $check;
}
...
}
So each method needs that 4 line check but the whole check can be done in 4 lines anyway so no need for that extra method:
public function anotherAction() {
$current_setid = $this->getCurrentSet();
if ($current_setid == "") {
return $this->redirect($this->generateUrl('selectset'));
}
...
}