(merged from branches/roa. use "svn log -c <changeset> -g <module-svn-path>" for detailed commit message)

git-svn-id: svn://svn.silverstripe.com/silverstripe/open/modules/sapphire/trunk@60232 467b73ca-7a2a-4603-9d3b-597d59a354a9
This commit is contained in:
Ingo Schommer 2008-08-09 06:40:50 +00:00
parent 4ec93162a0
commit 75f2cf2654
24 changed files with 342 additions and 107 deletions

View File

@ -27,6 +27,15 @@ abstract class DataFormatter extends Object {
*/
public $relationDepth = 1;
/**
* Allows overriding of the fields which are rendered for the
* processed dataobjects. By default, this includes all
* fields in {@link DataObject::inheritedDatabaseFields()}.
*
* @var array
*/
protected $customFields = null;
/**
* Get a DataFormatter object suitable for handling the given file extension
*/
@ -46,6 +55,50 @@ abstract class DataFormatter extends Object {
}
}
/**
* @param array $fields
*/
public function setCustomFields($fields) {
$this->customFields = $fields;
}
/**
* @return array
*/
public function getCustomFields() {
return $this->customFields;
}
/**
* Returns all fields on the object which should be shown
* in the output. Can be customised through {@link self::setCustomFields()}.
*
* @todo Allow for custom getters on the processed object (currently filtered through inheritedDatabaseFields)
* @todo Field level permission checks
*
* @param DataObject $obj
* @return array
*/
protected function getFieldsForObj($obj) {
$dbFields = array();
// if custom fields are specified, only select these
if($this->customFields) {
foreach($this->customFields as $fieldName) {
// @todo Possible security risk by making methods accessible - implement field-level security
if($obj->hasField($fieldName) || $obj->hasMethod("get{$fieldName}")) $dbFields[$fieldName] = $fieldName;
}
} else {
// by default, all database fields are selected
$dbFields = $obj->inheritedDatabaseFields();
}
// add default required fields
$dbFields = array_merge($dbFields, array('ID'=>'Int'));
return $dbFields;
}
/**
* Return an array of the extensions that this data formatter supports
*/
@ -54,13 +107,11 @@ abstract class DataFormatter extends Object {
/**
* Convert a single data object to this format. Return a string.
* @todo Add parameters for things like selecting output columns
*/
abstract function convertDataObject(DataObjectInterface $do);
/**
* Convert a data object set to this format. Return a string.
* @todo Add parameters for things like selecting output columns
*/
abstract function convertDataObjectSet(DataObjectSet $set);

View File

@ -22,8 +22,7 @@ class JSONDataFormatter extends DataFormatter {
$id = $obj->ID;
$json = "{\n className : \"$className\",\n";
$dbFields = array_merge($obj->inheritedDatabaseFields(), array('ID'=>'Int'));
foreach($dbFields as $fieldName => $fieldType) {
foreach($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
if(is_object($obj->$fieldName)) {
$jsonParts[] = "$fieldName : " . $obj->$fieldName->toJSON();
} else {

View File

@ -23,11 +23,19 @@
* - DELETE /api/v1/(ClassName)/(ID)/(Relation)/(ForeignID) - remove the relationship between two database records, but don't actually delete the foreign object (NOT IMPLEMENTED YET)
*
* - POST /api/v1/(ClassName)/(ID)/(MethodName) - executes a method on the given object (e.g, publish)
*
* @todo Finish RestfulServer_Item and RestfulServer_List implementation and re-enable $url_handlers
*
* @package sapphire
* @subpackage api
* You can trigger searches based on the fields specified on {@link DataObject::searchable_fields} and passed
* through {@link DataObject::getDefaultSearchContext()}. Just add a key-value pair with the search-term
* to the url, e.g. /api/v1/(ClassName)/?Title=mytitle
*
* Other url-modifiers:
* - &limit=<numeric>: Limit the result set
* - &relationdepth=<numeric>: Displays links to existing has-one and has-many relationships to a certain depth (Default: 1)
* - &fields=<string>: Comma-separated list of fields on the output object (defaults to all database-columns)
*
* @todo Finish RestfulServer_Item and RestfulServer_List implementation and re-enable $url_handlers
* @todo Make SearchContext specification customizeable for each class
* @todo Allow for range-searches (e.g. on Created column)
*/
class RestfulServer extends Controller {
static $url_handlers = array(
@ -74,6 +82,9 @@ class RestfulServer extends Controller {
if(!$extension) $extension = "xml";
$formatter = DataFormatter::for_extension($extension); //$this->dataFormatterFromMime($contentType);
if($customFields = $this->request->getVar('fields')) $formatter->setCustomFields(explode(',',$customFields));
$relationDepth = $this->request->getVar('relationdepth');
if(is_numeric($relationDepth)) $formatter->relationDepth = (int)$relationDepth;
switch($requestMethod) {
case 'GET':
@ -169,7 +180,7 @@ class RestfulServer extends Controller {
// show empty serialized result when no records are present
if(!$obj) $obj = new DataObjectSet();
}
if($obj instanceof DataObjectSet) return $formatter->convertDataObjectSet($obj);
else return $formatter->convertDataObject($obj);
}

View File

@ -29,8 +29,7 @@ class XMLDataFormatter extends DataFormatter {
$objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID");
$json = "<$className href=\"$objHref.xml\">\n";
$dbFields = array_merge($obj->inheritedDatabaseFields(), array('ID'=>'Int'));
foreach($dbFields as $fieldName => $fieldType) {
foreach($this->getFieldsForObj($obj) as $fieldName => $fieldType) {
if(is_object($obj->$fieldName)) {
$json .= $obj->$fieldName->toXML();
} else {

View File

@ -1261,7 +1261,7 @@ class DataObject extends ViewableData implements DataObjectInterface {
// We need to find the inverse component name
$otherManyMany = singleton($candidate)->stat('many_many');
if(!$otherManyMany) {
Debug::message("Inverse component of $candidate not found");
user_error("Inverse component of $candidate not found ({$this->class})", E_USER_ERROR);
}
foreach($otherManyMany as $inverseComponentName => $candidateClass) {
@ -1641,6 +1641,40 @@ class DataObject extends ViewableData implements DataObjectInterface {
}
}
/**
* @param Member $member
* @return boolean
*/
public function canView($member = null) {
return true;
}
/**
* @param Member $member
* @return boolean
*/
public function canEdit($member = null) {
return true;
}
/**
* @param Member $member
* @return boolean
*/
public function canDelete($member = null) {
return true;
}
/**
* @todo Should canCreate be a static method?
*
* @param Member $member
* @return boolean
*/
public function canCreate($member = null) {
return true;
}
/**
* Debugging used by Debug::show()
*

View File

@ -612,6 +612,7 @@ class SiteTree extends DataObject {
* It can be overloaded to customise the security model for an
* application.
*
* @param Member $member
* @return boolean True if the current user can delete this page.
*/
public function canDelete($member = null) {
@ -637,6 +638,7 @@ class SiteTree extends DataObject {
* It can be overloaded to customise the security model for an
* application.
*
* @param Member $member
* @return boolean True if the current user can create pages on this
* class.
*/
@ -663,6 +665,7 @@ class SiteTree extends DataObject {
* It can be overloaded to customise the security model for an
* application.
*
* @param Member $member
* @return boolean True if the current user can edit this page.
*/
public function canEdit($member = null) {
@ -693,6 +696,7 @@ class SiteTree extends DataObject {
* It can be overloaded to customise the security model for an
* application.
*
* @param Member $member
* @return boolean True if the current user can publish this page.
*/
public function canPublish($member = null) {

View File

@ -0,0 +1,17 @@
<?php
/**
* @package sapphire
* @subpackage model
*/
/**
* Represents a US zip code.
*
* Can either be 5 or 9 digits.
*/
class USZipcode extends Varchar {
}
?>

View File

@ -107,10 +107,11 @@ class Debug {
* @param mixed $val
*/
static function dump($val) {
echo '<pre style="background-color:#ccc;padding:5px;">';
echo '<pre style="background-color:#ccc;padding:5px;font-size:14px;line-height:18px;">';
$caller = Debug::caller();
echo "<span style=\"font-size: 60%\">Line $caller[line] of " . basename($caller['file']) . "</span>\n";
print_r($val);
echo "<span style=\"font-size: 12px;color:#666;\">Line $caller[line] of " . basename($caller['file']) . ":</span>\n";
if (is_string($val)) print_r(wordwrap($val, 100));
else print_r($val);
echo '</pre>';
}
@ -260,7 +261,7 @@ class Debug {
echo "ERROR:Error $errno: $errstr\n At l$errline in $errfile\n";
Debug::backtrace();
} else {
$reporter = new DebugReporter();
$reporter = new SapphireDebugReporter();
$reporter->writeHeader();
echo '<div class="info">';
echo "<h1>" . strip_tags($errstr) . "</h1>";
@ -588,8 +589,9 @@ class SapphireDebugReporter implements DebugReporter {
echo 'pre { margin-left:18px; }';
echo 'pre span { color:#999;}';
echo 'pre .error { color:#f00; }';
echo '.pass { padding:2px 20px 2px 40px; color:#006600; background:#E2F9E3 url('.Director::absoluteBaseURL() .'cms/images/alert-good.gif) no-repeat scroll 7px 50%; border:1px solid #8DD38D; }';
echo '.fail { padding:2px 20px 2px 40px; color:#C80700; background:#FFE9E9 url('.Director::absoluteBaseURL() .'cms/images/alert-bad.gif) no-repeat scroll 7px 50%; }';
echo '.pass { margin-top:18px; padding:2px 20px 2px 40px; color:#006600; background:#E2F9E3 url('.Director::absoluteBaseURL() .'cms/images/alert-good.gif) no-repeat scroll 7px 50%; border:1px solid #8DD38D; }';
echo '.fail { margin-top:18px; padding:2px 20px 2px 40px; color:#C80700; background:#FFE9E9 url('.Director::absoluteBaseURL() .'cms/images/alert-bad.gif) no-repeat scroll 7px 50%; border:1px solid #C80700; }';
echo '.failure span { color:#C80700; font-weight:bold; }';
echo '</style></head>';
echo '<body>';
echo '<div class="header"><img src="'. Director::absoluteBaseURL() .'cms/images/mainmenu/logo.gif" width="26" height="23"></div>';

View File

@ -17,7 +17,7 @@ class DevelopmentAdmin extends Controller {
);
function index() {
$renderer = new DebugView();
$renderer = new SapphireDebugReporter();
$renderer->writeHeader();
echo <<<HTML
<div class="info"><h1>Sapphire Development Tools</h1></div>

View File

@ -50,6 +50,10 @@ class TestRunner extends Controller {
if (!self::$default_reporter) self::set_reporter('SapphireDebugReporter');
}
public function Link() {
return Controller::join_links(Director::absoluteBaseURL(), 'dev/tests/');
}
/**
* Run all test classes
*/
@ -69,10 +73,19 @@ class TestRunner extends Controller {
* Browse all enabled test cases in the environment
*/
function browse() {
self::$default_reporter->writeHeader();
echo '<div class="info">';
echo '<h1>Available Tests</h1>';
echo '</div>';
echo '<div class="trace">';
$tests = ClassInfo::subclassesFor('SapphireTest');
echo "<h3><a href=\"" . $this->Link() . "all\">Run all " . count($tests) . " tests</a></h3>";
echo "<br />";
foreach ($tests as $test) {
echo "<h3><a href=\"$test\">$test</a></h3>";
echo "<h3><a href=\"" . $this->Link() . "$test\">Run $test</a></h3>";
}
echo '</div>';
self::$default_reporter->writeFooter();
}
function coverage() {
@ -109,7 +122,6 @@ class TestRunner extends Controller {
echo "<p>Running test cases: " . implode(", ", $classList) . "</p>";
} else {
echo "<h1>{$classList[0]}</h1>";
echo "<p>Running test case:</p>";
}
echo "</div>";
echo '<div class="trace">';

View File

@ -743,35 +743,6 @@ class TableField_Item extends TableListField_Item {
return $content;
}
function Can($mode) {
return $this->parent->Can($mode);
}
function Parent() {
return $this->parent;
}
/**
* Create the base link for the call below.
*/
function BaseLink() {
$parent = $this->parent;
$action = $parent->FormAction();
if(substr($action, -1, 1) !== '&'){
$action = $action."&";
}
$action = str_replace('&', '&amp;', $action);
return $action . "action_callfieldmethod=1&amp;fieldName=". $parent->Name() . "&amp;childID=" . $this->ID;
}
/**
* Runs the delete() method on the Tablefield parent.
* Allows the deletion of objects via ajax
*/
function DeleteLink() {
return $this->BaseLink() . "&amp;methodName=delete";
}
}
?>

View File

@ -166,8 +166,6 @@ class SearchContext extends Object {
$searchParams = array_filter($searchParams, array($this,'clearEmptySearchFields'));
$query = $this->getQuery($searchParams, $sort, $limit);
//Debug::dump($query->sql());
// use if a raw SQL query is needed
$results = new DataObjectSet();
foreach($query->execute() as $row) {
@ -198,7 +196,7 @@ class SearchContext extends Object {
* @param SQLQuery $query
*/
protected function processFilters(SQLQuery $query, $searchParams) {
$conditions = array();
/*$conditions = array();
foreach($this->filters as $field => $filter) {
if (strstr($field, '.')) {
$path = explode('.', $field);
@ -207,7 +205,7 @@ class SearchContext extends Object {
}
}
$query->where = $conditions;
return $query;
return $query;*/
}
/**
@ -255,31 +253,5 @@ class SearchContext extends Object {
$this->fields = $fields;
}
/**
* Placeholder, until I figure out the rest of the SQLQuery stuff
* and link the $searchable_fields array to the SearchContext
*
* @deprecated in favor of getResults
*/
public function getResultSet($fields) {
$filter = "";
$current = 1;
$fields = array_filter($fields, array($this,'clearEmptySearchFields'));
$length = count($fields);
foreach($fields as $key=>$val) {
// Array values come from more complex fields - for now let's just disable searching on them
if (!is_array($val) && $val != '') {
$filter .= "`$key`='$val'";
} else {
$length--;
}
if ($current < $length) {
$filter .= " AND ";
}
$current++;
}
return DataObject::get($this->modelClass, $filter);
}
}
?>

View File

@ -1,17 +1,20 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Checks if a value is in a given set.
* SQL syntax used: Column IN ('val1','val2')
*
* @todo Add negation (NOT IN)6
*
* @author Silverstripe Ltd., Ingo Schommer (<firstname>@silverstripe.com)
*/
class CollectionFilter extends SearchFilter {
public function apply(SQLQuery $query) {
$query = $this->applyRelation($query);
$values = explode(',',$this->value);
$values = explode(',',$this->getValue());
if(!$values) return false;
for($i=0; $i<count($values); $i++) {

View File

@ -0,0 +1,32 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Matches textual content with a substring match on a text fragment leading
* to the end of the string.
*
* <code>
* "abcdefg" => "defg" # true
* "abcdefg" => "abcd" # false
* </code>
*
* @package sapphire
* @subpackage search
*/
class EndsWithFilter extends SearchFilter {
/**
* Applies a match on the trailing characters of a field value.
*
* @return unknown
*/
public function apply(SQLQuery $query) {
$query = $this->applyRelation($query);
$query->where($this->getName(), "RLIKE", "{$this->getValue()}$");
}
}
?>

View File

@ -1,6 +1,11 @@
<?php
/**
* Matches textual content with a columnname = 'keyword' construct
* @package sapphire
* @subpackage search
*/
/**
* Selects textual content with an exact match between columnname and keyword.
*
* @todo case sensitivity switch
* @todo documentation
@ -17,7 +22,7 @@ class ExactMatchFilter extends SearchFilter {
*/
public function apply(SQLQuery $query) {
$query = $this->applyRelation($query);
return $query->where("{$this->getName()} = '{$this->value}'");
return $query->where("{$this->getName()} = '{$this->getValue()}'");
}
}

View File

@ -1,4 +1,9 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Filters by full-text matching on the given field.
*
@ -23,7 +28,8 @@
class FulltextFilter extends SearchFilter {
public function apply(SQLQuery $query) {
return "";
$query->where("MATCH ({$this->getName()} AGAINST ('{$this->getValue()}')");
return $query;
}
}

View File

@ -13,7 +13,7 @@
class NegationFilter extends SearchFilter {
public function apply(SQLQuery $query) {
return $query->where("{$this->name} != '{$this->value}'");
return $query->where("{$this->name} != '{$this->getValue()}'");
}
}

View File

@ -1,4 +1,9 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Matches textual content with a LIKE '%keyword%' construct.
*
@ -9,7 +14,7 @@ class PartialMatchFilter extends SearchFilter {
public function apply(SQLQuery $query) {
$query = $this->applyRelation($query);
return $query->where("{$this->getName()} LIKE '%{$this->value}%'");
return $query->where("{$this->getName()} LIKE '%{$this->getValue()}%'");
}
}

View File

@ -1,4 +1,9 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* @todo documentation
*
@ -17,12 +22,48 @@ abstract class SearchFilter extends Object {
$this->value = $value;
}
/**
* Called by constructor to convert a string pathname into
* a well defined relationship sequence.
*
* @param unknown_type $name
*/
protected function addRelation($name) {
if (strstr($name, '.')) {
$parts = explode('.', $name);
$this->name = array_pop($parts);
$this->relation = $parts;
} else {
$this->name = $name;
}
}
/**
* Set the root model class to be selected by this
* search query.
*
* @param string $className
*/
public function setModel($className) {
$this->model = $className;
}
/**
* Set the current value to be filtered on.
*
* @param string $value
*/
public function setValue($value) {
$this->value = $value;
}
public function setModel($className) {
$this->model = $className;
/**
* Accessor for the current value to be filtered on.
*
* @return string
*/
public function getValue() {
return $this->value;
}
/**
@ -42,16 +83,6 @@ abstract class SearchFilter extends Object {
return $candidateClass . "." . $this->name;
}
protected function addRelation($name) {
if (strstr($name, '.')) {
$parts = explode('.', $name);
$this->name = array_pop($parts);
$this->relation = $parts;
} else {
$this->name = $name;
}
}
/**
* Applies multiple-table inheritance to straight joins on the data objects
*
@ -66,11 +97,10 @@ abstract class SearchFilter extends Object {
/**
* Traverse the relationship fields, and add the table
* mappings to the query object state.
*
* @todo move join specific crap into SQLQuery
*
* @param unknown_type $query
* @return unknown
* @todo try to make this implicitly triggered so it doesn't have to be manually called in child filters
* @param SQLQuery $query
* @return SQLQuery
*/
protected function applyRelation($query) {
if (is_array($this->relation)) {
@ -84,6 +114,8 @@ abstract class SearchFilter extends Object {
$model = singleton($component);
$this->applyJoin($query, $model, $component);
$this->model = $component;
} elseif ($component = $model->many_many($rel)) {
Debug::dump("Many-Many traversals not implemented");
}
}
}
@ -94,6 +126,7 @@ abstract class SearchFilter extends Object {
* Apply filter criteria to a SQL query.
*
* @param SQLQuery $query
* @return SQLQuery
*/
abstract public function apply(SQLQuery $query);

View File

@ -0,0 +1,32 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Matches textual content with a substring match from the beginning
* of the string.
*
* <code>
* "abcdefg" => "defg" # false
* "abcdefg" => "abcd" # true
* </code>
*
* @package sapphire
* @subpackage search
*/
class StartsWithFilter extends SearchFilter {
/**
* Applies a substring match on a field value.
*
* @return unknown
*/
public function apply(SQLQuery $query) {
$query = $this->applyRelation($query);
$query->where("LOCATE('{$this->getValue()}', {$this->getName()}) = 1");
}
}
?>

View File

@ -1,4 +1,9 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Uses a substring match against content in column rows.
*
@ -8,7 +13,7 @@
class SubstringFilter extends SearchFilter {
public function apply(SQLQuery $query) {
return $query->where("LOCATE({$this->name}, $value)");
return $query->where("LOCATE('{$this->getValue()}', {$this->getName()}) != 0");
}
}

View File

@ -0,0 +1,34 @@
<?php
/**
* @package sapphire
* @subpackage search
*/
/**
* Incomplete.
*
* @todo add to tests
*
* @package sapphire
* @subpackage search
*/
class WithinRangeFilter extends SearchFilter {
private $min;
private $max;
function setMin($min) {
$this->min = $min;
}
function setMax($max) {
$this->max = $max;
}
function apply(SQLQuery $query) {
$query->where("{$this->getName()} >= {$this->min} AND {$this->getName()} <= {$this->max}");
}
}
?>

View File

@ -101,6 +101,8 @@ class SearchContextTest extends SapphireTest {
"PartialMatch" => "partially",
"Negation" => "undisclosed",
"CollectionMatch" => "ExistingCollectionValue,NonExistingCollectionValue,4,Inline'Quotes'",
"StartsWith" => "12345",
"EndsWith" => "ijkl"
);
$results = $context->getResults($params);
@ -209,8 +211,10 @@ class SearchContextTest_AllFilterTypes extends DataObject implements TestOnly {
"PartialMatch" => "Text",
"Negation" => "Text",
"SubstringMatch" => "Text",
"HiddenValue" => "Text",
"CollectionMatch" => "Text",
"StartsWith" => "Text",
"EndsWith" => "Text",
"HiddenValue" => "Text"
);
static $searchable_fields = array(
@ -219,6 +223,8 @@ class SearchContextTest_AllFilterTypes extends DataObject implements TestOnly {
"Negation" => "NegationFilter",
"SubstringMatch" => "SubstringFilter",
"CollectionMatch" => "CollectionFilter",
"StartsWith" => "StartsWithFilter",
"EndsWith" => "EndsWithFilter"
);
}

View File

@ -62,4 +62,6 @@ SearchContextTest_AllFilterTypes:
PartialMatch: Match me partially
Negation: Shouldnt match me
HiddenValue: Filtered value
CollectionMatch: ExistingCollectionValue
CollectionMatch: ExistingCollectionValue
StartsWith: 12345-6789
EndsWith: abcd-efgh-ijkl