(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@60208 467b73ca-7a2a-4603-9d3b-597d59a354a9
This commit is contained in:
Ingo Schommer 2008-08-09 04:06:52 +00:00
parent b5776e0438
commit a599df309c
18 changed files with 493 additions and 98 deletions

View File

@ -28,6 +28,7 @@ Director::addRules(10, array(
'' => 'RootURLController',
'sitemap.xml' => 'GoogleSitemap',
'api/v1' => 'RestfulServer',
'dev' => 'DevelopmentAdmin'
));
Director::addRules(1, array(

View File

@ -169,7 +169,7 @@ class RestfulServer extends Controller {
* @param $includeHeader Include <?xml ...?> header (Default: true)
* @return String XML
*/
protected function dataObjectAsXML(DataObject $obj, $includeHeader = true) {
public function dataObjectAsXML(DataObject $obj, $includeHeader = true) {
$className = $obj->class;
$id = $obj->ID;
$objHref = Director::absoluteURL(self::$api_base . "$obj->class/$obj->ID");
@ -177,7 +177,8 @@ class RestfulServer extends Controller {
$json = "";
if($includeHeader) $json .= "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
$json .= "<$className href=\"$objHref.xml\">\n";
foreach($obj->db() as $fieldName => $fieldType) {
$dbFields = array_merge($obj->databaseFields(), array('ID'=>'Int'));
foreach($dbFields as $fieldName => $fieldType) {
if(is_object($obj->$fieldName)) {
$json .= $obj->$fieldName->toXML();
} else {
@ -228,7 +229,7 @@ class RestfulServer extends Controller {
* @param DataObjectSet $set
* @return String XML
*/
protected function dataObjectSetAsXML(DataObjectSet $set) {
public function dataObjectSetAsXML(DataObjectSet $set) {
$className = $set->class;
$xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<$className>\n";
@ -248,12 +249,13 @@ class RestfulServer extends Controller {
* @param DataObject $obj
* @return String JSON
*/
protected function dataObjectAsJSON(DataObject $obj) {
public function dataObjectAsJSON(DataObject $obj) {
$className = $obj->class;
$id = $obj->ID;
$json = "{\n className : \"$className\",\n";
foreach($obj->db() as $fieldName => $fieldType) {
$dbFields = array_merge($obj->databaseFields(), array('ID'=>'Int'));
foreach($dbFields as $fieldName => $fieldType) {
if(is_object($obj->$fieldName)) {
$jsonParts[] = "$fieldName : " . $obj->$fieldName->toJSON();
} else {
@ -302,7 +304,7 @@ class RestfulServer extends Controller {
* @param DataObjectSet $set
* @return String JSON
*/
protected function dataObjectSetAsJSON(DataObjectSet $set) {
public function dataObjectSetAsJSON(DataObjectSet $set) {
$jsonParts = array();
foreach($set as $item) {
if($item->canView()) $jsonParts[] = $this->dataObjectAsJSON($item);

View File

@ -1158,33 +1158,31 @@ class DataObject extends ViewableData implements DataObjectInterface {
* @return SearchContext
*/
public function getDefaultSearchContext() {
$c = new SearchContext($this->class);
return $c;
return new SearchContext($this->class, $this->searchable_fields(), $this->defaultSearchFilters());
}
/**
* Determine which properties on the DataObject are
* searchable, and map them to their default {@link FormField}
* representations. Useful for scaffolding a searchform for {@link ModelAdmin}.
* representations. Used for scaffolding a searchform for {@link ModelAdmin}.
*
* @usedby {@link SearchContext}
* @return FieldSet
*/
public function scaffoldSearchFields() {
$fields = new FieldSet();
foreach($this->searchableFields() as $fieldName => $fieldType) {
foreach($this->searchable_fields() as $fieldName => $fieldType) {
// @todo Pass localized title
$fields->push($this->dbObject($fieldName)->scaffoldSearchField());
}
$extras = $this->invokeWithExtensions('extraSearchFields');
}
/*$extras = $this->invokeWithExtensions('extraSearchFields');
if ($extras) {
foreach($extras as $result) {
foreach($result as $fieldName => $fieldType) {
$fields->push(new $fieldType($fieldName));
}
}
}
}*/
return $fields;
}
@ -1203,6 +1201,13 @@ class DataObject extends ViewableData implements DataObjectInterface {
// commented out, to be less of a pain in the ass
//$fields->addFieldToTab('Root.Main', $this->dbObject($fieldName)->scaffoldFormField());
$fields->push($this->dbObject($fieldName)->scaffoldFormField());
}
foreach($this->has_one() as $relationship => $component) {
$model = singleton($component);
$records = DataObject::get($component);
$collect = ($model->hasMethod('customSelectOption')) ? 'customSelectOption' : current($model->summary_fields());
$options = $records->filter_map('ID', $collect);
$fields->push(new DropdownField($relationship.'ID', $relationship, $options));
}
return $fields;
}
@ -1212,13 +1217,11 @@ class DataObject extends ViewableData implements DataObjectInterface {
*/
protected function addScaffoldRelationFields($fieldSet) {
foreach($this->has_many() as $relationship => $component) {
$relationshipFields = array_keys($this->searchableFields());
$relationshipFields = array_keys($this->searchable_fields());
$fieldSet->push(new ComplexTableField($this, $relationship, $component, $relationshipFields));
}
return $fieldSet;
}
/**
* Centerpiece of every data administration interface in Silverstripe,
@ -2059,7 +2062,7 @@ class DataObject extends ViewableData implements DataObjectInterface {
* but still needs to know the properties of its parent. This should be merged into databaseFields or
* customDatabaseFields.
*
* @todo integrate with pre-existing crap
* @todo review whether this is still needed after recent API changes
*/
public function inheritedDatabaseFields() {
$fields = array();
@ -2073,32 +2076,69 @@ class DataObject extends ViewableData implements DataObjectInterface {
/**
* Get the default searchable fields for this object,
* excluding any fields that are specifically overriden
* in the data object itself.
* as defined in the $searchable_fields list. If searchable
* fields are not defined on the data object, uses a default
* selection of summary fields.
*
* @todo rename $searchable to $excluded
* @todo overcomplicated, should be simpler way of looking up whether specific fields are supposed to be searchable or not
* @return array
*/
public function searchableFields() {
$parents = ClassInfo::dataClassesFor($this);
$fields = array();
$searchable = array();
foreach($parents as $class) {
$fields = array_merge($fields, singleton($class)->stat('db'));
$obj = singleton($class);
$results = $obj->invokeWithExtensions('excludeFromSearch');
if ($results) {
foreach($results as $result) {
if (is_array($result)) {
$searchable = array_merge($searchable, $result);
public function searchable_fields() {
$fields = $this->stat('searchable_fields');
if (!$fields) {
$fields = array_fill_keys($this->summary_fields(), 'TextField');
}
return $fields;
}
/**
* Get the default summary fields for this object.
*
* @todo use the translation apparatus to return a default field selection for the language
*
* @return array
*/
public function summary_fields() {
$fields = $this->stat('summary_fields');
if (!$fields) {
$fields = array();
if ($this->hasField('Name')) $fields[] = 'Name';
if ($this->hasField('Title')) $fields[] = 'Title';
if ($this->hasField('Description')) $fields[] = 'Description';
if ($this->hasField('Firstname')) $fields[] = 'Firstname';
}
return $fields;
}
/**
* Defines a default list of filters for the search context.
*
* If a filter class mapping is defined on the data object,
* it is constructed here. Otherwise, the default filter specified in
* {@link DBField} is used.
*
* @todo error handling/type checking for valid FormField and SearchFilter subclasses?
*
* @return array
*/
public function defaultSearchFilters() {
$filters = array();
foreach($this->searchable_fields() as $name => $type) {
if (is_int($name)) {
$filters[$type] = $this->dbObject($type)->defaultSearchFilter();
} else {
if (is_array($type)) {
$filter = current($type);
$filters[$name] = new $filter();
} else {
if (is_subclass_of($type, 'SearchFilter')) {
$filters[$name] = new $type($name);
} else {
$filters[$name] = $this->dbObject($name)->defaultSearchFilter();
}
}
}
}
foreach($searchable as $field) {
unset($fields[$field]);
}
return $fields;
return $filters;
}
/**
@ -2126,6 +2166,9 @@ class DataObject extends ViewableData implements DataObjectInterface {
return self::$context_obj;
}
/**
* @ignore
*/
protected static $context_obj = null;
@ -2231,6 +2274,44 @@ class DataObject extends ViewableData implements DataObjectInterface {
*/
public static $default_sort = null;
/**
* Default list of fields that can be scaffolded by the ModelAdmin
* search interface.
*
* Defining a basic set of searchable fields:
* <code>
* static $searchable_fields = array("Name", "Email");
* </code>
*
* Overriding the default form fields, with a custom defined field:
* <code>
* static $searchable_fields = array(
* "Name" => "TextField"
* );
* </code>
*
* Overriding the default filter, with a custom defined filter:
* <code>
* static $searchable_fields = array(
* "Name" => "PartialMatchFilter"
* );
* </code>
*
* Overriding the default form field and filter:
* <code>
* static $searchable_fields = array(
* "Name" => array("TextField" => "PartialMatchFilter")
* );
* </code>
*/
public static $searchable_fields = null;
/**
* Provides a default list of fields to be used by a 'summary'
* view of this object.
*/
public static $summary_fields = null;
}
?>

View File

@ -596,6 +596,21 @@ class DataObjectSet extends ViewableData implements IteratorAggregate {
}
return $map;
}
/**
* Temporary filter method for filtering a list based on multiple fields of the DataObject.
*
* Question: should any args be passed to the filter function?
*
* @todo deprecate toDropdownMap() and map_multiple(), rename this method to map()
*/
public function filter_map($key, $value) {
$map = array();
foreach($this->items as $object) {
$map[$object->$key] = ($object->hasMethod($value)) ? $object->$value() : $object->$value;
}
return $map;
}
/**
* Find an item in this list where the field $key is equal to $value

View File

@ -96,7 +96,7 @@ class ErrorPage extends Page {
// Run the page
Requirements::clear();
$controller = new ErrorPage_Controller($this);
$errorContent = $controller->run( array() )->getBody();
$errorContent = $controller->handleRequest(new HTTPRequest('GET',''))->getBody();
if(!file_exists("../assets")) {
mkdir("../assets", 02775);

View File

@ -60,6 +60,12 @@ class SQLQuery extends Object {
*/
public $delete;
/**
* The logical connective used to join WHERE clauses. Defaults to AND.
* @var string
*/
private $connective = 'AND';
/**
* Construct a new SQLQuery.
* @param array $select An array of fields to select.
@ -81,6 +87,20 @@ class SQLQuery extends Object {
parent::__construct();
}
/**
* Use the disjunctive operator 'OR' to join filter expressions in the WHERE clause.
*/
public function useDisjunction() {
$this->connective = 'OR';
}
/**
* Use the conjunctive operator 'AND' to join filter expressions in the WHERE clause.
*/
public function useConjunction() {
$this->connective = 'AND';
}
/**
* Swap the use of one table with another.
@ -121,6 +141,15 @@ class SQLQuery extends Object {
}
}
/**
* Return an SQL WHERE clause to filter a SELECT query.
*
* @return string
*/
function getFilter() {
return implode(") {$this->connective} (" , $this->where);
}
/**
* Generate the SQL statement for this query.
* @return string
@ -134,7 +163,7 @@ class SQLQuery extends Object {
}
$text .= " FROM " . implode(" ", $this->from);
if($this->where) $text .= " WHERE (" . implode(") AND (" , $this->where) . ")";
if($this->where) $text .= " WHERE (" . $this->getFilter(). ")";
if($this->groupby) $text .= " GROUP BY " . implode(", ", $this->groupby);
if($this->having) $text .= " HAVING ( " . implode(" ) AND ( ", $this->having) . " )";
if($this->orderby) $text .= " ORDER BY " . $this->orderby;

View File

@ -222,7 +222,7 @@ abstract class DBField extends ViewableData {
* @return SearchFilter
*/
public function defaultSearchFilter() {
return new ExactMatchSearchFilter();
return new ExactMatchFilter($this->name);
}
/**

View File

@ -12,7 +12,8 @@ class DevelopmentAdmin extends Controller {
static $url_handlers = array(
'' => 'index',
'$Action' => '$Action'
'$Action' => '$Action',
'$Action//$Action/$ID' => 'handleAction',
);
function index() {
@ -31,16 +32,14 @@ HTML;
$renderer->writeFooter();
}
function tests() {
if(isset($this->urlParams['NestedAction'])) {
Director::redirect("TestRunner/only/" . $this->urlParams['NestedAction']);
} else {
Director::redirect("TestRunner/");
}
function tests($request) {
$controller = new TestRunner();
return $controller->handleRequest($request);
}
function tasks($request) {
return new TaskRunner();
function tasks() {
$controller = new TaskRunner();
return $controller->handleRequest($request);
}
}

View File

@ -30,6 +30,11 @@ class TestRunner extends Controller {
/** @ignore */
private static $default_reporter;
static $url_handlers = array(
'' => 'index',
'$TestCase' => 'only'
);
/**
* Override the default reporter with a custom configured subclass.
*
@ -76,15 +81,13 @@ class TestRunner extends Controller {
/**
* Run only a single test class
*/
function only() {
$className = $this->urlParams['ID'];
function only($request) {
$className = $request->param('TestCase');
if(class_exists($className)) {
$this->runTests(array($className));
} else {
echo "Class '$className' not found";
}
}
function runTests($classList, $coverage = false) {

View File

@ -56,17 +56,7 @@ class SearchContext extends Object {
* @var array
protected $params;
*/
/**
* Require either all search filters to be evaluated to true,
* or just a single one.
*
* @todo Not Implemented
* @var string
*/
protected $booleanSearchType = 'AND';
function __construct($modelClass, $fields = null, $filters = null) {
$this->modelClass = $modelClass;
$this->fields = $fields;
@ -74,7 +64,7 @@ class SearchContext extends Object {
parent::__construct();
}
/**
* Returns scaffolded search fields for UI.
*
@ -82,43 +72,53 @@ class SearchContext extends Object {
* @return FieldSet
*/
public function getSearchFields() {
return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
// $this->fields is causing weirdness, so we ignore for now, using the default scaffolding
//return ($this->fields) ? $this->fields : singleton($this->modelClass)->scaffoldSearchFields();
return singleton($this->modelClass)->scaffoldSearchFields();
}
/**
* Get the query object augumented with all clauses from
* the connected {@link SearchFilter}s
*
* @todo query generation
* Returns a SQL object representing the search context for the given
* list of query parameters.
*
* @param array $searchParams
* @return SQLQuery
*/
public function getQuery($searchParams) {
$q = new SQLQuery("*", $this->modelClass);
$this->processFilters($q);
return $q;
public function getQuery($searchParams, $start = false, $limit = false) {
$model = singleton($this->modelClass);
$fields = array_keys($model->db());
$query = new SQLQuery($fields, $this->modelClass);
foreach($searchParams as $key => $value) {
$filter = $this->getFilter($key);
if ($filter) {
$query->where[] = $filter->apply($value);
}
}
return $query;
}
/**
* Light wrapper around {@link getQuery()}.
* Returns a result set from the given search parameters.
*
* @todo rearrange start and limit params to reflect DataObject
*
* @param array $searchParams
* @param int $start
* @param int $limit
* @return DataObjectSet
*/
public function getResults($searchParams, $start = false, $limit = false) {
$q = $this->getQuery($searchParams);
//$q->limit = $start ? "$start, $limit" : $limit;
$output = new DataObjectSet();
foreach($q->execute() as $row) {
$className = $row['RecordClassName'];
$output->push(new $className($row));
}
// do the setting of start/limit on the dataobjectset
return $output;
$query = $this->getQuery($searchParams, $start, $limit);
//
// use if a raw SQL query is needed
//$results = new DataObjectSet();
//foreach($query->execute() as $row) {
// $className = $row['ClassName'];
// $results->push(new $className($row));
//}
//return $results;
//
return DataObject::get($this->modelClass, $query->getFilter(), "", "", $limit);
}
/**
@ -128,14 +128,25 @@ class SearchContext extends Object {
* @param array $searchFilters
* @param SQLQuery $query
*/
protected function processFilters($searchFilters, SQLQuery &$query) {
foreach($this->filters as $filter) {
$filter->updateQuery($searchFilters, $tableName, $query);
protected function processFilters(SQLQuery $query, $searchParams) {
$conditions = array();
foreach($this->filters as $field => $filter) {
if (strstr($field, '.')) {
$path = explode('.', $field);
} else {
$conditions[] = $filter->apply($searchParams[$field]);
}
}
$query->where = $conditions;
}
// ############ Getters/Setters ###########
public function getFilter($name) {
if (isset($this->filters[$name])) {
return $this->filters[$name];
} else {
return null;
}
}
public function getFields() {
return $this->fields;
@ -146,7 +157,7 @@ class SearchContext extends Object {
}
public function getFilters() {
return $this->fields;
return $this->filters;
}
public function setFilters($filters) {

View File

@ -10,5 +10,14 @@
*/
class ExactMatchFilter extends SearchFilter {
/**
* Applies an exact match (equals) on a field value.
*
* @return unknown
*/
public function apply($value) {
return "{$this->name}='$value'";
}
}
?>

View File

@ -6,6 +6,10 @@
* @subpackage search
*/
class FulltextFilter extends SearchFilter {
public function apply($value) {
return "";
}
}
?>

View File

@ -0,0 +1,21 @@
<?php
/**
* @package search
* @subpackage filters
*/
/**
* Matches on rows where the field is not equal to the given value.
*
* @package search
* @subpackage filters
*/
class NegationFilter extends SearchFilter {
public function apply($value) {
return "{$this->name} != '$value'";
}
}
?>

View File

@ -7,5 +7,9 @@
*/
class PartialMatchFilter extends SearchFilter {
public function apply($value) {
return "{$this->name} LIKE '%$value%'";
}
}
?>

View File

@ -7,5 +7,13 @@
*/
abstract class SearchFilter extends Object {
protected $name;
function __construct($name) {
$this->name = $name;
}
abstract public function apply($value);
}
?>

View File

@ -6,7 +6,10 @@
* @subpackage search
*/
class SubstringMatchFilter extends SearchFilter {
public function apply($value) {
return "";
}
}

View File

@ -4,7 +4,7 @@ class SearchContextTest extends SapphireTest {
static $fixture_file = 'sapphire/tests/SearchContextTest.yml';
function testResultSetFilterReturnsExpectedCount() {
$person = singleton('PersonBubble');
$person = singleton('SearchContextTest_Person');
$context = $person->getDefaultSearchContext();
$results = $context->getResultSet(array('Name'=>''));
@ -15,13 +15,90 @@ class SearchContextTest extends SapphireTest {
$results = $context->getResultSet(array('EyeColor'=>'green', 'HairColor'=>'black'));
$this->assertEquals(1, $results->Count());
}
//function
function testSummaryIncludesDefaultFieldsIfNotDefined() {
$person = singleton('SearchContextTest_Person');
$this->assertContains('Name', $person->summary_fields());
$book = singleton('SearchContextTest_Book');
$this->assertContains('Title', $book->summary_fields());
}
function testAccessDefinedSummaryFields() {
$company = singleton('SearchContextTest_Company');
$this->assertContains('Industry', $company->summary_fields());
}
function testExactMatchUsedByDefaultWhenNotExplicitlySet() {
$person = singleton('SearchContextTest_Person');
$context = $person->getDefaultSearchContext();
$this->assertEquals(
array(
"Name" => new ExactMatchFilter("Name"),
"HairColor" => new ExactMatchFilter("HairColor"),
"EyeColor" => new ExactMatchFilter("EyeColor")
),
$context->getFilters()
);
}
function testDefaultFiltersDefinedWhenNotSetInDataObject() {
$book = singleton('SearchContextTest_Book');
$context = $book->getDefaultSearchContext();
$this->assertEquals(
array(
"Title" => new ExactMatchFilter("Title")
),
$context->getFilters()
);
}
function testUserDefinedFiltersAppearInSearchContext() {
//$company = singleton('SearchContextTest_Company');
//$context = $company->getDefaultSearchContext();
/*$this->assertEquals(
array(
"Name" => new PartialMatchFilter("Name"),
"Industry" => new ExactMatchFilter("Industry"),
"AnnualProfit" => new PartialMatchFilter("AnnualProfit")
),
$context->getFilters()
);*/
}
function testRelationshipObjectsLinkedInSearch() {
//$project = singleton('SearchContextTest_Project');
//$context = $project->getDefaultSearchContext();
//$query = array("Name"=>"Blog Website");
//$results = $context->getQuery($query);
}
function testCanGenerateQueryUsingAllFilterTypes() {
$all = singleton("SearchContextTest_AllFilterTypes");
$context = $all->getDefaultSearchContext();
$params = array(
"ExactMatch" => "Match Me Exactly",
"PartialMatch" => "partially",
"Negation" => "undisclosed"
);
$results = $context->getResults($params);
$this->assertEquals(1, $results->Count());
$this->assertEquals("Filtered value", $results->First()->HiddenValue);
}
}
class PersonBubble extends DataObject {
class SearchContextTest_Person extends DataObject implements TestOnly {
static $db = array(
"Name" => "Text",
@ -30,6 +107,102 @@ class PersonBubble extends DataObject {
"EyeColor" => "Text"
);
static $searchable_fields = array(
"Name", "HairColor", "EyeColor"
);
}
class SearchContextTest_Book extends DataObject implements TestOnly {
static $db = array(
"Title" => "Text",
"Summary" => "Text"
);
}
class SearchContextTest_Company extends DataObject implements TestOnly {
static $db = array(
"Name" => "Text",
"Industry" => "Text",
"AnnualProfit" => "Int"
);
static $summary_fields = array(
"Industry"
);
static $searchable_fields = array(
"Name" => "PartialMatchFilter",
"Industry" => "TextareaField",
"AnnualProfit" => array("NumericField" => "PartialMatchFilter")
);
}
class SearchContextTest_Project extends DataObject implements TestOnly {
static $db = array(
"Name" => "Text"
);
static $has_one = array(
"Deadline" => "SearchContextTest_Deadline"
);
static $has_many = array(
"Actions" => "SearchContextTest_Action"
);
static $searchable_fields = array(
"Name" => "PartialMatchFilter",
"Actions.SolutionArea" => "ExactMatchFilter"
);
}
class SearchContextTest_Deadline extends DataObject implements TestOnly {
static $db = array(
"CompletionDate" => "Datetime"
);
static $has_one = array(
"Project" => "SearchContextTest_Project"
);
}
class SearchContextTest_Action extends DataObject implements TestOnly {
static $db = array(
"Description" => "Text",
"SolutionArea" => "Text"
);
static $has_one = array(
"Project" => "SearchContextTest_Project"
);
}
class SearchContextTest_AllFilterTypes extends DataObject implements TestOnly {
static $db = array(
"ExactMatch" => "Text",
"PartialMatch" => "Text",
"Negation" => "Text",
"HiddenValue" => "Text"
);
static $searchable_fields = array(
"ExactMatch" => "ExactMatchFilter",
"PartialMatch" => "PartialMatchFilter",
"Negation" => "NegationFilter"
);
}
?>

View File

@ -1,4 +1,4 @@
PersonBubble:
SearchContextTest_Person:
person1:
Name: James
Email: james@example.com
@ -25,4 +25,36 @@ PersonBubble:
HairColor: black
EyeColor: green
SearchContextTest_Deadline:
deadline1:
CompletionDate: 2008-05-29 09:00:00
deadline2:
CompletionDate: 2008-05-29 09:00:00
SearchContextTest_Action:
action1:
Description: Get search context working
SolutionArea: backend
action2:
Description: Get relationship editor working
SolutionArea: frontend
action3:
Description: Get RSS feeds working
SolutionArea: technical
SearchContextTest_Project:
project1:
Name: CRM Application
Deadline: =>SearchContextTest_Deadline.deadline1
Actions: =>SearchContextTest_Action.action1,=>SearchContextTest_Action.action2
project2:
Name: Blog Website
Deadline: =>SearchContextTest_Deadline.deadline2
Actions: =>SearchContextTest_Action.action3
SearchContextTest_AllFilterTypes:
filter1:
ExactMatch: Match me exactly
PartialMatch: Match me partially
Negation: Shouldnt match me
HiddenValue: Filtered value