Merge pull request #1 from silverstripe/master

Update
This commit is contained in:
Martijn 2013-12-16 08:04:26 -08:00
commit ee336c13d9
51 changed files with 1861 additions and 516 deletions

View File

@ -217,6 +217,21 @@
}
});
/**
* If we've a history state to go back to, go back, otherwise fall back to
* submitting the form with the 'doCancel' action.
*/
$('.cms-edit-form .Actions input.action[type=submit].ss-ui-action-cancel, .cms-edit-form .Actions button.action.ss-ui-action-cancel').entwine({
onclick: function(e) {
if (History.getStateByIndex(1)) {
History.back();
} else {
this.parents('form').trigger('submit', [this]);
}
e.preventDefault();
}
});
/**
* Hide tabs when only one is available.
* Special case is actiontabs - tabs between buttons, where we want to have

View File

@ -284,9 +284,10 @@ jQuery.noConflict();
* - {Object} data Any additional data passed through to History.pushState()
* - {boolean} forceReload Forces the replacement of the current history state, even if the URL is the same, i.e. allows reloading.
*/
loadPanel: function(url, title, data, forceReload) {
loadPanel: function(url, title, data, forceReload, forceReferer) {
if(!data) data = {};
if(!title) title = "";
if (!forceReferer) forceReferer = History.getState().url;
// Check change tracking (can't use events as we need a way to cancel the current state change)
var contentEls = this._findFragments(data.pjax ? data.pjax.split(',') : ['Content']);
@ -306,6 +307,7 @@ jQuery.noConflict();
this.saveTabState();
if(window.History.enabled) {
$.extend(data, {__forceReferer: forceReferer});
// Active menu item is set based on X-Controller ajax header,
// which matches one class on the menu
if(forceReload) {
@ -457,7 +459,13 @@ jQuery.noConflict();
// The actually returned view isn't always decided upon when the request
// is fired, so the server might decide to change it based on its own logic.
headers['X-Pjax'] = fragments;
// Set 'fake' referer - we call pushState() before making the AJAX request, so we have to
// set our own referer here
if (typeof state.data.__forceReferer !== 'undefined') {
headers['X-Backurl'] = state.data.__forceReferer;
}
contentEls.addClass('loading');
var xhr = $.ajax({
headers: headers,

View File

@ -1,17 +1,21 @@
<?php
/**
* RestfulService class allows you to consume various RESTful APIs.
*
* Through this you could connect and aggregate data of various web services.
* For more info visit wiki documentation - http://doc.silverstripe.org/doku.php?id=restfulservice
*
* @see http://doc.silverstripe.org/framework/en/reference/restfulservice
*
* @package framework
* @subpackage integration
*/
class RestfulService extends ViewableData {
protected $baseURL;
protected $queryString;
protected $errorTag;
protected $checkErrors;
protected $connectTimeout = 5;
protected $cache_expire;
protected $authUsername, $authPassword;
protected $customHeaders = array();
@ -213,7 +217,6 @@ class RestfulService extends ViewableData {
*/
public function curlRequest($url, $method, $data = null, $headers = null, $curlOptions = array()) {
$ch = curl_init();
$timeout = 5;
$sapphireInfo = new SapphireInfo();
$useragent = 'SilverStripe/' . $sapphireInfo->Version();
$curlOptions = $curlOptions + (array)$this->config()->default_curl_options;
@ -221,7 +224,7 @@ class RestfulService extends ViewableData {
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_USERAGENT, $useragent);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->getConnectTimeout());
if(!ini_get('open_basedir')) curl_setopt($ch, CURLOPT_FOLLOWLOCATION,1);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
@ -553,6 +556,31 @@ class RestfulService extends ViewableData {
return $output;
}
/**
* Set the connection timeout for the curl request in seconds.
*
* @see http://curl.haxx.se/libcurl/c/curl_easy_setopt.html#CURLOPTCONNECTTIMEOUT
*
* @param int
*
* @return RestfulService
*/
public function setConnectTimeout($timeout) {
$this->connectTimeout = $timeout;
return $this;
}
/**
* Return the connection timeout value.
*
* @return int
*/
public function getConnectTimeout() {
return $this->connectTimeout;
}
}
/**

View File

@ -488,6 +488,8 @@ class Controller extends RequestHandler implements TemplateGlobalProvider {
if($this->request) {
if($this->request->requestVar('BackURL')) {
$url = $this->request->requestVar('BackURL');
} else if($this->request->isAjax() && $this->request->getHeader('X-Backurl')) {
$url = $this->request->getHeader('X-Backurl');
} else if($this->request->getHeader('Referer')) {
$url = $this->request->getHeader('Referer');
}

View File

@ -347,6 +347,7 @@ class Debug {
);
if(file_exists($errorFilePath)) {
$content = file_get_contents(ASSETS_PATH . "/error-$statusCode.html");
if(!headers_sent()) header('Content-Type: text/html');
// $BaseURL is left dynamic in error-###.html, so that multi-domain sites don't get broken
echo str_replace('$BaseURL', Director::absoluteBaseURL(), $content);
}

View File

@ -371,15 +371,14 @@ class InstallRequirements {
}
/**
* Check if the web server is IIS.
* Check if the web server is IIS and version greater than the given version.
* @return boolean
*/
function isIIS($version = 7) {
if(strpos($this->findWebserver(), 'IIS/' . $version) !== false) {
return true;
} else {
function isIIS($fromVersion = 7) {
if(strpos($this->findWebserver(), 'IIS/') === false) {
return false;
}
return substr(strstr($this->findWebserver(), '/'), -3, 1) >= $fromVersion;
}
function isApache() {
@ -409,7 +408,7 @@ class InstallRequirements {
function check() {
$this->errors = null;
$isApache = $this->isApache();
$isIIS = $this->isIIS(7);
$isIIS = $this->isIIS();
$webserver = $this->findWebserver();
$this->requirePHPVersion('5.3.4', '5.3.3', array(

View File

@ -1,4 +1,4 @@
# 3.1.2 (unreleased)
# 3.1.2
## Overview

View File

@ -38,7 +38,7 @@ Our web-based [PHP installer](/installation) can check if you meet the requireme
Hardware requirements vary widely depending on the traffic to your website, the complexity of its logic (i.e., PHP), and its size (i.e., database.) By default, all pages are dynamic, and thus access both the database and execute PHP code to generate. SilverStripe can cache full pages and segments of templates to dramatically increase performance.
A typical website page on a conservative single CPU machine (e.g., Intel 2Ghz) takes roughly 300ms to generate. This comfortably allows over a million page views per month. Caching and other optimisations can improve this by a factor of ten or even one hundred times. SilverStripe CMS can be used in multiple-server architectures to improve scalability and redunancy.
A typical website page on a conservative single CPU machine (e.g., Intel 2Ghz) takes roughly 300ms to generate. This comfortably allows over a million page views per month. Caching and other optimisations can improve this by a factor of ten or even one hundred times. SilverStripe CMS can be used in multiple-server architectures to improve scalability and redundancy.
## Client side (CMS) requirements

View File

@ -250,6 +250,50 @@ The CMS default sections as well as custom interfaces like
`[ModelAdmin](/reference/modeladmin)` or `[GridField](/reference/gridfield)`
already enforce these permissions.
## Indexes
It is sometimes desirable to add indexes to your data model, whether to
optimize queries or add a uniqueness constraint to a field. This is done
through the `DataObject::$indexes` map, which maps index names to descriptor
arrays that represent each index. There's several supported notations:
:::php
# Simple
private static $indexes = array(
'<column-name>' => true
);
# Advanced
private static $indexes = array(
'<index-name>' => array('type' => '<type>', 'value' => '"<column-name>"')
);
# SQL
private static $indexes = array(
'<index-name>' => 'unique("<column-name>")'
);
The "advanced" notation varies between database drivers, but all of them support the following keys:
* `index`: Standard index
* `unique`: Index plus uniqueness constraint on the value
* `fulltext`: Fulltext content index
In order to use more database specific or complex index notations,
we also support raw SQL for as a value in the `$indexes` definition.
Keep in mind this will likely make your code less portable between databases.
Example: A combined index on a two fields.
:::php
private static $db = array(
'MyField' => 'Varchar',
'MyOtherField' => 'Varchar',
);
private static $indexes = array(
'MyIndexName' => array('type' => 'index', 'value' => '"MyField","MyOtherField"'),
);
## API Documentation
`[api:DataObject]`

View File

@ -61,13 +61,16 @@ For example, if we have a menu, we want that menu to update whenever _any_ page
otherwise. By using aggregates, we can do that like this:
:::ss
<% cached 'navigation', List(SiteTree).max(LastEdited) %>
<% cached 'navigation', List(SiteTree).max(LastEdited), List(SiteTree).count() %>
If we have a block that shows a list of categories, we can make sure the cache updates every time a category is added or
edited
:::ss
<% cached 'categorylist', List(Category).max(LastEdited) %>
<% cached 'categorylist', List(Category).max(LastEdited), List(Category).count() %>
Note the use of both .max(LastEdited) and .count() - this takes care of both the case where an object has been edited
since the cache was last built, and also when an object has been deleted/un-linked since the cache was last built.
We can also calculate aggregates on relationships. A block that shows the current member's favourites needs to update
whenever the relationship Member::$has_many = array('Favourites' => Favourite') changes.

View File

@ -112,6 +112,24 @@ You can also clear specific Requirements:
Caution: Depending on where you call this command, a Requirement might be *re-included* afterwards.
## Blocking
Requirements can also be explicitly blocked from inclusion,
which is useful to avoid conflicting JavaScript logic or CSS rules.
These blocking rules are independent of where the `block()` call is made:
It applies both for already included requirements, and ones
included after the `block()` call.
One common example is to block the core `jquery.js` include
added by various form fields and core controllers,
and use a newer version in a custom location.
:::php
Requirements::block(THIRDPARTY_DIR . '/jquery/jquery.js');
Caution: The CMS also uses the `Requirements` system, and its operation can be
affected by `block()` calls. Avoid this by limiting the scope of
your blocking operations, e.g. in `init()` of your controller.
## Inclusion Order

View File

@ -102,8 +102,8 @@ in order to read page limit information. It is also passed the current
if($records) {
$records = new PaginatedList($records, $this->request);
$records->setPageStart($start);
$records->setPageSize($limit);
$records->setTotalSize($query->unlimitedRowCount());
$records->setPageLength($limit);
$records->setTotalItems($query->unlimitedRowCount());
}
return $records;

View File

@ -9,9 +9,9 @@ There are a number of ways to restrict access in SilverStripe. In the security
that have access to certain parts. The options can be found on the [permissions](/reference/permission) documentation.
Once you have groups, you can set access for each page for a particular groups. This can be:
- anyone
- any person who is logged in
- a specific group
* anyone;
* any person who is logged in;
* a specific group.
It is unclear how this works for data-objects that are not pages.
@ -20,15 +20,15 @@ It is unclear how this works for data-objects that are not pages.
In the security tab you can make groups for security. The way this was intended was as follows (this may be a counter
intuitive):
* employees
* marketing
* marketing executive
* employees
* marketing
* marketing executive
Thus, the further up the hierarchy you go the MORE privileges you can get. Similarly, you could have:
* members
* coordinators
* admins
* members
* coordinators
* admins
Where members have some privileges, coordinators slightly more and administrators the most; having each group inheriting
privileges from its parent group.
@ -36,7 +36,7 @@ privileges from its parent group.
## Permission checking is at class level
SilverStripe provides a security mechanism via the *Permission::check* method (see `[api:LeftAndMain]` for examples on how
the admin screens work)
the admin screens work).
(next step -- go from *Permission::checkMember*...)
@ -58,4 +58,4 @@ important security checks are made by calling *Permission::check*.
### Customizing Access Checks in CMS Classes
see `[api:LeftAndMain]`
see `[api:LeftAndMain]`

View File

@ -210,6 +210,25 @@ You can also combine both conjunctive ("AND") and disjunctive ("OR") statements.
'Age' => 17,
));
// WHERE ("LastName" = 'Minnée' AND ("FirstName" = 'Sam' OR "Age" = '17'))
### Filter with PHP / filterByCallback
It is also possible to filter by a PHP callback, however this will force the
data model to fetch all records and loop them in PHP, thus `filter()` or `filterAny()`
are to be preferred over `filterByCallback()`.
Please note that because `filterByCallback()` has to run in PHP, it will always return
an `ArrayList` (even if called on a `DataList`, this however might change in future).
The first parameter to the callback is the item, the second parameter is the list itself.
The callback will run once for each record, if the callback returns true, this record
will be added to the list of returned items.
The below example will get all Members that have an expired or not encrypted password.
:::php
$membersWithBadPassword = Member::get()->filterByCallback(function($item, $list) {
if ($item->isPasswordExpired() || $item->PasswordEncryption = 'none') {
return true;
}
});
### Exclude
@ -743,6 +762,11 @@ Example: Validate postcodes based on the selected country
}
}
<div class="hint" markdown='1'>
**Tip:** If you decide to add unique or other indexes to your model via
`static $indexes`, see [DataObject](/reference/dataobject) for details.
</div>
## Maps
A map is an array where the array indexes contain data as well as the values.

View File

@ -95,7 +95,7 @@ Creating a form is a matter of defining a method to represent that form. This
method should return a form object. The constructor takes the following
arguments:
* `$controller`: This must be and instance of the controller that contains the form, often `$this`.
* `$controller`: This must be an instance of the controller that contains the form, often `$this`.
* `$name`: This must be the name of the method on that controller that is called to return the form. The first two
fields allow the form object to be re-created after submission. **It's vital that they are properly set - if you ever
have problems with form action handler not working, check that these values are correct.**

View File

@ -42,7 +42,7 @@ This is explained in a more in-depth topic at [Page Type Templates](/topics/page
## Adding Database Fields
Adding database fields is a simple process. You define them in an array of the static variable `$db`, this array is
added on the object class. For example, Page or StaffPage. Every time you run db/build to recompile the manifest, it
added on the object class. For example, Page or StaffPage. Every time you run dev/build to recompile the manifest, it
checks if any new entries are added to the `$db` array and adds any fields to the database that are missing.
For example, you may want an additional field on a `StaffPage` class which extends `Page`, called `Author`. `Author` is a
@ -120,4 +120,4 @@ This will also work if you want to remove a whole tab e.g. $fields->removeByName
Metadata tab.
For more information on forms, see [form](/topics/forms), [tutorial:2-extending-a-basic-site](/tutorials/2-extending-a-basic-site)
and [tutorial:3-forms](/tutorials/3-forms).
and [tutorial:3-forms](/tutorials/3-forms).

View File

@ -413,7 +413,7 @@ you need to serve directly.
See [Apache](/installation/webserver) and [Nginx](/installation/nginx) installation documentation for details
specific to your web server
See [Apache](/installation/webserver) and [Nginx](/installation/nginx) installation documentation for details specific to your web server
## Passwords

View File

@ -385,10 +385,15 @@ The controller for a page is only created when page is actually visited, while t
## Creating a RSS feed
An RSS feed is something that no news section should be without. SilverStripe makes it easy to create RSS feeds by providing an `[api:RSSFeed]` class to do all the hard work for us. Create the following function in the
*ArticleHolder_Controller*:
An RSS feed is something that no news section should be without. SilverStripe makes it easy to create RSS feeds by providing an `[api:RSSFeed]` class to do all the hard work for us. Add the following in the *ArticleHolder_Controller* class:
**mysite/code/ArticlePage.php**
:::php
private static $allowed_actions = array(
'rss'
);
public function rss() {
$rss = new RSSFeed($this->Children(), $this->Link(), "The coolest news around");
return $rss->outputToBrowser();
@ -403,6 +408,8 @@ Depending on your browser, you should see something like the picture below. If y
Now all we need is to let the user know that our RSS feed exists. Add this function to *ArticleHolder_Controller*:
**mysite/code/ArticlePage.php**
:::php
public function init() {
RSSFeed::linkToFeed($this->Link() . "rss");

View File

@ -304,7 +304,7 @@ a named list of object.
<% end_loop %>
</td>
<td>
<% loop $Mentor %>
<% loop $Mentors %>
$Name<% if $Last !=1 %>,<% end_if %>
<% end_loop %>
</td>

View File

@ -77,6 +77,17 @@ class DatetimeField extends FormField {
return $this;
}
public function setName($name) {
parent::setName($name);
$this->dateField->setName($name . '[date]');
$this->timeField->setName($name . '[time]');
$this->timezoneField->setName($name . '[timezone]');
return $this;
}
public function FieldHolder($properties = array()) {
$config = array(

View File

@ -684,7 +684,7 @@ class FormField extends RequestHandler {
*/
public function Field($properties = array()) {
$obj = ($properties) ? $this->customise($properties) : $this;
$this->extend('onBeforeRender', $this);
return $obj->renderWith($this->getTemplates());
}

View File

@ -215,9 +215,12 @@ class GridFieldAddExistingAutocompleter
->limit($this->getResultsLimit());
$json = array();
$originalSourceFileComments = Config::inst()->get('SSViewer', 'source_file_comments');
Config::inst()->update('SSViewer', 'source_file_comments', false);
foreach($results as $result) {
$json[$result->ID] = SSViewer::fromString($this->resultsFormat)->process($result);
$json[$result->ID] = html_entity_decode(SSViewer::fromString($this->resultsFormat)->process($result));
}
Config::inst()->update('SSViewer', 'source_file_comments', $originalSourceFileComments);
return Convert::array2json($json);
}

View File

@ -372,8 +372,26 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
$actions->push(new LiteralField('cancelbutton', $text));
}
}
$fields = $this->component->getFields();
if(!$fields) $fields = $this->record->getCMSFields();
// If we are creating a new record in a has-many list, then
// pre-populate the record's foreign key. Also disable the form field as
// it has no effect.
if($list instanceof HasManyList) {
$key = $list->getForeignKey();
$id = $list->getForeignID();
if(!$this->record->isInDB()) {
$this->record->$key = $id;
}
if($field = $fields->dataFieldByName($key)) {
$fields->makeFieldReadonly($field);
}
}
$form = new Form(
$this,
'ItemEditForm',

View File

@ -87,6 +87,7 @@ class GridFieldSortableHeader implements GridField_HTMLProvider, GridField_DataM
$state = $gridField->State->GridFieldSortableHeader;
$columns = $gridField->getColumns();
$currentColumn = 0;
$list = $gridField->getList();
foreach($columns as $columnField) {
$currentColumn++;
@ -96,7 +97,35 @@ class GridFieldSortableHeader implements GridField_HTMLProvider, GridField_DataM
if(isset($this->fieldSorting[$columnField]) && $this->fieldSorting[$columnField]) {
$columnField = $this->fieldSorting[$columnField];
}
if($title && $gridField->getList()->canSortBy($columnField)) {
$allowSort = ($title && $list->canSortBy($columnField));
if(!$allowSort && strpos($columnField, '.') !== false) {
// we have a relation column with dot notation
// @see DataObject::relField for approximation
$parts = explode('.', $columnField);
$tmpItem = singleton($list->dataClass());
for($idx = 0; $idx < sizeof($parts); $idx++) {
$methodName = $parts[$idx];
if($tmpItem instanceof SS_List) {
// It's impossible to sort on a HasManyList/ManyManyList
break;
} elseif($tmpItem->hasMethod($methodName)) {
// The part is a relation name, so get the object/list from it
$tmpItem = $tmpItem->$methodName();
} elseif($tmpItem instanceof DataObject && $tmpItem->hasField($methodName)) {
// Else, if we've found a field at the end of the chain, we can sort on it.
// If a method is applied further to this field (E.g. 'Cost.Currency') then don't try to sort.
$allowSort = $idx === sizeof($parts) - 1;
break;
} else {
// If neither method nor field, then unable to sort
break;
}
}
}
if($allowSort) {
$dir = 'asc';
if($state->SortColumn(null) == $columnField && $state->SortDirection('asc') == 'asc') {
$dir = 'desc';
@ -161,14 +190,60 @@ class GridFieldSortableHeader implements GridField_HTMLProvider, GridField_DataM
}
}
/**
* Returns the manipulated (sorted) DataList. Field names will simply add an 'ORDER BY'
* clause, relation names will add appropriate joins to the DataQuery first.
*
* @param GridField
* @param SS_List
* @return SS_List
*/
public function getManipulatedData(GridField $gridField, SS_List $dataList) {
if(!$this->checkDataType($dataList)) return $dataList;
$state = $gridField->State->GridFieldSortableHeader;
$sortColumn = $state->SortColumn();
if (empty($sortcolumn)) {
if ($state->SortColumn == "") {
return $dataList;
}
return $dataList->sort($sortColumn, $state->SortDirection('asc'));
$column = $state->SortColumn;
// if we have a relation column with dot notation
if(strpos($column, '.') !== false) {
$lastAlias = $dataList->dataClass();
$tmpItem = singleton($lastAlias);
$parts = explode('.', $state->SortColumn);
for($idx = 0; $idx < sizeof($parts); $idx++) {
$methodName = $parts[$idx];
// If we're not on the last item, we're looking at a relation
if($idx !== sizeof($parts) - 1) {
// Traverse to the relational list
$tmpItem = $tmpItem->$methodName();
// Add a left join to the query
$dataList = $dataList->leftJoin(
$tmpItem->class,
'"' . $methodName . '"."ID" = "' . $lastAlias . '"."' . $methodName . 'ID"',
$methodName
);
// Store the last 'alias' name as it'll be used for the next
// join, or the 'sort' column
$lastAlias = $methodName;
} else {
// Change relation.relation.fieldname to alias.fieldname
$column = $lastAlias . '.' . $methodName;
}
}
}
// We need to manually create our ORDER BY "Foo"."Bar" string for relations,
// as ->sort() won't do it by itself. Blame PostgreSQL for making this necessary
$pieces = explode('.', $column);
$column = '"' . implode('"."', $pieces) . '"';
return $dataList->sort($column, $state->SortDirection('asc'));
}
}

View File

@ -67,12 +67,16 @@ ss.i18n = {
this.init();
var langName = this.getLocale().replace(/_[\w]+/i, '');
var defaultlangName = this.defaultLocale.replace(/_[\w]+/i, '');
if (this.lang && this.lang[this.getLocale()] && this.lang[this.getLocale()][entity]) {
return this.lang[this.getLocale()][entity];
} else if (this.lang && this.lang[langName] && this.lang[langName][entity]) {
return this.lang[langName][entity];
} else if (this.lang && this.lang[this.defaultLocale] && this.lang[this.defaultLocale][entity]) {
return this.lang[this.defaultLocale][entity];
} else if (this.lang && this.lang[defaultlangName] && this.lang[defaultlangName][entity]) {
return this.lang[defaultlangName][entity];
} else if(fallbackString) {
return fallbackString;
} else {

View File

@ -476,6 +476,27 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta
return $firstElement;
}
/**
* @see SS_Filterable::filterByCallback()
*
* @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
* @param callable $callback
* @return ArrayList
*/
public function filterByCallback($callback) {
if(!is_callable($callback)) {
throw new LogicException(sprintf(
"SS_Filterable::filterByCallback() passed callback must be callable, '%s' given",
gettype($callback)
));
}
$output = ArrayList::create();
foreach($this as $item) {
if(call_user_func($callback, $item, $this)) $output->push($item);
}
return $output;
}
/**
* Exclude the list to not contain items with these charactaristics
*

View File

@ -39,7 +39,7 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
/**
* The DataModel from which this DataList comes.
*
*
* @var DataModel
*/
protected $model;
@ -406,20 +406,24 @@ class DataList extends ViewableData implements SS_List, SS_Filterable, SS_Sortab
}
/**
* Filter this DataList by a callback function.
* The function will be passed each record of the DataList in turn, and must return true for the record to be
* included. Returns the filtered list.
*
* Note that, in the current implementation, the filtered list will be an ArrayList, but this may change in a
* future implementation.
* @see SS_Filterable::filterByCallback()
*
* @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
* @param callable $callback
* @return ArrayList (this may change in future implementations)
*/
public function filterByCallback($callback) {
if(!is_callable($callback)) {
throw new LogicException("DataList::filterByCallback() must be passed something callable.");
throw new LogicException(sprintf(
"SS_Filterable::filterByCallback() passed callback must be callable, '%s' given",
gettype($callback)
));
}
$output = new ArrayList();
$output = ArrayList::create();
foreach($this as $item) {
if($callback($item)) $output->push($item);
if(call_user_func($callback, $item, $this)) $output->push($item);
}
return $output;
}

View File

@ -1050,211 +1050,4 @@ abstract class SS_Database {
public function releaseLock($name) {
return false;
}
}
/**
* Abstract query-result class.
* Once again, this should be subclassed by an actual database implementation. It will only
* ever be constructed by a subclass of SS_Database. The result of a database query - an iteratable object
* that's returned by DB::SS_Query
*
* Primarily, the SS_Query class takes care of the iterator plumbing, letting the subclasses focusing
* on providing the specific data-access methods that are required: {@link nextRecord()}, {@link numRecords()}
* and {@link seek()}
* @package framework
* @subpackage model
*/
abstract class SS_Query implements Iterator {
/**
* The current record in the interator.
* @var array
*/
private $currentRecord = null;
/**
* The number of the current row in the interator.
* @var int
*/
private $rowNum = -1;
/**
* Flag to keep track of whether iteration has begun, to prevent unnecessary seeks
*/
private $queryHasBegun = false;
/**
* Return an array containing all the values from a specific column. If no column is set, then the first will be
* returned
*
* @param string $column
* @return array
*/
public function column($column = null) {
$result = array();
while($record = $this->next()) {
if($column) $result[] = $record[$column];
else $result[] = $record[key($record)];
}
return $result;
}
/**
* Return an array containing all values in the leftmost column, where the keys are the
* same as the values.
* @return array
*/
public function keyedColumn() {
$column = array();
foreach($this as $record) {
$val = $record[key($record)];
$column[$val] = $val;
}
return $column;
}
/**
* Return a map from the first column to the second column.
* @return array
*/
public function map() {
$column = array();
foreach($this as $record) {
$key = reset($record);
$val = next($record);
$column[$key] = $val;
}
return $column;
}
/**
* Returns the next record in the iterator.
* @return array
*/
public function record() {
return $this->next();
}
/**
* Returns the first column of the first record.
* @return string
*/
public function value() {
$record = $this->next();
if($record) return $record[key($record)];
}
/**
* Return an HTML table containing the full result-set
*/
public function table() {
$first = true;
$result = "<table>\n";
foreach($this as $record) {
if($first) {
$result .= "<tr>";
foreach($record as $k => $v) {
$result .= "<th>" . Convert::raw2xml($k) . "</th> ";
}
$result .= "</tr> \n";
}
$result .= "<tr>";
foreach($record as $k => $v) {
$result .= "<td>" . Convert::raw2xml($v) . "</td> ";
}
$result .= "</tr> \n";
$first = false;
}
$result .= "</table>\n";
if($first) return "No records found";
return $result;
}
/**
* Iterator function implementation. Rewind the iterator to the first item and return it.
* Makes use of {@link seek()} and {@link numRecords()}, takes care of the plumbing.
* @return array
*/
public function rewind() {
if($this->queryHasBegun && $this->numRecords() > 0) {
$this->queryHasBegun = false;
return $this->seek(0);
}
}
/**
* Iterator function implementation. Return the current item of the iterator.
* @return array
*/
public function current() {
if(!$this->currentRecord) {
return $this->next();
} else {
return $this->currentRecord;
}
}
/**
* Iterator function implementation. Return the first item of this iterator.
* @return array
*/
public function first() {
$this->rewind();
return $this->current();
}
/**
* Iterator function implementation. Return the row number of the current item.
* @return int
*/
public function key() {
return $this->rowNum;
}
/**
* Iterator function implementation. Return the next record in the iterator.
* Makes use of {@link nextRecord()}, takes care of the plumbing.
* @return array
*/
public function next() {
$this->queryHasBegun = true;
$this->currentRecord = $this->nextRecord();
$this->rowNum++;
return $this->currentRecord;
}
/**
* Iterator function implementation. Check if the iterator is pointing to a valid item.
* @return boolean
*/
public function valid() {
if(!$this->queryHasBegun) $this->next();
return $this->currentRecord !== false;
}
/**
* Return the next record in the query result.
* @return array
*/
abstract public function nextRecord();
/**
* Return the total number of items in the query result.
* @return int
*/
abstract public function numRecords();
/**
* Go to a specific row number in the query result and return the record.
* @param int $rowNum Tow number to go to.
* @return array
*/
abstract public function seek($rowNum);
}
}

View File

@ -30,7 +30,7 @@ interface SS_Filterable {
* // aziz with the age 21 or 43 and bob with the Age 21 or 43
*/
public function filter();
/**
* Return a new instance of this list that excludes any items with these charactaristics
*
@ -43,5 +43,14 @@ interface SS_Filterable {
* // bob age 21 or 43, phil age 21 or 43 would be excluded
*/
public function exclude();
/**
* Return a new instance of this list that excludes any items with these charactaristics
* Filter this List by a callback function. The function will be passed each record of the List in turn,
* and must return true for the record to be included. Returns the filtered list.
*
* @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
* @return SS_Filterable
*/
public function filterByCallback($callback);
}

View File

@ -147,6 +147,29 @@ abstract class SS_ListDecorator extends ViewableData implements SS_List, SS_Sort
return call_user_func_array(array($this->list, 'filter'), $args);
}
/**
* Note that, in the current implementation, the filtered list will be an ArrayList, but this may change in a
* future implementation.
* @see SS_Filterable::filterByCallback()
*
* @example $list = $list->filterByCallback(function($item, $list) { return $item->Age == 9; })
* @param callable $callback
* @return ArrayList (this may change in future implementations)
*/
public function filterByCallback($callback) {
if(!is_callable($callback)) {
throw new LogicException(sprintf(
"SS_Filterable::filterByCallback() passed callback must be callable, '%s' given",
gettype($callback)
));
}
$output = ArrayList::create();
foreach($this->list as $item) {
if(call_user_func($callback, $item, $this->list)) $output->push($item);
}
return $output;
}
public function limit($limit, $offset = 0) {
return $this->list->limit($limit, $offset);
}

View File

@ -7,12 +7,26 @@
* @subpackage model
*/
class SS_Map implements ArrayAccess, Countable, IteratorAggregate {
protected $list, $keyField, $valueField;
/**
* @see SS_Map::unshift()
*
* @var array $firstItems
*/
protected $firstItems = array();
/**
* @see SS_Map::push()
*
* @var array $lastItems
*/
protected $lastItems = array();
/**
* Construct a new map around an SS_list.
*
* @param $list The list to build a map from
* @param $keyField The field to use as the key of each map entry
* @param $valueField The field to use as the value of each map entry
@ -24,182 +38,355 @@ class SS_Map implements ArrayAccess, Countable, IteratorAggregate {
}
/**
* Set the key field for this map
* Set the key field for this map.
*
* @var string $keyField
*/
public function setKeyField($keyField) {
$this->keyField = $keyField;
}
/**
* Set the value field for this map
* Set the value field for this map.
*
* @var string $valueField
*/
public function setValueField($valueField) {
$this->valueField = $valueField;
}
/**
* Return an array equivalent to this map
* Return an array equivalent to this map.
*
* @return array
*/
public function toArray() {
$array = array();
foreach($this as $k => $v) {
$array[$k] = $v;
}
return $array;
}
/**
* Return all the keys of this map
* Return all the keys of this map.
*
* @return array
*/
public function keys() {
$output = array();
foreach($this as $k => $v) {
$output[] = $k;
}
return $output;
return array_keys($this->toArray());
}
/**
* Return all the values of this map
* Return all the values of this map.
*
* @return array
*/
public function values() {
$output = array();
foreach($this as $k => $v) {
$output[] = $v;
}
return $output;
return array_values($this->toArray());
}
/**
* Unshift an item onto the start of the map
* Unshift an item onto the start of the map.
*
* Stores the value in addition to the {@link DataQuery} for the map.
*
* @var string $key
* @var mixed $value
*/
public function unshift($key, $value) {
$oldItems = $this->firstItems;
$this->firstItems = array($key => $value);
if($oldItems) $this->firstItems = $this->firstItems + $oldItems;
$this->firstItems = array(
$key => $value
);
if($oldItems) {
$this->firstItems = $this->firstItems + $oldItems;
}
return $this;
}
/**
* Pushes an item onto the end of the map.
*
* @var string $key
* @var mixed $value
*/
public function push($key, $value) {
$oldItems = $this->lastItems;
$this->lastItems = array(
$key => $value
);
if($oldItems) {
$this->lastItems = $this->lastItems + $oldItems;
}
return $this;
}
// ArrayAccess
/**
* @var string $key
*
* @return boolean
*/
public function offsetExists($key) {
if(isset($this->firstItems[$key])) return true;
if(isset($this->firstItems[$key])) {
return true;
}
if(isset($this->lastItems[$key])) {
return true;
}
$record = $this->list->find($this->keyField, $key);
return $record != null;
}
/**
* @var string $key
*
* @return mixed
*/
public function offsetGet($key) {
if(isset($this->firstItems[$key])) return $this->firstItems[$key];
if(isset($this->firstItems[$key])) {
return $this->firstItems[$key];
}
if(isset($this->lastItems[$key])) {
return $this->lastItems[$key];
}
$record = $this->list->find($this->keyField, $key);
if($record) {
$col = $this->valueField;
return $record->$col;
} else {
return null;
}
}
public function offsetSet($key, $value) {
if(isset($this->firstItems[$key])) return $this->firstItems[$key] = $value;
user_error("SS_Map is read-only", E_USER_ERROR);
return $record->$col;
}
return null;
}
/**
* Sets a value in the map by a given key that has been set via
* {@link SS_Map::push()} or {@link SS_Map::unshift()}
*
* Keys in the map cannot be set since these values are derived from a
* {@link DataQuery} instance. In this case, use {@link SS_Map::toArray()}
* and manipulate the resulting array.
*
* @var string $key
* @var mixed $value
*/
public function offsetSet($key, $value) {
if(isset($this->firstItems[$key])) {
return $this->firstItems[$key] = $value;
}
if(isset($this->lastItems[$key])) {
return $this->lastItems[$key] = $value;
}
user_error(
"SS_Map is read-only. Please use $map->push($key, $value) to append values",
E_USER_ERROR
);
}
/**
* Removes a value in the map by a given key which has been added to the map
* via {@link SS_Map::push()} or {@link SS_Map::unshift()}
*
* Keys in the map cannot be unset since these values are derived from a
* {@link DataQuery} instance. In this case, use {@link SS_Map::toArray()}
* and manipulate the resulting array.
*
* @var string $key
* @var mixed $value
*/
public function offsetUnset($key) {
if(isset($this->firstItems[$key])) {
unset($this->firstItems[$key]);
return;
}
user_error("SS_Map is read-only", E_USER_ERROR);
if(isset($this->lastItems[$key])) {
unset($this->lastItems[$key]);
return;
}
user_error(
"SS_Map is read-only. Unset cannot be called on keys derived from the DataQuery",
E_USER_ERROR
);
}
// IteratorAggreagte
/**
* Returns an SS_Map_Iterator instance for iterating over the complete set
* of items in the map.
*
* Satisfies the IteratorAggreagte interface.
*
* @return SS_Map_Iterator
*/
public function getIterator() {
return new SS_Map_Iterator($this->list->getIterator(), $this->keyField, $this->valueField, $this->firstItems);
return new SS_Map_Iterator(
$this->list->getIterator(),
$this->keyField,
$this->valueField,
$this->firstItems,
$this->lastItems
);
}
// Countable
/**
* Returns the count of items in the list including the additional items set
* through {@link SS_Map::push()} and {@link SS_Map::unshift}.
*
* @return int
*/
public function count() {
return $this->list->count();
return $this->list->count() +
count($this->firstItems) +
count($this->lastItems);
}
}
/**
* Builds a map iterator around an Iterator. Called by SS_Map
*
* @package framework
* @subpackage model
*/
class SS_Map_Iterator implements Iterator {
protected $items;
protected $keyField, $titleField;
protected $firstItemIdx = 0;
protected $endItemIdx;
protected $firstItems = array();
protected $lastItems = array();
protected $excludedItems = array();
/**
* @param $items The iterator to build this map from
* @param $keyField The field to use for the keys
* @param $titleField The field to use for the values
* @param $fistItems An optional map of items to show first
* @param Iterator $items The iterator to build this map from
* @param string $keyField The field to use for the keys
* @param string $titleField The field to use for the values
* @param array $fristItems An optional map of items to show first
* @param array $lastItems An optional map of items to show last
*/
public function __construct(Iterator $items, $keyField, $titleField, $firstItems = null) {
public function __construct(Iterator $items, $keyField, $titleField, $firstItems = null, $lastItems = null) {
$this->items = $items;
$this->keyField = $keyField;
$this->titleField = $titleField;
foreach($firstItems as $k => $v) {
$this->firstItems[] = array($k,$v);
$this->excludedItems[] = $k;
$this->endItemIdx = null;
if($firstItems) {
foreach($firstItems as $k => $v) {
$this->firstItems[] = array($k,$v);
$this->excludedItems[] = $k;
}
}
if($lastItems) {
foreach($lastItems as $k => $v) {
$this->lastItems[] = array($k, $v);
$this->excludedItems[] = $k;
}
}
}
// Iterator functions
/**
* Rewind the Iterator to the first element.
*
* @return mixed
*/
public function rewind() {
$this->firstItemIdx = 0;
$this->endItemIdx = null;
$rewoundItem = $this->items->rewind();
if(isset($this->firstItems[$this->firstItemIdx])) {
return $this->firstItems[$this->firstItemIdx][1];
} else {
if($rewoundItem) return ($rewoundItem->hasMethod($this->titleField))
? $rewoundItem->{$this->titleField}()
: $rewoundItem->{$this->titleField};
if($rewoundItem) {
if($rewoundItem->hasMethod($this->titleField)) {
return $rewoundItem->{$this->titleField}();
}
return $rewoundItem->{$this->titleField};
} else if(!$this->items->valid() && $this->lastItems) {
$this->endItemIdx = 0;
return $this->lastItems[0][1];
}
}
}
/**
* Return the current element.
*
* @return mixed
*/
public function current() {
if(isset($this->firstItems[$this->firstItemIdx])) {
if(($this->endItemIdx !== null) && isset($this->lastItems[$this->endItemIdx])) {
return $this->lastItems[$this->endItemIdx][1];
} else if(isset($this->firstItems[$this->firstItemIdx])) {
return $this->firstItems[$this->firstItemIdx][1];
} else {
return ($this->items->current()->hasMethod($this->titleField))
? $this->items->current()->{$this->titleField}()
: $this->items->current()->{$this->titleField};
if($this->items->current()->hasMethod($this->titleField)) {
return $this->items->current()->{$this->titleField}();
}
return $this->items->current()->{$this->titleField};
}
}
/**
* Return the key of the current element.
*
* @return string
*/
public function key() {
if(isset($this->firstItems[$this->firstItemIdx])) {
if(($this->endItemIdx !== null) && isset($this->lastItems[$this->endItemIdx])) {
return $this->lastItems[$this->endItemIdx][0];
} else if(isset($this->firstItems[$this->firstItemIdx])) {
return $this->firstItems[$this->firstItemIdx][0];
} else {
return $this->items->current()->{$this->keyField};
}
}
/**
* Move forward to next element.
*
* @return mixed
*/
public function next() {
$this->firstItemIdx++;
if(isset($this->firstItems[$this->firstItemIdx])) {
return $this->firstItems[$this->firstItemIdx][1];
} else {
if(!isset($this->firstItems[$this->firstItemIdx-1])) $this->items->next();
if(!isset($this->firstItems[$this->firstItemIdx-1])) {
$this->items->next();
}
if($this->excludedItems) {
while(($c = $this->items->current()) && in_array($c->{$this->keyField}, $this->excludedItems, true)) {
@ -207,9 +394,34 @@ class SS_Map_Iterator implements Iterator {
}
}
}
if(!$this->items->valid()) {
// iterator has passed the preface items, off the end of the items
// list. Track through the end items to go through to the next
if($this->endItemIdx === null) {
$this->endItemIdx = -1;
}
$this->endItemIdx++;
if(isset($this->lastItems[$this->endItemIdx])) {
return $this->lastItems[$this->endItemIdx];
}
return false;
}
}
/**
* Checks if current position is valid.
*
* @return boolean
*/
public function valid() {
return $this->items->valid();
return (
(isset($this->firstItems[$this->firstItemIdx])) ||
(($this->endItemIdx !== null) && isset($this->lastItems[$this->endItemIdx])) ||
$this->items->valid()
);
}
}

View File

@ -1209,53 +1209,4 @@ class MySQLDatabase extends SS_Database {
// Prefix with database name
return Convert::raw2sql($this->database . '_' . Convert::raw2sql($name));
}
}
/**
* A result-set from a MySQL database.
* @package framework
* @subpackage model
*/
class MySQLQuery extends SS_Query {
/**
* The MySQLDatabase object that created this result set.
* @var MySQLDatabase
*/
protected $database;
/**
* The internal MySQL handle that points to the result set.
* @var resource
*/
protected $handle;
/**
* Hook the result-set given into a Query class, suitable for use by SilverStripe.
* @param database The database object that created this query.
* @param handle the internal mysql handle that is points to the resultset.
*/
public function __construct(MySQLDatabase $database, $handle) {
$this->database = $database;
$this->handle = $handle;
}
public function __destruct() {
if(is_object($this->handle)) $this->handle->free();
}
public function seek($row) {
if(is_object($this->handle)) return $this->handle->data_seek($row);
}
public function numRecords() {
if(is_object($this->handle)) return $this->handle->num_rows;
}
public function nextRecord() {
if(is_object($this->handle) && ($data = $this->handle->fetch_assoc())) {
return $data;
} else {
return false;
}
}
}
}

69
model/MySQLQuery.php Normal file
View File

@ -0,0 +1,69 @@
<?php
/**
* A result-set from a MySQL database.
*
* @package framework
* @subpackage model
*/
class MySQLQuery extends SS_Query {
/**
* The MySQLDatabase object that created this result set.
* @var MySQLDatabase
*/
protected $database;
/**
* The internal MySQL handle that points to the result set.
* @var resource
*/
protected $handle;
/**
* Hook the result-set given into a Query class, suitable for use by
* SilverStripe.
*
* @param database $database The database object that created this query.
* @param handle $handle the internal mysql handle that is points to the resultset.
*/
public function __construct(MySQLDatabase $database, $handle) {
$this->database = $database;
$this->handle = $handle;
}
public function __destruct() {
if(is_object($this->handle)) {
$this->handle->free();
}
}
/**
* {@inheritdoc}
*/
public function seek($row) {
if(is_object($this->handle)) {
return $this->handle->data_seek($row);
}
}
/**
* {@inheritdoc}
*/
public function numRecords() {
if(is_object($this->handle)) {
return $this->handle->num_rows;
}
}
/**
* {@inheritdoc}
*/
public function nextRecord() {
if(is_object($this->handle) && ($data = $this->handle->fetch_assoc())) {
return $data;
} else {
return false;
}
}
}

252
model/Query.php Normal file
View File

@ -0,0 +1,252 @@
<?php
/**
* Abstract query-result class.
*
* Once again, this should be subclassed by an actual database implementation
* such as {@link MySQLQuery}.
*
* It will only ever be constructed by a subclass of {@link SS_Database} and
* contain the result of a database query as an iteratable object.
*
* Primarily, the SS_Query class takes care of the iterator plumbing, letting
* the subclasses focusing on providing the specific data-access methods that
* are required: {@link nextRecord()}, {@link numRecords()} and {@link seek()}
*
* @package framework
* @subpackage model
*/
abstract class SS_Query implements Iterator {
/**
* The current record in the interator.
*
* @var array
*/
private $currentRecord = null;
/**
* The number of the current row in the interator.
*
* @var int
*/
private $rowNum = -1;
/**
* Flag to keep track of whether iteration has begun, to prevent unnecessary
* seeks.
*
* @var boolean
*/
private $queryHasBegun = false;
/**
* Return an array containing all the values from a specific column. If no
* column is set, then the first will be returned.
*
* @param string $column
*
* @return array
*/
public function column($column = null) {
$result = array();
while($record = $this->next()) {
if($column) {
$result[] = $record[$column];
} else {
$result[] = $record[key($record)];
}
}
return $result;
}
/**
* Return an array containing all values in the leftmost column, where the
* keys are the same as the values.
*
* @return array
*/
public function keyedColumn() {
$column = array();
foreach($this as $record) {
$val = $record[key($record)];
$column[$val] = $val;
}
return $column;
}
/**
* Return a map from the first column to the second column.
*
* @return array
*/
public function map() {
$column = array();
foreach($this as $record) {
$key = reset($record);
$val = next($record);
$column[$key] = $val;
}
return $column;
}
/**
* Returns the next record in the iterator.
*
* @return array
*/
public function record() {
return $this->next();
}
/**
* Returns the first column of the first record.
*
* @return string
*/
public function value() {
$record = $this->next();
if($record) {
return $record[key($record)];
}
}
/**
* Return an HTML table containing the full result-set.
*/
public function table() {
$first = true;
$result = "<table>\n";
foreach($this as $record) {
if($first) {
$result .= "<tr>";
foreach($record as $k => $v) {
$result .= "<th>" . Convert::raw2xml($k) . "</th> ";
}
$result .= "</tr> \n";
}
$result .= "<tr>";
foreach($record as $k => $v) {
$result .= "<td>" . Convert::raw2xml($v) . "</td> ";
}
$result .= "</tr> \n";
$first = false;
}
$result .= "</table>\n";
if($first) return "No records found";
return $result;
}
/**
* Iterator function implementation. Rewind the iterator to the first item
* and return it.
*
* Makes use of {@link seek()} and {@link numRecords()}, takes care of the
* plumbing.
*
* @return array
*/
public function rewind() {
if($this->queryHasBegun && $this->numRecords() > 0) {
$this->queryHasBegun = false;
return $this->seek(0);
}
}
/**
* Iterator function implementation. Return the current item of the
* iterator.
*
* @return array
*/
public function current() {
if(!$this->currentRecord) {
return $this->next();
} else {
return $this->currentRecord;
}
}
/**
* Iterator function implementation. Return the first item of this iterator.
* @return array
*/
public function first() {
$this->rewind();
return $this->current();
}
/**
* Iterator function implementation. Return the row number of the current
* item.
*
* @return int
*/
public function key() {
return $this->rowNum;
}
/**
* Iterator function implementation. Return the next record in the iterator.
*
* Makes use of {@link nextRecord()}, takes care of the plumbing.
*
* @return array
*/
public function next() {
$this->queryHasBegun = true;
$this->currentRecord = $this->nextRecord();
$this->rowNum++;
return $this->currentRecord;
}
/**
* Iterator function implementation. Check if the iterator is pointing to a
* valid item.
*
* @return boolean
*/
public function valid() {
if(!$this->queryHasBegun) {
$this->next();
}
return $this->currentRecord !== false;
}
/**
* Return the next record in the query result.
*
* @return array
*/
abstract public function nextRecord();
/**
* Return the total number of items in the query result.
*
* @return int
*/
abstract public function numRecords();
/**
* Go to a specific row number in the query result and return the record.
*
* @param int $rowNum Tow number to go to.
*
* @return array
*/
abstract public function seek($rowNum);
}

View File

@ -411,12 +411,7 @@ class Versioned extends DataExtension {
// Extra tables for _Live, etc.
// Change unique indexes to 'index'. Versioned tables may run into unique indexing difficulties
// otherwise.
foreach($indexes as $key=>$index){
if(is_array($index) && $index['type']=='unique'){
$indexes[$key]['type']='index';
}
}
$indexes = $this->uniqueToIndex($indexes);
if($stage != $this->defaultStage) {
DB::requireTable("{$table}_$stage", $fields, $indexes, false, $options);
}
@ -454,12 +449,7 @@ class Versioned extends DataExtension {
);
//Unique indexes will not work on versioned tables, so we'll convert them to standard indexes:
foreach($indexes as $key=>$index){
if(is_array($index) && strtolower($index['type'])=='unique'){
$indexes[$key]['type']='index';
}
}
$indexes = $this->uniqueToIndex($indexes);
$versionIndexes = array_merge(
array(
'RecordID_Version' => array('type' => 'unique', 'value' => '"RecordID","Version"'),
@ -531,6 +521,35 @@ class Versioned extends DataExtension {
}
}
/**
* Helper for augmentDatabase() to find unique indexes and convert them to non-unique
*
* @param array $indexes The indexes to convert
* @return array $indexes
*/
private function uniqueToIndex($indexes) {
$unique_regex = '/unique/i';
$results = array();
foreach ($indexes as $key => $index) {
$results[$key] = $index;
// support string descriptors
if (is_string($index)) {
if (preg_match($unique_regex, $index)) {
$results[$key] = preg_replace($unique_regex, 'index', $index);
}
}
// canonical, array-based descriptors
elseif (is_array($index)) {
if (strtolower($index['type']) == 'unique') {
$results[$key]['type'] = 'index';
}
}
}
return $results;
}
/**
* Augment a write-record request.
*

View File

@ -20,8 +20,7 @@ require_once 'PHPUnit/Framework/Assert/Functions.php';
* Context automatically loaded by Behat.
* Uses subcontexts to extend functionality.
*/
class FeatureContext extends SilverStripeContext
{
class FeatureContext extends SilverStripeContext {
/**
* @var FixtureFactory
@ -34,8 +33,7 @@ class FeatureContext extends SilverStripeContext
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
public function __construct(array $parameters) {
parent::__construct($parameters);
$this->useContext('BasicContext', new BasicContext($parameters));
@ -57,8 +55,7 @@ class FeatureContext extends SilverStripeContext
$factory->define('Member', $blueprint);
}
public function setMinkParameters(array $parameters)
{
public function setMinkParameters(array $parameters) {
parent::setMinkParameters($parameters);
if(isset($parameters['files_path'])) {

View File

@ -22,8 +22,7 @@ require_once 'PHPUnit/Framework/Assert/Functions.php';
*
* Context used to define steps related to forms inside CMS.
*/
class CmsFormsContext extends BehatContext
{
class CmsFormsContext extends BehatContext {
protected $context;
/**
@ -32,8 +31,7 @@ class CmsFormsContext extends BehatContext
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
public function __construct(array $parameters) {
// Initialize your context here
$this->context = $parameters;
}
@ -41,28 +39,29 @@ class CmsFormsContext extends BehatContext
/**
* Get Mink session from MinkContext
*/
public function getSession($name = null)
{
public function getSession($name = null) {
return $this->getMainContext()->getSession($name);
}
/**
* @Then /^I should see an edit page form$/
* @Then /^I should( not? |\s*)see an edit page form$/
*/
public function stepIShouldSeeAnEditPageForm()
{
public function stepIShouldSeeAnEditPageForm($negative) {
$page = $this->getSession()->getPage();
$form = $page->find('css', '#Form_EditForm');
assertNotNull($form, 'I should see an edit page form');
if(trim($negative)) {
assertNull($form, 'I should not see an edit page form');
} else {
assertNotNull($form, 'I should see an edit page form');
}
}
/**
* @When /^I fill in the "(?P<field>([^"]*))" HTML field with "(?P<value>([^"]*))"$/
* @When /^I fill in "(?P<value>([^"]*))" for the "(?P<field>([^"]*))" HTML field$/
*/
public function stepIFillInTheHtmlFieldWith($field, $value)
{
public function stepIFillInTheHtmlFieldWith($field, $value) {
$page = $this->getSession()->getPage();
$inputField = $page->findField($field);
assertNotNull($inputField, sprintf('HTML field "%s" not found', $field));
@ -77,8 +76,7 @@ class CmsFormsContext extends BehatContext
/**
* @When /^I append "(?P<value>([^"]*))" to the "(?P<field>([^"]*))" HTML field$/
*/
public function stepIAppendTotheHtmlField($field, $value)
{
public function stepIAppendTotheHtmlField($field, $value) {
$page = $this->getSession()->getPage();
$inputField = $page->findField($field);
assertNotNull($inputField, sprintf('HTML field "%s" not found', $field));
@ -93,13 +91,12 @@ class CmsFormsContext extends BehatContext
/**
* @Then /^the "(?P<locator>([^"]*))" HTML field should contain "(?P<html>.*)"$/
*/
public function theHtmlFieldShouldContain($locator, $html)
{
public function theHtmlFieldShouldContain($locator, $html) {
$page = $this->getSession()->getPage();
$element = $page->findField($locator);
assertNotNull($element, sprintf('HTML field "%s" not found', $locator));
$actual = $element->getAttribute('value');
$actual = $element->getValue();
$regex = '/'.preg_quote($html, '/').'/ui';
if (!preg_match($regex, $actual)) {
$message = sprintf(
@ -160,8 +157,7 @@ class CmsFormsContext extends BehatContext
*
* @When /^I select "(?P<text>([^"]*))" in the "(?P<field>([^"]*))" HTML field$/
*/
public function stepIHighlightTextInHtmlField($text, $field)
{
public function stepIHighlightTextInHtmlField($text, $field) {
$page = $this->getSession()->getPage();
$inputField = $page->findField($field);
assertNotNull($inputField, sprintf('HTML field "%s" not found', $field));
@ -190,31 +186,42 @@ JS;
}
/**
* @Given /^I should see a "([^"]*)" button$/
* Example: I should see a "Submit" button
* Example: I should not see a "Delete" button
*
* @Given /^I should( not? |\s*)see a "([^"]*)" button$/
*/
public function iShouldSeeAButton($text)
{
public function iShouldSeeAButton($negative, $text) {
$page = $this->getSession()->getPage();
$els = $page->findAll('named', array('link_or_button', "'$text'"));
$matchedEl = null;
foreach($els as $el) {
if($el->isVisible()) $matchedEl = $el;
}
assertNotNull($matchedEl, sprintf('%s button not found', $text));
if(trim($negative)) {
assertNull($matchedEl, sprintf('%s button found', $text));
} else {
assertNotNull($matchedEl, sprintf('%s button not found', $text));
}
}
/**
* @Given /^I should not see a "([^"]*)" button$/
* @Given /^I should( not? |\s*)see a "([^"]*)" field$/
*/
public function iShouldNotSeeAButton($text)
{
public function iShouldSeeAField($negative, $text) {
$page = $this->getSession()->getPage();
$els = $page->findAll('named', array('link_or_button', "'$text'"));
$els = $page->findAll('named', array('field', "'$text'"));
$matchedEl = null;
foreach($els as $el) {
if($el->isVisible()) $matchedEl = $el;
}
assertNull($matchedEl, sprintf('%s button found', $text));
if(trim($negative)) {
assertNull($matchedEl);
} else {
assertNotNull($matchedEl);
}
}
}

View File

@ -22,8 +22,7 @@ require_once 'PHPUnit/Framework/Assert/Functions.php';
*
* Context used to define steps related to SilverStripe CMS UI like Tree or Panel.
*/
class CmsUiContext extends BehatContext
{
class CmsUiContext extends BehatContext {
protected $context;
/**
@ -32,8 +31,7 @@ class CmsUiContext extends BehatContext
*
* @param array $parameters context parameters (set them up through behat.yml)
*/
public function __construct(array $parameters)
{
public function __construct(array $parameters) {
// Initialize your context here
$this->context = $parameters;
}
@ -41,16 +39,14 @@ class CmsUiContext extends BehatContext
/**
* Get Mink session from MinkContext
*/
public function getSession($name = null)
{
public function getSession($name = null) {
return $this->getMainContext()->getSession($name);
}
/**
* @Then /^I should see the CMS$/
*/
public function iShouldSeeTheCms()
{
public function iShouldSeeTheCms() {
$page = $this->getSession()->getPage();
$cms_element = $page->find('css', '.cms');
assertNotNull($cms_element, 'CMS not found');
@ -59,21 +55,18 @@ class CmsUiContext extends BehatContext
/**
* @Then /^I should see a "([^"]*)" notice$/
*/
public function iShouldSeeANotice($notice)
{
public function iShouldSeeANotice($notice) {
$this->getMainContext()->assertElementContains('.notice-wrap', $notice);
}
/**
* @Then /^I should see a "([^"]*)" message$/
*/
public function iShouldSeeAMessage($message)
{
public function iShouldSeeAMessage($message) {
$this->getMainContext()->assertElementContains('.message', $message);
}
protected function getCmsTabsElement()
{
protected function getCmsTabsElement() {
$this->getSession()->wait(
5000,
"window.jQuery && window.jQuery('.cms-content-header-tabs').size() > 0"
@ -86,8 +79,7 @@ class CmsUiContext extends BehatContext
return $cms_content_header_tabs;
}
protected function getCmsContentToolbarElement()
{
protected function getCmsContentToolbarElement() {
$this->getSession()->wait(
5000,
"window.jQuery && window.jQuery('.cms-content-toolbar').size() > 0 "
@ -101,8 +93,7 @@ class CmsUiContext extends BehatContext
return $cms_content_toolbar_element;
}
protected function getCmsTreeElement()
{
protected function getCmsTreeElement() {
$this->getSession()->wait(
5000,
"window.jQuery && window.jQuery('.cms-tree').size() > 0"
@ -115,8 +106,7 @@ class CmsUiContext extends BehatContext
return $cms_tree_element;
}
protected function getGridfieldTable($title)
{
protected function getGridfieldTable($title) {
$page = $this->getSession()->getPage();
$table_elements = $page->findAll('css', '.ss-gridfield-table');
assertNotNull($table_elements, 'Table elements not found');
@ -137,8 +127,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^I should see a "([^"]*)" button in CMS Content Toolbar$/
*/
public function iShouldSeeAButtonInCmsContentToolbar($text)
{
public function iShouldSeeAButtonInCmsContentToolbar($text) {
$cms_content_toolbar_element = $this->getCmsContentToolbarElement();
$element = $cms_content_toolbar_element->find('named', array('link_or_button', "'$text'"));
@ -148,8 +137,7 @@ class CmsUiContext extends BehatContext
/**
* @When /^I should see "([^"]*)" in the tree$/
*/
public function stepIShouldSeeInCmsTree($text)
{
public function stepIShouldSeeInCmsTree($text) {
$cms_tree_element = $this->getCmsTreeElement();
$element = $cms_tree_element->find('named', array('content', "'$text'"));
@ -159,8 +147,7 @@ class CmsUiContext extends BehatContext
/**
* @When /^I should not see "([^"]*)" in the tree$/
*/
public function stepIShouldNotSeeInCmsTree($text)
{
public function stepIShouldNotSeeInCmsTree($text) {
$cms_tree_element = $this->getCmsTreeElement();
$element = $cms_tree_element->find('named', array('content', "'$text'"));
@ -170,8 +157,7 @@ class CmsUiContext extends BehatContext
/**
* @When /^I click on "([^"]*)" in the tree$/
*/
public function stepIClickOnElementInTheTree($text)
{
public function stepIClickOnElementInTheTree($text) {
$treeEl = $this->getCmsTreeElement();
$treeNode = $treeEl->findLink($text);
assertNotNull($treeNode, sprintf('%s not found', $text));
@ -181,8 +167,7 @@ class CmsUiContext extends BehatContext
/**
* @When /^I expand the "([^"]*)" CMS Panel$/
*/
public function iExpandTheCmsPanel()
{
public function iExpandTheCmsPanel() {
// TODO Make dynamic, currently hardcoded to first panel
$page = $this->getSession()->getPage();
@ -197,8 +182,7 @@ class CmsUiContext extends BehatContext
/**
* @When /^I click the "([^"]*)" CMS tab$/
*/
public function iClickTheCmsTab($tab)
{
public function iClickTheCmsTab($tab) {
$this->getSession()->wait(
5000,
"window.jQuery && window.jQuery('.ui-tabs-nav').size() > 0"
@ -221,8 +205,7 @@ class CmsUiContext extends BehatContext
/**
* @Then /^the "([^"]*)" table should contain "([^"]*)"$/
*/
public function theTableShouldContain($table, $text)
{
public function theTableShouldContain($table, $text) {
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('named', array('content', "'$text'"));
@ -232,8 +215,7 @@ class CmsUiContext extends BehatContext
/**
* @Then /^the "([^"]*)" table should not contain "([^"]*)"$/
*/
public function theTableShouldNotContain($table, $text)
{
public function theTableShouldNotContain($table, $text) {
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('named', array('content', "'$text'"));
@ -243,8 +225,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^I click on "([^"]*)" in the "([^"]*)" table$/
*/
public function iClickOnInTheTable($text, $table)
{
public function iClickOnInTheTable($text, $table) {
$table_element = $this->getGridfieldTable($table);
$element = $table_element->find('xpath', sprintf('//*[count(*)=0 and contains(.,"%s")]', $text));
@ -255,16 +236,14 @@ class CmsUiContext extends BehatContext
/**
* @Then /^I can see the preview panel$/
*/
public function iCanSeeThePreviewPanel()
{
public function iCanSeeThePreviewPanel() {
$this->getMainContext()->assertElementOnPage('.cms-preview');
}
/**
* @Given /^the preview contains "([^"]*)"$/
*/
public function thePreviewContains($content)
{
public function thePreviewContains($content) {
$driver = $this->getSession()->getDriver();
// TODO Remove once we have native support in Mink and php-webdriver,
// see https://groups.google.com/forum/#!topic/behat/QNhOuGHKEWI
@ -278,8 +257,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^I set the CMS mode to "([^"]*)"$/
*/
public function iSetTheCmsToMode($mode)
{
public function iSetTheCmsToMode($mode) {
return array(
new Step\When(sprintf('I fill in the "Change view mode" dropdown with "%s"', $mode)),
new Step\When('I wait for 1 second') // wait for CMS layout to redraw
@ -289,8 +267,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^I wait for the preview to load$/
*/
public function iWaitForThePreviewToLoad()
{
public function iWaitForThePreviewToLoad() {
$driver = $this->getSession()->getDriver();
// TODO Remove once we have native support in Mink and php-webdriver,
// see https://groups.google.com/forum/#!topic/behat/QNhOuGHKEWI
@ -307,8 +284,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^I switch the preview to "([^"]*)"$/
*/
public function iSwitchThePreviewToMode($mode)
{
public function iSwitchThePreviewToMode($mode) {
$controls = $this->getSession()->getPage()->find('css', '.cms-preview-controls');
assertNotNull($controls, 'Preview controls not found');
@ -326,8 +302,7 @@ class CmsUiContext extends BehatContext
/**
* @Given /^the preview does not contain "([^"]*)"$/
*/
public function thePreviewDoesNotContain($content)
{
public function thePreviewDoesNotContain($content) {
$driver = $this->getSession()->getDriver();
// TODO Remove once we have native support in Mink and php-webdriver,
// see https://groups.google.com/forum/#!topic/behat/QNhOuGHKEWI
@ -344,8 +319,7 @@ class CmsUiContext extends BehatContext
* @When /^(?:|I )fill in the "(?P<field>(?:[^"]|\\")*)" dropdown with "(?P<value>(?:[^"]|\\")*)"$/
* @When /^(?:|I )fill in "(?P<value>(?:[^"]|\\")*)" for the "(?P<field>(?:[^"]|\\")*)" dropdown$/
*/
public function theIFillInTheDropdownWith($field, $value)
{
public function theIFillInTheDropdownWith($field, $value) {
$field = $this->fixStepArgument($field);
$value = $this->fixStepArgument($value);
@ -447,8 +421,7 @@ class CmsUiContext extends BehatContext
*
* @return string
*/
protected function fixStepArgument($argument)
{
protected function fixStepArgument($argument) {
return str_replace('\\"', '"', $argument);
}
@ -461,11 +434,10 @@ class CmsUiContext extends BehatContext
*/
protected function findParentByClass(NodeElement $el, $class) {
$container = $el->getParent();
while($container && $container->getTagName() != 'body'
) {
while($container && $container->getTagName() != 'body') {
if($container->isVisible() && in_array($class, explode(' ', $container->getAttribute('class')))) {
return $container;
}
}
$container = $container->getParent();
}

View File

@ -6,7 +6,7 @@ Feature: Log in
Scenario: Bad login
Given I log in with "bad@example.com" and "badpassword"
Then I will see a bad log-in message
Then I will see a "bad" log-in message
Scenario: Valid login
Given I am logged in with "ADMIN" permissions
@ -17,4 +17,4 @@ Feature: Log in
# disable automatic redirection so we can use the profiler
When I go to "/admin/" without redirection
Then I should be redirected to "/Security/login"
And I should see a log-in form
And I should see a log-in form

View File

@ -5,9 +5,9 @@ Feature: Manage users
So that I can control access to the CMS
Background:
Given a "member" "Admin" belonging to "Admin Group" with "Email"="admin@test.com"
Given a "member" "ADMIN" belonging to "ADMIN Group" with "Email"="admin@test.com"
And a "member" "Staff" belonging to "Staff Group" with "Email"="staffmember@test.com"
And the "group" "Admin Group" has permissions "Full administrative rights"
And the "group" "ADMIN group" has permissions "Full administrative rights"
And I am logged in with "ADMIN" permissions
And I go to "/admin/security"
@ -19,7 +19,7 @@ Feature: Manage users
Scenario: I can list all users in a specific group
When I click the "Groups" CMS tab
# TODO Please check how performant this is
And I click "Admin Group" in the "#Root_Groups" element
And I click "ADMIN group" in the "#Root_Groups" element
Then I should see "admin@test.com" in the "#Root_Members" element
And I should not see "staffmember@test.com" in the "#Root_Members" element
@ -39,13 +39,13 @@ Feature: Manage users
Scenario: I can edit an existing user and add him to an existing group
When I click the "Users" CMS tab
And I click "staffmember@test.com" in the "#Root_Users" element
And I select "Admin Group" from "Groups"
And I select "ADMIN group" from "Groups"
And I press the "Save" button
Then I should see a "Saved Member" message
When I go to "admin/security"
And I click the "Groups" CMS tab
And I click "Admin Group" in the "#Root_Groups" element
And I click "ADMIN group" in the "#Root_Groups" element
Then I should see "staffmember@test.com"
Scenario: I can delete an existing user
@ -53,4 +53,4 @@ Feature: Manage users
And I click "staffmember@test.com" in the "#Root_Users" element
And I press the "Delete" button, confirming the dialog
Then I should see "admin@test.com"
And I should not see "staffmember@test.com"
And I should not see "staffmember@test.com"

View File

@ -122,6 +122,26 @@ class CheckboxSetFieldTest extends SapphireTest {
'CheckboxSetField loads data from a manymany relationship in an object through Form->loadDataFrom()'
);
}
public function testSavingIntoTextField() {
$field = new CheckboxSetField('Content', 'Content', array(
'Test' => 'Test',
'Another' => 'Another',
'Something' => 'Something'
));
$article = new CheckboxSetFieldTest_Article();
$field->setValue(array('Test' => 'Test', 'Another' => 'Another'));
$field->saveInto($article);
$article->write();
$dbValue = DB::query(sprintf(
'SELECT "Content" FROM "CheckboxSetFieldTest_Article" WHERE "ID" = %s',
$article->ID
))->value();
$this->assertEquals('Test,Another', $dbValue);
}
}
class CheckboxSetFieldTest_Article extends DataObject implements TestOnly {

View File

@ -220,6 +220,31 @@ class GridFieldDetailFormTest extends FunctionalTest {
$form = $request->ItemEditForm();
$this->assertNotNull($form->Fields()->fieldByName('Callback'));
}
/**
* Tests that a has-many detail form is pre-populated with the parent ID.
*/
public function testHasManyFormPrePopulated() {
$group = $this->objFromFixture(
'GridFieldDetailFormTest_PeopleGroup', 'group'
);
$this->logInWithPermission('ADMIN');
$response = $this->get('GridFieldDetailFormTest_Controller');
$parser = new CSSContentParser($response->getBody());
$addLink = $parser->getBySelector('.ss-gridfield .new-link');
$addLink = (string) $addLink[0]['href'];
$response = $this->get($addLink);
$parser = new CSSContentParser($response->getBody());
$title = $parser->getBySelector('#Form_ItemEditForm_GroupID_Holder span');
$id = $parser->getBySelector('#Form_ItemEditForm_GroupID_Holder input');
$this->assertEquals($group->Name, (string) $title[0]);
$this->assertEquals($group->ID, (string) $id[0]['value']);
}
}
class GridFieldDetailFormTest_Person extends DataObject implements TestOnly {

View File

@ -0,0 +1,146 @@
<?php
/**
* @package framework
* @subpackage tests
*/
class GridFieldSortableHeaderTest extends SapphireTest {
protected static $fixture_file = 'GridFieldSortableHeaderTest.yml';
protected $extraDataObjects = array(
'GridFieldSortableHeaderTest_Team',
'GridFieldSortableHeaderTest_Cheerleader',
'GridFieldSortableHeaderTest_CheerleaderHat'
);
/**
* Tests that the appropriate sortable headers are generated
*/
public function testRenderHeaders() {
// Generate sortable header and extract HTML
$list = new DataList('GridFieldSortableHeaderTest_Team');
$config = new GridFieldConfig_RecordEditor();
$form = new Form(Controller::curr(), 'Form', new FieldList(), new FieldList());
$gridField = new GridField('testfield', 'testfield', $list, $config);
$gridField->setForm($form);
$compontent = $gridField->getConfig()->getComponentByType('GridFieldSortableHeader');
$htmlFragment = $compontent->getHTMLFragments($gridField);
// Check that the output shows name and hat as sortable fields, but not city
$this->assertContains('<span class="non-sortable">City</span>', $htmlFragment['header']);
$this->assertContains('value="Name" class="action ss-gridfield-sort" id="action_SetOrderName"', $htmlFragment['header']);
$this->assertContains('value="Cheerleader Hat" class="action ss-gridfield-sort" id="action_SetOrderCheerleader.Hat.Colour"', $htmlFragment['header']);
// Check inverse of above
$this->assertNotContains('value="City" class="action ss-gridfield-sort" id="action_SetOrderCity"', $htmlFragment['header']);
$this->assertNotContains('<span class="non-sortable">Name</span>', $htmlFragment['header']);
$this->assertNotContains('<span class="non-sortable">Cheerleader Hat</span>', $htmlFragment['header']);
}
public function testGetManipulatedData() {
$list = new DataList('GridFieldSortableHeaderTest_Team');
$config = new GridFieldConfig_RecordEditor();
$gridField = new GridField('testfield', 'testfield', $list, $config);
// Test normal sorting
$state = $gridField->State->GridFieldSortableHeader;
$state->SortColumn = 'City';
$state->SortDirection = 'asc';
$compontent = $gridField->getConfig()->getComponentByType('GridFieldSortableHeader');
$listA = $compontent->getManipulatedData($gridField, $list);
$state->SortDirection = 'desc';
$listB = $compontent->getManipulatedData($gridField, $list);
$this->assertEquals(
array('Auckland', 'Cologne', 'Melbourne', 'Wellington'),
$listA->column('City')
);
$this->assertEquals(
array('Wellington', 'Melbourne', 'Cologne', 'Auckland'),
$listB->column('City')
);
// Test one relation 'deep'
$state->SortColumn = 'Cheerleader.Name';
$state->SortDirection = 'asc';
$relationListA = $compontent->getManipulatedData($gridField, $list);
$state->SortDirection = 'desc';
$relationListB = $compontent->getManipulatedData($gridField, $list);
$this->assertEquals(
array('Wellington', 'Melbourne', 'Cologne', 'Auckland'),
$relationListA->column('City')
);
$this->assertEquals(
array('Auckland', 'Cologne', 'Melbourne', 'Wellington'),
$relationListB->column('City')
);
// Test two relations 'deep'
$state->SortColumn = 'Cheerleader.Hat.Colour';
$state->SortDirection = 'asc';
$relationListC = $compontent->getManipulatedData($gridField, $list);
$state->SortDirection = 'desc';
$relationListD = $compontent->getManipulatedData($gridField, $list);
$this->assertEquals(
array('Cologne', 'Auckland', 'Wellington', 'Melbourne'),
$relationListC->column('City')
);
$this->assertEquals(
array('Melbourne', 'Wellington', 'Auckland', 'Cologne'),
$relationListD->column('City')
);
}
}
class GridFieldSortableHeaderTest_Team extends DataObject implements TestOnly {
private static $summary_fields = array(
'Name' => 'Name',
'City.Initial' => 'City',
'Cheerleader.Hat.Colour' => 'Cheerleader Hat'
);
private static $db = array(
'Name' => 'Varchar',
'City' => 'Varchar'
);
private static $has_one = array(
'Cheerleader' => 'GridFieldSortableHeaderTest_Cheerleader'
);
}
class GridFieldSortableHeaderTest_Cheerleader extends DataObject implements TestOnly {
private static $db = array(
'Name' => 'Varchar'
);
private static $has_one = array(
'Team' => 'GridFieldSortableHeaderTest_Team',
'Hat' => 'GridFieldSortableHeaderTest_CheerleaderHat'
);
}
class GridFieldSortableHeaderTest_CheerleaderHat extends DataObject implements TestOnly {
private static $db = array(
'Colour' => 'Varchar'
);
private static $has_one = array(
'Cheerleader' => 'GridFieldSortableHeaderTest_Cheerleader'
);
}

View File

@ -0,0 +1,39 @@
GridFieldSortableHeaderTest_CheerleaderHat:
hat1:
Colour: Blue
hat2:
Colour: Red
hat3:
Colour: Green
hat4:
Colour: Pink
GridFieldSortableHeaderTest_Cheerleader:
cheerleader1:
Name: Heather
Hat: =>GridFieldSortableHeaderTest_CheerleaderHat.hat2
cheerleader2:
Name: Bob
Hat: =>GridFieldSortableHeaderTest_CheerleaderHat.hat4
cheerleader3:
Name: Jenny
Hat: =>GridFieldSortableHeaderTest_CheerleaderHat.hat1
cheerleader4:
Name: Sam
Hat: =>GridFieldSortableHeaderTest_CheerleaderHat.hat3
GridFieldSortableHeaderTest_Team:
team1:
Name: Team 1
City: Cologne
Cheerleader: =>GridFieldSortableHeaderTest_Cheerleader.cheerleader3
team2:
Name: Team 2
City: Wellington
Cheerleader: =>GridFieldSortableHeaderTest_Cheerleader.cheerleader2
team3:
Name: Team 3
City: Auckland
Cheerleader: =>GridFieldSortableHeaderTest_Cheerleader.cheerleader4
team4:
Name: Team 4
City: Melbourne
Cheerleader: =>GridFieldSortableHeaderTest_Cheerleader.cheerleader1

View File

@ -438,6 +438,32 @@ class ArrayListTest extends SapphireTest {
$this->assertEquals(3, $list->count());
$this->assertEquals($expected, $list->toArray(), 'List should only contain Steve and Steve and Clair');
}
/**
* $list = $list->filterByCallback(function($item, $list) { return $item->Age == 21; })
*/
public function testFilterByCallback() {
$list = new ArrayList(array(
array('Name' => 'Steve', 'ID' => 1, 'Age' => 21),
array('Name' => 'Bob', 'ID' => 2, 'Age' => 18),
array('Name' => 'Clair', 'ID' => 2, 'Age' => 21),
array('Name' => 'Oscar', 'ID' => 2, 'Age' => 52),
array('Name' => 'Mike', 'ID' => 3, 'Age' => 43)
));
$list = $list->filterByCallback(function ($item, $list) {
return $item->Age == 21;
});
$expected = array(
new ArrayData(array('Name' => 'Steve', 'ID' => 1, 'Age' => 21)),
new ArrayData(array('Name' => 'Clair', 'ID' => 2, 'Age' => 21)),
);
$this->assertEquals(2, $list->count());
$this->assertEquals($expected, $list->toArray(), 'List should only contain Steve and Clair');
$this->assertTrue($list instanceof SS_Filterable, 'The List should be of type SS_Filterable');
}
/**
* $list->exclude('Name', 'bob'); // exclude bob from list

View File

@ -671,6 +671,24 @@ class DataListTest extends SapphireTest {
$this->assertEquals(0, $list->exclude('ID', $obj->ID)->count());
}
/**
* $list = $list->filterByCallback(function($item, $list) { return $item->Age == 21; })
*/
public function testFilterByCallback() {
$team1ID = $this->idFromFixture('DataObjectTest_Team', 'team1');
$list = DataObjectTest_TeamComment::get();
$list = $list->filterByCallback(function ($item, $list) use ($team1ID) {
return $item->TeamID == $team1ID;
});
$result = $list->column('Name');
$expected = array_intersect($result, array('Joe', 'Bob'));
$this->assertEquals(2, $list->count());
$this->assertEquals($expected, $result, 'List should only contain comments from Team 1 (Joe and Bob)');
$this->assertTrue($list instanceof SS_Filterable, 'The List should be of type SS_Filterable');
}
/**
* $list->exclude('Name', 'bob'); // exclude bob from list
*/

View File

@ -1,6 +1,11 @@
<?php
/**
* @package framework
* @subpackage tests
*/
class SS_MapTest extends SapphireTest {
// Borrow the model from DataObjectTest
protected static $fixture_file = 'DataObjectTest.yml';
@ -16,6 +21,43 @@ class SS_MapTest extends SapphireTest {
'DataObjectTest_TeamComment'
);
public function testValues() {
$list = DataObjectTest_TeamComment::get()->sort('Name');
$map = new SS_Map($list, 'Name', 'Comment');
$this->assertEquals(array(
'This is a team comment by Bob',
'This is a team comment by Joe',
'Phil is a unique guy, and comments on team2'
), $map->values());
$map->push('Push', 'Item');
$this->assertEquals(array(
'This is a team comment by Bob',
'This is a team comment by Joe',
'Phil is a unique guy, and comments on team2',
'Item'
), $map->values());
$map = new SS_Map(new ArrayList());
$map->push('Push', 'Pushed value');
$this->assertEquals(array(
'Pushed value'
), $map->values());
$map = new SS_Map(new ArrayList());
$map->unshift('Unshift', 'Unshift item');
$this->assertEquals(array(
'Unshift item'
), $map->values());
}
public function testArrayAccess() {
$list = DataObjectTest_TeamComment::get();
$map = new SS_Map($list, 'Name', 'Comment');
@ -65,6 +107,39 @@ class SS_MapTest extends SapphireTest {
'Joe',
'Phil'
), $map->keys());
$map->unshift('Unshift', 'Item');
$this->assertEquals(array(
'Unshift',
'Bob',
'Joe',
'Phil'
), $map->keys());
$map->push('Push', 'Item');
$this->assertEquals(array(
'Unshift',
'Bob',
'Joe',
'Phil',
'Push'
), $map->keys());
$map = new SS_Map(new ArrayList());
$map->push('Push', 'Item');
$this->assertEquals(array(
'Push'
), $map->keys());
$map = new SS_Map(new ArrayList());
$map->unshift('Unshift', 'Item');
$this->assertEquals(array(
'Unshift'
), $map->keys());
}
public function testMethodAsValueField() {
@ -80,16 +155,6 @@ class SS_MapTest extends SapphireTest {
), $map->values());
}
public function testValues() {
$list = DataObjectTest_TeamComment::get()->sort('Name');
$map = new SS_Map($list, 'Name', 'Comment');
$this->assertEquals(array(
'This is a team comment by Bob',
'This is a team comment by Joe',
'Phil is a unique guy, and comments on team2'
), $map->values());
}
public function testUnshift() {
$list = DataObjectTest_TeamComment::get();
$map = new SS_Map($list, 'Name', 'Comment');
@ -137,7 +202,103 @@ class SS_MapTest extends SapphireTest {
"Bob" => "Replaced",
0 => "(Select)",
-1 => "(All)"), $map->toArray());
}
public function testPush() {
$list = DataObjectTest_TeamComment::get();
$map = new SS_Map($list, 'Name', 'Comment');
$map->push(1, '(All)');
$this->assertEquals(array(
"Joe" => "This is a team comment by Joe",
"Bob" => "This is a team comment by Bob",
"Phil" => "Phil is a unique guy, and comments on team2",
1 => "(All)"
), $map->toArray());
}
public function testCount() {
$list = DataObjectTest_TeamComment::get();
$map = new SS_Map($list, 'Name', 'Comment');
$this->assertEquals(3, $map->count());
// pushing a new item should update the count
$map->push(1, 'Item pushed');
$this->assertEquals(4, $map->count());
$map->unshift(2, 'Item shifted');
$this->assertEquals(5, $map->count());
$map = new SS_Map(new ArrayList());
$map->unshift('1', 'shifted');
$this->assertEquals(1, $map->count());
unset($map[1]);
$this->assertEquals(0, $map->count());
}
public function testIterationWithUnshift() {
$list = DataObjectTest_TeamComment::get()->sort('ID');
$map = new SS_Map($list, 'Name', 'Comment');
$map->unshift(1, 'Unshifted');
$text = "";
foreach($map as $k => $v) {
$text .= "$k: $v\n";
}
$this->assertEquals("1: Unshifted\n"
. "Joe: This is a team comment by Joe\n"
. "Bob: This is a team comment by Bob\n"
. "Phil: Phil is a unique guy, and comments on team2\n", $text
);
}
public function testIterationWithPush() {
$list = DataObjectTest_TeamComment::get()->sort('ID');
$map = new SS_Map($list, 'Name', 'Comment');
$map->push(1, 'Pushed');
$text = "";
foreach($map as $k => $v) {
$text .= "$k: $v\n";
}
$this->assertEquals("Joe: This is a team comment by Joe\n"
. "Bob: This is a team comment by Bob\n"
. "Phil: Phil is a unique guy, and comments on team2\n"
. "1: Pushed\n", $text
);
}
public function testIterationWithEmptyListUnshifted() {
$map = new SS_Map(new ArrayList());
$map->unshift('1', 'unshifted');
$text = "";
foreach($map as $k => $v) {
$text .= "$k: $v\n";
}
$this->assertEquals("1: unshifted\n", $text);
}
public function testIterationWithEmptyListPushed() {
$map = new SS_Map(new ArrayList());
$map->push('1', 'pushed');
$text = "";
foreach($map as $k => $v) {
$text .= "$k: $v\n";
}
$this->assertEquals("1: pushed\n", $text);
}
}

View File

@ -12,13 +12,70 @@ class VersionedTest extends SapphireTest {
'VersionedTest_DataObject',
'VersionedTest_Subclass',
'VersionedTest_RelatedWithoutVersion',
'VersionedTest_SingleStage'
'VersionedTest_SingleStage',
'VersionedTest_WithIndexes',
);
protected $requiredExtensions = array(
"VersionedTest_DataObject" => array('Versioned')
"VersionedTest_DataObject" => array('Versioned'),
"VersionedTest_WithIndexes" => array('Versioned'),
);
public function testUniqueIndexes() {
$table_expectations = array(
'VersionedTest_WithIndexes' =>
array('value' => 1, 'message' => 'Unique indexes are unique in main table'),
'VersionedTest_WithIndexes_versions' =>
array('value' => 0, 'message' => 'Unique indexes are no longer unique in _versions table'),
'VersionedTest_WithIndexes_Live' =>
array('value' => 0, 'message' => 'Unique indexes are no longer unique in _Live table'),
);
// Check for presence of all unique indexes
$db = DB::getConn();
$db_class = get_class($db);
$tables = array_keys($table_expectations);
switch ($db_class) {
case 'MySQLDatabase':
$our_indexes = array('UniqA_idx', 'UniqS_idx');
foreach ($tables as $t) {
$indexes = array_keys($db->indexList($t));
sort($indexes);
$this->assertEquals(
array_values($our_indexes), array_values(array_intersect($indexes, $our_indexes)),
"$t has both indexes");
}
break;
case 'SQLite3Database':
$our_indexes = array('"UniqA"', '"UniqS"');
foreach ($tables as $t) {
$indexes = array_values($db->indexList($t));
sort($indexes);
$this->assertEquals(array_values($our_indexes),
array_values(array_intersect(array_values($indexes), $our_indexes)), "$t has both indexes");
}
break;
default:
$this->markTestSkipped("Test for DBMS $db_class not implemented; skipped.");
break;
}
// Check unique -> non-unique conversion
foreach ($table_expectations as $table_name => $expectation) {
$indexes = $db->indexList($table_name);
foreach ($indexes as $idx_name => $idx_value) {
if (in_array($idx_name, $our_indexes)) {
$match_value = preg_match('/unique/', $idx_value);
if (false === $match_value) {
user_error('preg_match failure');
}
$this->assertEquals($match_value, $expectation['value'], $expectation['message']);
}
}
}
}
public function testDeletingOrphanedVersions() {
$obj = new VersionedTest_Subclass();
$obj->ExtraField = 'Foo'; // ensure that child version table gets written
@ -521,6 +578,22 @@ class VersionedTest_DataObject extends DataObject implements TestOnly {
}
class VersionedTest_WithIndexes extends DataObject implements TestOnly {
private static $db = array(
'UniqA' => 'Int',
'UniqS' => 'Int',
);
private static $extensions = array(
"Versioned('Stage', 'Live')"
);
private static $indexes = array(
'UniqS_idx' => 'unique ("UniqS")',
'UniqA_idx' => array('type' => 'unique', 'name' => 'UniqA_idx', 'value' => '"UniqA"',),
);
}
/**
* @package framework
* @subpackage tests

View File

@ -1299,6 +1299,38 @@ after')
$this->assertEquals($expected, trim($this->render($template, $data)));
}
}
public function testClosedBlockExtension() {
$count = 0;
$parser = new SSTemplateParser();
$parser->addClosedBlock(
'test',
function (&$res) use (&$count) {
$count++;
}
);
$template = new SSViewer_FromString("<% test %><% end_test %>", $parser);
$template->process(new SSViewerTestFixture());
$this->assertEquals(1, $count);
}
public function testOpenBlockExtension() {
$count = 0;
$parser = new SSTemplateParser();
$parser->addOpenBlock(
'test',
function (&$res) use (&$count) {
$count++;
}
);
$template = new SSViewer_FromString("<% test %>", $parser);
$template->process(new SSViewerTestFixture());
$this->assertEquals(1, $count);
}
}
/**

View File

@ -74,9 +74,33 @@ class SSTemplateParser extends Parser implements TemplateParser {
protected $includeDebuggingComments = false;
/**
* Override the Parser constructor to change the requirement of setting a string
* Stores the user-supplied closed block extension rules in the form:
* array(
* 'name' => function (&$res) {}
* )
* See SSTemplateParser::ClosedBlock_Handle_Loop for an example of what the callable should look like
* @var array
*/
function __construct() {
protected $closedBlocks = array();
/**
* Stores the user-supplied open block extension rules in the form:
* array(
* 'name' => function (&$res) {}
* )
* See SSTemplateParser::OpenBlock_Handle_Base_tag for an example of what the callable should look like
* @var array
*/
protected $openBlocks = array();
/**
* Allow the injection of new closed & open block callables
* @param array $closedBlocks
* @param array $openBlocks
*/
public function __construct($closedBlocks = array(), $openBlocks = array()) {
$this->setClosedBlocks($closedBlocks);
$this->setOpenBlocks($openBlocks);
}
/**
@ -87,6 +111,84 @@ class SSTemplateParser extends Parser implements TemplateParser {
if (!isset($res['php'])) $res['php'] = '';
return $res;
}
/**
* Set the closed blocks that the template parser should use
*
* This method will delete any existing closed blocks, please use addClosedBlock if you don't
* want to overwrite
* @param array $closedBlocks
* @throws InvalidArgumentException
*/
public function setClosedBlocks($closedBlocks) {
$this->closedBlocks = array();
foreach ((array) $closedBlocks as $name => $callable) {
$this->addClosedBlock($name, $callable);
}
}
/**
* Set the open blocks that the template parser should use
*
* This method will delete any existing open blocks, please use addOpenBlock if you don't
* want to overwrite
* @param array $openBlocks
* @throws InvalidArgumentException
*/
public function setOpenBlocks($openBlocks) {
$this->openBlocks = array();
foreach ((array) $openBlocks as $name => $callable) {
$this->addOpenBlock($name, $callable);
}
}
/**
* Add a closed block callable to allow <% name %><% end_name %> syntax
* @param string $name The name of the token to be used in the syntax <% name %><% end_name %>
* @param callable $callable The function that modifies the generation of template code
* @throws InvalidArgumentException
*/
public function addClosedBlock($name, $callable) {
$this->validateExtensionBlock($name, $callable, 'Closed block');
$this->closedBlocks[$name] = $callable;
}
/**
* Add a closed block callable to allow <% name %> syntax
* @param string $name The name of the token to be used in the syntax <% name %>
* @param callable $callable The function that modifies the generation of template code
* @throws InvalidArgumentException
*/
public function addOpenBlock($name, $callable) {
$this->validateExtensionBlock($name, $callable, 'Open block');
$this->openBlocks[$name] = $callable;
}
/**
* Ensures that the arguments to addOpenBlock and addClosedBlock are valid
* @param $name
* @param $callable
* @param $type
* @throws InvalidArgumentException
*/
protected function validateExtensionBlock($name, $callable, $type) {
if (!is_string($name)) {
throw new InvalidArgumentException(
sprintf(
"Name argument for %s must be a string",
$type
)
);
} elseif (!is_callable($callable)) {
throw new InvalidArgumentException(
sprintf(
"Callable %s argument named '%s' is not callable",
$type,
$name
)
);
}
}
/* Template: (Comment | Translate | If | Require | CacheBlock | UncachedBlock | OldI18NTag | Include | ClosedBlock |
OpenBlock | MalformedBlock | Injection | Text)+ */
@ -3586,15 +3688,18 @@ class SSTemplateParser extends Parser implements TemplateParser {
$res['ArgumentCount'] = count($res['Arguments']);
}
}
function ClosedBlock__finalise(&$res) {
$blockname = $res['BlockName']['text'];
$method = 'ClosedBlock_Handle_'.$blockname;
if (method_exists($this, $method)) $res['php'] = $this->$method($res);
else {
if (method_exists($this, $method)) {
$res['php'] = $this->$method($res);
} else if (isset($this->closedBlocks[$blockname])) {
$res['php'] = call_user_func($this->closedBlocks[$blockname], $res);
} else {
throw new SSTemplateParseException('Unknown closed block "'.$blockname.'" encountered. Perhaps you are ' .
'not supposed to close this block, or have mis-spelled it?', $this);
'not supposed to close this block, or have mis-spelled it?', $this);
}
}
@ -3733,15 +3838,18 @@ class SSTemplateParser extends Parser implements TemplateParser {
$res['ArgumentCount'] = count($res['Arguments']);
}
}
function OpenBlock__finalise(&$res) {
$blockname = $res['BlockName']['text'];
$method = 'OpenBlock_Handle_'.$blockname;
if (method_exists($this, $method)) $res['php'] = $this->$method($res);
else {
if (method_exists($this, $method)) {
$res['php'] = $this->$method($res);
} elseif (isset($this->openBlocks[$blockname])) {
$res['php'] = call_user_func($this->openBlocks[$blockname], $res);
} else {
throw new SSTemplateParseException('Unknown open block "'.$blockname.'" encountered. Perhaps you missed ' .
' the closing tag or have mis-spelled it?', $this);
' the closing tag or have mis-spelled it?', $this);
}
}

View File

@ -95,9 +95,33 @@ class SSTemplateParser extends Parser implements TemplateParser {
protected $includeDebuggingComments = false;
/**
* Override the Parser constructor to change the requirement of setting a string
* Stores the user-supplied closed block extension rules in the form:
* array(
* 'name' => function (&$res) {}
* )
* See SSTemplateParser::ClosedBlock_Handle_Loop for an example of what the callable should look like
* @var array
*/
function __construct() {
protected $closedBlocks = array();
/**
* Stores the user-supplied open block extension rules in the form:
* array(
* 'name' => function (&$res) {}
* )
* See SSTemplateParser::OpenBlock_Handle_Base_tag for an example of what the callable should look like
* @var array
*/
protected $openBlocks = array();
/**
* Allow the injection of new closed & open block callables
* @param array $closedBlocks
* @param array $openBlocks
*/
public function __construct($closedBlocks = array(), $openBlocks = array()) {
$this->setClosedBlocks($closedBlocks);
$this->setOpenBlocks($openBlocks);
}
/**
@ -108,6 +132,84 @@ class SSTemplateParser extends Parser implements TemplateParser {
if (!isset($res['php'])) $res['php'] = '';
return $res;
}
/**
* Set the closed blocks that the template parser should use
*
* This method will delete any existing closed blocks, please use addClosedBlock if you don't
* want to overwrite
* @param array $closedBlocks
* @throws InvalidArgumentException
*/
public function setClosedBlocks($closedBlocks) {
$this->closedBlocks = array();
foreach ((array) $closedBlocks as $name => $callable) {
$this->addClosedBlock($name, $callable);
}
}
/**
* Set the open blocks that the template parser should use
*
* This method will delete any existing open blocks, please use addOpenBlock if you don't
* want to overwrite
* @param array $openBlocks
* @throws InvalidArgumentException
*/
public function setOpenBlocks($openBlocks) {
$this->openBlocks = array();
foreach ((array) $openBlocks as $name => $callable) {
$this->addOpenBlock($name, $callable);
}
}
/**
* Add a closed block callable to allow <% name %><% end_name %> syntax
* @param string $name The name of the token to be used in the syntax <% name %><% end_name %>
* @param callable $callable The function that modifies the generation of template code
* @throws InvalidArgumentException
*/
public function addClosedBlock($name, $callable) {
$this->validateExtensionBlock($name, $callable, 'Closed block');
$this->closedBlocks[$name] = $callable;
}
/**
* Add a closed block callable to allow <% name %> syntax
* @param string $name The name of the token to be used in the syntax <% name %>
* @param callable $callable The function that modifies the generation of template code
* @throws InvalidArgumentException
*/
public function addOpenBlock($name, $callable) {
$this->validateExtensionBlock($name, $callable, 'Open block');
$this->openBlocks[$name] = $callable;
}
/**
* Ensures that the arguments to addOpenBlock and addClosedBlock are valid
* @param $name
* @param $callable
* @param $type
* @throws InvalidArgumentException
*/
protected function validateExtensionBlock($name, $callable, $type) {
if (!is_string($name)) {
throw new InvalidArgumentException(
sprintf(
"Name argument for %s must be a string",
$type
)
);
} elseif (!is_callable($callable)) {
throw new InvalidArgumentException(
sprintf(
"Callable %s argument named '%s' is not callable",
$type,
$name
)
);
}
}
/*!* SSTemplateParser
@ -766,15 +868,18 @@ class SSTemplateParser extends Parser implements TemplateParser {
$res['ArgumentCount'] = count($res['Arguments']);
}
}
function ClosedBlock__finalise(&$res) {
$blockname = $res['BlockName']['text'];
$method = 'ClosedBlock_Handle_'.$blockname;
if (method_exists($this, $method)) $res['php'] = $this->$method($res);
else {
if (method_exists($this, $method)) {
$res['php'] = $this->$method($res);
} else if (isset($this->closedBlocks[$blockname])) {
$res['php'] = call_user_func($this->closedBlocks[$blockname], $res);
} else {
throw new SSTemplateParseException('Unknown closed block "'.$blockname.'" encountered. Perhaps you are ' .
'not supposed to close this block, or have mis-spelled it?', $this);
'not supposed to close this block, or have mis-spelled it?', $this);
}
}
@ -856,15 +961,18 @@ class SSTemplateParser extends Parser implements TemplateParser {
$res['ArgumentCount'] = count($res['Arguments']);
}
}
function OpenBlock__finalise(&$res) {
$blockname = $res['BlockName']['text'];
$method = 'OpenBlock_Handle_'.$blockname;
if (method_exists($this, $method)) $res['php'] = $this->$method($res);
else {
if (method_exists($this, $method)) {
$res['php'] = $this->$method($res);
} elseif (isset($this->openBlocks[$blockname])) {
$res['php'] = call_user_func($this->openBlocks[$blockname], $res);
} else {
throw new SSTemplateParseException('Unknown open block "'.$blockname.'" encountered. Perhaps you missed ' .
' the closing tag or have mis-spelled it?', $this);
' the closing tag or have mis-spelled it?', $this);
}
}