Merge pull request #2206 from tractorcow/3.2-polymorphic-relations

API Polymorphic has_one behaviour
This commit is contained in:
Simon Welsh 2014-03-18 10:02:36 +13:00
commit 451f9d383f
14 changed files with 932 additions and 77 deletions

View File

@ -171,12 +171,15 @@ class FixtureBlueprint {
$obj->getManyManyComponents($fieldName)->setByIDList($parsedItems);
}
}
} elseif($obj->has_one($fieldName)) {
// Sets has_one with relation name
$obj->{$fieldName . 'ID'} = $this->parseValue($fieldVal, $fixtures);
} elseif($obj->has_one(preg_replace('/ID$/', '', $fieldName))) {
// Sets has_one with database field
$obj->$fieldName = $this->parseValue($fieldVal, $fixtures);
} else {
$hasOneField = preg_replace('/ID$/', '', $fieldName);
if($className = $obj->has_one($hasOneField)) {
$obj->{$hasOneField.'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass);
// Inject class for polymorphic relation
if($className === 'DataObject') {
$obj->{$hasOneField.'Class'} = $fieldClass;
}
}
}
}
$obj->write();
@ -261,11 +264,13 @@ class FixtureBlueprint {
* Parse a value from a fixture file. If it starts with =>
* it will get an ID from the fixture dictionary
*
* @param String $fieldVal
* @param Array $fixtures See {@link createObject()}
* @return String Fixture database ID, or the original value
* @param string $fieldVal
* @param array $fixtures See {@link createObject()}
* @param string $class If the value parsed is a class relation, this parameter
* will be given the value of that class's name
* @return string Fixture database ID, or the original value
*/
protected function parseValue($value, $fixtures = null) {
protected function parseValue($value, $fixtures = null, &$class = null) {
if(substr($value,0,2) == '=>') {
// Parse a dictionary reference - used to set foreign keys
list($class, $identifier) = explode('.', substr($value,2), 2);

View File

@ -96,6 +96,7 @@ class TestRunner extends Controller {
// Invalidate classname spec since the test manifest will now pull out new subclasses for each internal class
// (e.g. Member will now have various subclasses of DataObjects that implement TestOnly)
DataObject::clear_classname_spec_cache();
PolymorphicForeignKey::clear_classname_spec_cache();
}
public function init() {

View File

@ -21,7 +21,8 @@ Every object of this class **or any of its subclasses** will have an entry in th
* Every field listed in the data object's **$db** array will be included in this table.
* For every relationship listed in the data object's **$has_one** array, there will be an integer field included in the
table. This will contain the ID of the data-object being linked to. The database field name will be of the form
"(relationship-name)ID", for example, ParentID.
"(relationship-name)ID", for example, ParentID. For polymorphic has_one relationships, there is an additional
"(relationship-name)Class" field to identify the class this ID corresponds to. See [datamodel](/topics/datamodel#has_one).
### ID Generation

View File

@ -533,6 +533,46 @@ relationship to link to its parent element in the tree:
);
}
A has_one can also be polymorphic, which allows any type of object to be associated.
This is useful where there could be many use cases for a particular data structure.
An additional column is created called "`<relationship-name>`Class", which along
with the ID column identifies the object.
To specify that a has_one relation is polymorphic set the type to 'DataObject'.
Ideally, the associated has_many (or belongs_to) should be specified with dot notation.
::php
class Player extends DataObject {
private static $has_many = array(
"Fans" => "Fan.FanOf"
);
}
class Team extends DataObject {
private static $has_many = array(
"Fans" => "Fan.FanOf"
);
}
// Type of object returned by $fan->FanOf() will vary
class Fan extends DataObject {
// Generates columns FanOfID and FanOfClass
private static $has_one = array(
"FanOf" => "DataObject"
);
}
<div class="warning" markdown='1'>
Note: The use of polymorphic relationships can affect query performance, especially
on joins, and also increases the complexity of the database and necessary user code.
They should be used sparingly, and only where additional complexity would otherwise
be necessary. E.g. Additional parent classes for each respective relationship, or
duplication of code.
</div>
### has_many
Defines 1-to-many joins. A database-column named ""`<relationship-name>`ID""

View File

@ -96,13 +96,16 @@ class FormScaffolder extends Object {
if($this->obj->has_one()) {
foreach($this->obj->has_one() as $relationship => $component) {
if($this->restrictFields && !in_array($relationship, $this->restrictFields)) continue;
$fieldName = "{$relationship}ID";
$fieldName = $component === 'DataObject'
? $relationship // Polymorphic has_one field is composite, so don't refer to ID subfield
: "{$relationship}ID";
if($this->fieldClasses && isset($this->fieldClasses[$fieldName])) {
$fieldClass = $this->fieldClasses[$fieldName];
$hasOneField = new $fieldClass($fieldName);
} else {
$hasOneField = $this->obj->dbObject($fieldName)->scaffoldFormField(null, $this->getParamsArray());
}
if(empty($hasOneField)) continue; // Allow fields to opt out of scaffolding
$hasOneField->setTitle($this->obj->fieldLabel($relationship));
if($this->tabbed) {
$fields->addFieldToTab("Root.Main", $hasOneField);

View File

@ -313,7 +313,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
// Add has_one relationships
$hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED);
if($hasOne) foreach(array_keys($hasOne) as $field) {
$fields[$field . 'ID'] = 'ForeignKey';
// Check if this is a polymorphic relation, in which case the relation
// is a composite field
if($hasOne[$field] === 'DataObject') {
$relationField = DBField::create_field('PolymorphicForeignKey', null, $field);
$relationField->setTable($class);
if($compositeFields = $relationField->compositeDatabaseFields()) {
foreach($compositeFields as $compositeName => $spec) {
$fields["{$field}{$compositeName}"] = $spec;
}
}
} else {
$fields[$field . 'ID'] = 'ForeignKey';
}
}
$output = (array) $fields;
@ -1412,7 +1425,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
/**
* Return a component object from a one to one relationship, as a DataObject.
* If no component is available, an 'empty component' will be returned.
* If no component is available, an 'empty component' will be returned for
* non-polymorphic relations, or for polymorphic relations with a class set.
*
* @param string $componentName Name of the component
*
@ -1427,24 +1441,40 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$joinField = $componentName . 'ID';
$joinID = $this->getField($joinField);
// Extract class name for polymorphic relations
if($class === 'DataObject') {
$class = $this->getField($componentName . 'Class');
if(empty($class)) return null;
}
if($joinID) {
$component = $this->model->$class->byID($joinID);
}
if(!isset($component) || !$component) {
if(empty($component)) {
$component = $this->model->$class->newObject();
}
} elseif($class = $this->belongs_to($componentName)) {
$joinField = $this->getRemoteJoinField($componentName, 'belongs_to');
$joinField = $this->getRemoteJoinField($componentName, 'belongs_to', $polymorphic);
$joinID = $this->ID;
if($joinID) {
$component = DataObject::get_one($class, "\"$joinField\" = $joinID");
$filter = $polymorphic
? "\"{$joinField}ID\" = '".Convert::raw2sql($joinID)."' AND
\"{$joinField}Class\" = '".Convert::raw2sql($this->class)."'"
: "\"{$joinField}\" = '".Convert::raw2sql($joinID)."'";
$component = DataObject::get_one($class, $filter);
}
if(!isset($component) || !$component) {
if(empty($component)) {
$component = $this->model->$class->newObject();
$component->$joinField = $this->ID;
if($polymorphic) {
$component->{$joinField.'ID'} = $this->ID;
$component->{$joinField.'Class'} = $this->class;
} else {
$component->$joinField = $this->ID;
}
}
} else {
throw new Exception("DataObject->getComponent(): Could not find component '$componentName'.");
@ -1489,15 +1519,21 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
return $this->unsavedRelations[$componentName];
}
$joinField = $this->getRemoteJoinField($componentName, 'has_many');
$result = HasManyList::create($componentClass, $joinField);
// Determine type and nature of foreign relation
$joinField = $this->getRemoteJoinField($componentName, 'has_many', $polymorphic);
if($polymorphic) {
$result = PolymorphicHasManyList::create($componentClass, $joinField, $this->class);
} else {
$result = HasManyList::create($componentClass, $joinField);
}
if($this->model) $result->setDataModel($this->model);
$result = $result->forForeignID($this->ID);
$result = $result->where($filter)->limit($limit)->sort($sort);
return $result;
return $result
->forForeignID($this->ID)
->where($filter)
->limit($limit)
->sort($sort);
}
/**
@ -1540,17 +1576,23 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
}
/**
* Tries to find the database key on another object that is used to store a relationship to this class. If no join
* field can be found it defaults to 'ParentID'.
* Tries to find the database key on another object that is used to store a
* relationship to this class. If no join field can be found it defaults to 'ParentID'.
*
* If the remote field is polymorphic then $polymorphic is set to true, and the return value
* is in the form 'Relation' instead of 'RelationID', referencing the composite DBField.
*
* @param string $component
* @param string $component Name of the relation on the current object pointing to the
* remote object.
* @param string $type the join type - either 'has_many' or 'belongs_to'
* @param boolean $polymorphic Flag set to true if the remote join field is polymorphic.
* @return string
*/
public function getRemoteJoinField($component, $type = 'has_many') {
$remoteClass = $this->$type($component, false);
public function getRemoteJoinField($component, $type = 'has_many', &$polymorphic = false) {
if(!$remoteClass) {
// Extract relation from current object
$remoteClass = $this->$type($component, false);
if(empty($remoteClass)) {
throw new Exception("Unknown $type component '$component' on class '$this->class'");
}
if(!ClassInfo::exists(strtok($remoteClass, '.'))) {
@ -1559,28 +1601,56 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
);
}
if($fieldPos = strpos($remoteClass, '.')) {
return substr($remoteClass, $fieldPos + 1) . 'ID';
// If presented with an explicit field name (using dot notation) then extract field name
$remoteField = null;
if(strpos($remoteClass, '.') !== false) {
list($remoteClass, $remoteField) = explode('.', $remoteClass);
}
$remoteRelations = Config::inst()->get($remoteClass, 'has_one');
if(!is_array($remoteRelations)) {
$remoteRelations = array();
}
$remoteRelations = array_flip($remoteRelations);
// look for remote has_one joins on this class or any parent classes
foreach(array_reverse(ClassInfo::ancestry($this)) as $class) {
if(array_key_exists($class, $remoteRelations)) return $remoteRelations[$class] . 'ID';
// Reference remote has_one to check against
$remoteRelations = Config::inst()->get($remoteClass, 'has_one');
// Without an explicit field name, attempt to match the first remote field
// with the same type as the current class
if(empty($remoteField)) {
// look for remote has_one joins on this class or any parent classes
$remoteRelationsMap = array_flip($remoteRelations);
foreach(array_reverse(ClassInfo::ancestry($this)) as $class) {
if(array_key_exists($class, $remoteRelationsMap)) {
$remoteField = $remoteRelationsMap[$class];
break;
}
}
}
// In case of an indeterminate remote field show an error
if(empty($remoteField)) {
$polymorphic = false;
$message = "No has_one found on class '$remoteClass'";
if($type == 'has_many') {
// include a hint for has_many that is missing a has_one
$message .= ", the has_many relation from '$this->class' to '$remoteClass'";
$message .= " requires a has_one on '$remoteClass'";
}
throw new Exception($message);
}
// If given an explicit field name ensure the related class specifies this
if(empty($remoteRelations[$remoteField])) {
throw new Exception("Missing expected has_one named '$remoteField'
on class '$remoteClass' referenced by $type named '$component'
on class {$this->class}"
);
}
$message = "No has_one found on class '$remoteClass'";
if($type == 'has_many') {
// include a hint for has_many that is missing a has_one
$message .= ", the has_many relation from '$this->class' to '$remoteClass'";
$message .= " requires a has_one on '$remoteClass'";
// Inspect resulting found relation
if($remoteRelations[$remoteField] === 'DataObject') {
$polymorphic = true;
return $remoteField; // Composite polymorphic field does not include 'ID' suffix
} else {
$polymorphic = false;
return $remoteField . 'ID';
}
throw new Exception($message);
}
/**
@ -1602,20 +1672,24 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
return $this->unsavedRelations[$componentName];
}
$result = ManyManyList::create($componentClass, $table, $componentField, $parentField,
$this->many_many_extraFields($componentName));
$result = ManyManyList::create(
$componentClass, $table, $componentField, $parentField,
$this->many_many_extraFields($componentName)
);
if($this->model) $result->setDataModel($this->model);
// If this is called on a singleton, then we return an 'orphaned relation' that can have the
// foreignID set elsewhere.
$result = $result->forForeignID($this->ID);
return $result->where($filter)->sort($sort)->limit($limit);
return $result
->forForeignID($this->ID)
->where($filter)
->sort($sort)
->limit($limit);
}
/**
* Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and
* their classes.
* Return the class of a one-to-one component. If $component is null, return all of the one-to-one components and
* their classes. If the selected has_one is a polymorphic field then 'DataObject' will be returned for the type.
*
* @param string $component Name of component
*
@ -2463,9 +2537,17 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
// all has_one relations on this specific class,
// add foreign key
$hasOne = Config::inst()->get($this->class, 'has_one', Config::UNINHERITED);
if($hasOne) foreach($hasOne as $fieldName => $fieldSchema) {
$fieldMap[$fieldName . 'ID'] = "ForeignKey";
if($fieldSchema === 'DataObject') {
// For polymorphic has_one relation break into individual subfields
$fieldMap[$fieldName . 'ID'] = "Int";
$fieldMap[$fieldName . 'Class'] = "Enum";
$fieldMap[$fieldName] = "PolymorphicForeignKey";
} else {
$fieldMap[$fieldName . 'ID'] = "ForeignKey";
}
}
// set cached fieldmap
@ -2704,6 +2786,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
} else if(preg_match('/ID$/', $fieldName) && $this->has_one(substr($fieldName,0,-2))) {
$val = $this->$fieldName;
return DBField::create_field('ForeignKey', $val, $fieldName, $this);
// has_one for polymorphic relations do not end in ID
} else if($this->has_one($fieldName)) {
$val = $this->$fieldName;
return DBField::create_field('PolymorphicForeignKey', $val, $fieldName, $this);
}
}

View File

@ -0,0 +1,129 @@
<?php
/**
* Represents a has_many list linked against a polymorphic relationship
*
* @package framework
* @subpackage model
*/
class PolymorphicHasManyList extends HasManyList {
/**
* Name of foreign key field that references the class name of the relation
*
* @var string
*/
protected $classForeignKey;
/**
* Retrieve the name of the class this relation is filtered by
*
* @return string
*/
public function getForeignClass() {
return $this->dataQuery->getQueryParam('Foreign.Class');
}
/**
* Create a new PolymorphicHasManyList relation list.
*
* @param string $dataClass The class of the DataObjects that this will list.
* @param string $foreignField The name of the composite foreign relation field. Used
* to generate the ID and Class foreign keys.
* @param string $foreignClass Name of the class filter this relation is filtered against
*/
function __construct($dataClass, $foreignField, $foreignClass) {
// Set both id foreign key (as in HasManyList) and the class foreign key
parent::__construct($dataClass, "{$foreignField}ID");
$this->classForeignKey = "{$foreignField}Class";
// Ensure underlying DataQuery globally references the class filter
$this->dataQuery->setQueryParam('Foreign.Class', $foreignClass);
// For queries with multiple foreign IDs (such as that generated by
// DataList::relation) the filter must be generalised to filter by subclasses
$classNames = Convert::raw2sql(ClassInfo::subclassesFor($foreignClass));
$this->dataQuery->where(sprintf(
"\"{$this->classForeignKey}\" IN ('%s')",
implode("', '", $classNames)
));
}
/**
* Adds the item to this relation.
*
* It does so by setting the relationFilters.
*
* @param $item The DataObject to be added, or its ID
*/
public function add($item) {
if(is_numeric($item)) {
$item = DataObject::get_by_id($this->dataClass, $item);
} else if(!($item instanceof $this->dataClass)) {
user_error(
"PolymorphicHasManyList::add() expecting a $this->dataClass object, or ID value",
E_USER_ERROR
);
}
$foreignID = $this->getForeignID();
// Validate foreignID
if(!$foreignID) {
user_error(
"PolymorphicHasManyList::add() can't be called until a foreign ID is set",
E_USER_WARNING
);
return;
}
if(is_array($foreignID)) {
user_error(
"PolymorphicHasManyList::add() can't be called on a list linked to mulitple foreign IDs",
E_USER_WARNING
);
return;
}
$foreignKey = $this->foreignKey;
$classForeignKey = $this->classForeignKey;
$item->$foreignKey = $foreignID;
$item->$classForeignKey = $this->getForeignClass();
$item->write();
}
/**
* Remove an item from this relation.
* Doesn't actually remove the item, it just clears the foreign key value.
*
* @param $item The DataObject to be removed
* @todo Maybe we should delete the object instead?
*/
public function remove($item) {
if(!($item instanceof $this->dataClass)) {
throw new InvalidArgumentException("HasManyList::remove() expecting a $this->dataClass object, or ID",
E_USER_ERROR);
}
// Don't remove item with unrelated class key
$foreignClass = $this->getForeignClass();
$classNames = ClassInfo::subclassesFor($foreignClass);
$classForeignKey = $this->classForeignKey;
if(!in_array($item->$classForeignKey, $classNames)) return;
// Don't remove item which doesn't belong to this list
$foreignID = $this->getForeignID();
$foreignKey = $this->foreignKey;
if( empty($foreignID)
|| (is_array($foreignID) && in_array($item->$foreignKey, $foreignID))
|| $foreignID == $item->$foreignKey
) {
$item->$foreignKey = null;
$item->$classForeignKey = null;
$item->write();
}
}
}

View File

@ -0,0 +1,193 @@
<?php
/**
* A special ForeignKey class that handles relations with arbitrary class types
*
* @package framework
* @subpackage model
*/
class PolymorphicForeignKey extends ForeignKey implements CompositeDBField {
/**
* @var boolean $isChanged
*/
protected $isChanged = false;
/**
* Value of relation class
*
* @var string
*/
protected $classValue = null;
/**
* Field definition cache for compositeDatabaseFields
*
* @var string
*/
protected static $classname_spec_cache = array();
/**
* Clear all cached classname specs. It's necessary to clear all cached subclassed names
* for any classes if a new class manifest is generated.
*/
public static function clear_classname_spec_cache() {
self::$classname_spec_cache = array();
}
public function scaffoldFormField($title = null, $params = null) {
// Opt-out of form field generation - Scaffolding should be performed on
// the has_many end, or set programatically.
// @todo - Investigate suitable FormField
return null;
}
public function requireField() {
$fields = $this->compositeDatabaseFields();
if($fields) foreach($fields as $name => $type){
DB::requireField($this->tableName, $this->name.$name, $type);
}
}
public function writeToManipulation(&$manipulation) {
// Write ID, checking that the value is valid
$manipulation['fields'][$this->name . 'ID'] = $this->exists()
? $this->prepValueForDB($this->getIDValue())
: $this->nullValue();
// Write class
$classObject = DBField::create_field('Enum', $this->getClassValue(), $this->name . 'Class');
$classObject->writeToManipulation($manipulation);
}
public function addToQuery(&$query) {
parent::addToQuery($query);
$query->selectField(
"\"{$this->tableName}\".\"{$this->name}ID\"",
"{$this->name}ID"
);
$query->selectField(
"\"{$this->tableName}\".\"{$this->name}Class\"",
"{$this->name}Class"
);
}
/**
* Get the value of the "Class" this key points to
*
* @return string Name of a subclass of DataObject
*/
public function getClassValue() {
return $this->classValue;
}
/**
* Set the value of the "Class" this key points to
*
* @param string $class Name of a subclass of DataObject
* @param boolean $markChanged Mark this field as changed?
*/
public function setClassValue($class, $markChanged = true) {
$this->classValue = $class;
if($markChanged) $this->isChanged = true;
}
/**
* Gets the value of the "ID" this key points to
*
* @return integer
*/
public function getIDValue() {
return parent::getValue();
}
/**
* Sets the value of the "ID" this key points to
*
* @param integer $id
* @param boolean $markChanged Mark this field as changed?
*/
public function setIDValue($id, $markChanged = true) {
parent::setValue($id);
if($markChanged) $this->isChanged = true;
}
public function setValue($value, $record = null, $markChanged = true) {
$idField = "{$this->name}ID";
$classField = "{$this->name}Class";
// Check if an object is assigned directly
if($value instanceof DataObject) {
$record = array(
$idField => $value->ID,
$classField => $value->class
);
}
// Convert an object to an array
if($record instanceof DataObject) {
$record = $record->getQueriedDatabaseFields();
}
// Use $value array if record is missing
if(empty($record) && is_array($value)) {
$record = $value;
}
// Inspect presented values
if(isset($record[$idField]) && isset($record[$classField])) {
if(empty($record[$idField]) || empty($record[$classField])) {
$this->setIDValue($this->nullValue(), $markChanged);
$this->setClassValue('', $markChanged);
} else {
$this->setClassValue($record[$classField], $markChanged);
$this->setIDValue($record[$idField], $markChanged);
}
}
}
public function getValue() {
if($this->exists()) {
return DataObject::get_by_id($this->getClassValue(), $this->getIDValue());
}
}
public function compositeDatabaseFields() {
// Ensure the table level cache exists
if(empty(self::$classname_spec_cache[$this->tableName])) {
self::$classname_spec_cache[$this->tableName] = array();
}
// Ensure the field level cache exists
if(empty(self::$classname_spec_cache[$this->tableName][$this->name])) {
// Get all class names
$classNames = ClassInfo::subclassesFor('DataObject');
unset($classNames['DataObject']);
$db = DB::getConn();
if($db->hasField($this->tableName, "{$this->name}Class")) {
$existing = $db->query("SELECT DISTINCT \"{$this->name}Class\" FROM \"{$this->tableName}\"")->column();
$classNames = array_unique(array_merge($classNames, $existing));
}
self::$classname_spec_cache[$this->tableName][$this->name]
= "Enum(array('" . implode("', '", array_filter($classNames)) . "'))";
}
return array(
'ID' => 'Int',
'Class' => self::$classname_spec_cache[$this->tableName][$this->name]
);
}
public function isChanged() {
return $this->isChanged;
}
public function exists() {
return $this->getClassValue() && $this->getIDValue();
}
}

View File

@ -33,7 +33,7 @@ class FormScaffolderTest extends SapphireTest {
$this->assertNotNull($fields->dataFieldByName('AuthorID'),
'getCMSFields() includes has_one fields on singletons');
$this->assertNull($fields->dataFieldByName('Tags'),
'getCMSFields() doesnt include many_many fields if no ID is present');
"getCMSFields() doesn't include many_many fields if no ID is present");
}
public function testGetCMSFieldsInstance() {
@ -47,6 +47,14 @@ class FormScaffolderTest extends SapphireTest {
'getCMSFields() includes has_one fields on instances');
$this->assertNotNull($fields->dataFieldByName('Tags'),
'getCMSFields() includes many_many fields if ID is present on instances');
$this->assertNotNull($fields->dataFieldByName('SubjectOfArticles'),
'getCMSFields() includes polymorphic has_many fields if ID is present on instances');
$this->assertNull($fields->dataFieldByName('Subject'),
"getCMSFields() doesn't include polymorphic has_one field");
$this->assertNull($fields->dataFieldByName('SubjectID'),
"getCMSFields() doesn't include polymorphic has_one id field");
$this->assertNull($fields->dataFieldByName('SubjectClass'),
"getCMSFields() doesn't include polymorphic has_one class field");
}
public function testUpdateCMSFields() {
@ -111,11 +119,15 @@ class FormScaffolderTest_Article extends DataObject implements TestOnly {
'Content' => 'HTMLText'
);
private static $has_one = array(
'Author' => 'FormScaffolderTest_Author'
'Author' => 'FormScaffolderTest_Author',
'Subject' => 'DataObject'
);
private static $many_many = array(
'Tags' => 'FormScaffolderTest_Tag',
);
private static $has_many = array(
'SubjectOfArticles' => 'FormScaffolderTest_Article.Subject'
);
}
class FormScaffolderTest_Author extends Member implements TestOnly {
@ -123,7 +135,8 @@ class FormScaffolderTest_Author extends Member implements TestOnly {
'ProfileImage' => 'Image'
);
private static $has_many = array(
'Articles' => 'FormScaffolderTest_Article'
'Articles' => 'FormScaffolderTest_Article.Author',
'SubjectOfArticles' => 'FormScaffolderTest_Article.Subject'
);
}
class FormScaffolderTest_Tag extends DataObject implements TestOnly {
@ -133,6 +146,9 @@ class FormScaffolderTest_Tag extends DataObject implements TestOnly {
private static $belongs_many_many = array(
'Articles' => 'FormScaffolderTest_Article'
);
private static $has_many = array(
'SubjectOfArticles' => 'FormScaffolderTest_Article.Subject'
);
}
class FormScaffolderTest_ArticleExtension extends DataExtension implements TestOnly {
private static $db = array(

View File

@ -16,7 +16,9 @@ class DataObjectTest extends SapphireTest {
'DataObjectTest_FieldlessSubTable',
'DataObjectTest_ValidatedObject',
'DataObjectTest_Player',
'DataObjectTest_TeamComment'
'DataObjectTest_TeamComment',
'DataObjectTest\NamespacedClass',
'DataObjectTest\RelationClass',
);
public function testBaseFieldsExcludedFromDb() {
@ -206,15 +208,54 @@ class DataObjectTest extends SapphireTest {
'belongs_many_many is properly inspected');
$this->assertEquals(singleton('DataObjectTest_CEO')->getRelationClass('Company'), 'DataObjectTest_Company',
'belongs_to is properly inspected');
$this->assertEquals(singleton('DataObjectTest_Fan')->getRelationClass('Favourite'), 'DataObject',
'polymorphic has_one is properly inspected');
}
/**
* Test that has_one relations can be retrieved
*/
public function testGetHasOneRelations() {
$captain1 = $this->objFromFixture("DataObjectTest_Player", "captain1");
/* There will be a field called (relname)ID that contains the ID of the object linked to via the
* has_one relation */
$this->assertEquals($this->idFromFixture('DataObjectTest_Team', 'team1'), $captain1->FavouriteTeamID);
/* There will be a method called $obj->relname() that returns the object itself */
$this->assertEquals($this->idFromFixture('DataObjectTest_Team', 'team1'), $captain1->FavouriteTeam()->ID);
$team1ID = $this->idFromFixture('DataObjectTest_Team', 'team1');
// There will be a field called (relname)ID that contains the ID of the
// object linked to via the has_one relation
$this->assertEquals($team1ID, $captain1->FavouriteTeamID);
// There will be a method called $obj->relname() that returns the object itself
$this->assertEquals($team1ID, $captain1->FavouriteTeam()->ID);
// Check entity with polymorphic has-one
$fan1 = $this->objFromFixture("DataObjectTest_Fan", "fan1");
// There will be fields named (relname)ID and (relname)Class for polymorphic
// entities
$this->assertEquals($team1ID, $fan1->FavouriteID);
$this->assertEquals('DataObjectTest_Team', $fan1->FavouriteClass);
// There will be a method called $obj->relname() that returns the object itself
$favourite = $fan1->Favourite();
$this->assertEquals($team1ID, $favourite->ID);
$this->assertInstanceOf('DataObjectTest_Team', $favourite);
}
/**
* Simple test to ensure that namespaced classes and polymorphic relations work together
*/
public function testPolymorphicNamespacedRelations() {
$parent = new \DataObjectTest\NamespacedClass();
$parent->Name = 'New Parent';
$parent->write();
$child = new \DataObjectTest\RelationClass();
$child->Title = 'New Child';
$child->write();
$parent->Relations()->add($child);
$this->assertEquals(1, $parent->Relations()->count());
$this->assertEquals(array('New Child'), $parent->Relations()->column('Title'));
$this->assertEquals('New Parent', $child->Parent()->Name);
}
public function testLimitAndCount() {
@ -240,9 +281,20 @@ class DataObjectTest extends SapphireTest {
$obj = $this->objFromFixture('DataObjectTest_Player', 'captain1');
$obj->FavouriteTeamID = 99;
$obj->write();
// reload the page from the database
$savedObj = DataObject::get_by_id('DataObjectTest_Player', $obj->ID);
$this->assertTrue($savedObj->FavouriteTeamID == 99);
// Test with porymorphic relation
$obj2 = $this->objFromFixture("DataObjectTest_Fan", "fan1");
$obj2->FavouriteID = 99;
$obj2->FavouriteClass = 'DataObjectTest_Player';
$obj2->write();
$savedObj2 = DataObject::get_by_id('DataObjectTest_Fan', $obj2->ID);
$this->assertTrue($savedObj2->FavouriteID == 99);
$this->assertTrue($savedObj2->FavouriteClass == 'DataObjectTest_Player');
}
/**
@ -284,10 +336,58 @@ class DataObjectTest extends SapphireTest {
$team1CommentIDs = $team1->Comments()->sort('ID')->column('ID');
$this->assertEquals(array($comment1->ID, $newComment->ID), $team1CommentIDs);
}
/**
* Test has many relationships against polymorphic has_one fields
* - Test getComponents() gets the ComponentSet of the other side of the relation
* - Test the IDs on the DataObjects are set correctly
*/
public function testHasManyPolymorphicRelationships() {
$team1 = $this->objFromFixture('DataObjectTest_Team', 'team1');
// Test getComponents() gets the ComponentSet of the other side of the relation
$this->assertTrue($team1->Fans()->Count() == 2);
// Test the IDs/Classes on the DataObjects are set correctly
foreach($team1->Fans() as $fan) {
$this->assertEquals($team1->ID, $fan->FavouriteID, 'Fan has the correct FavouriteID');
$this->assertEquals('DataObjectTest_Team', $fan->FavouriteClass, 'Fan has the correct FavouriteClass');
}
// Test that we can add and remove items that already exist in the database
$newFan = new DataObjectTest_Fan();
$newFan->Name = "New fan";
$newFan->write();
$team1->Fans()->add($newFan);
$this->assertEquals($team1->ID, $newFan->FavouriteID, 'Newly created fan has the correct FavouriteID');
$this->assertEquals(
'DataObjectTest_Team',
$newFan->FavouriteClass,
'Newly created fan has the correct FavouriteClass'
);
$fan1 = $this->objFromFixture('DataObjectTest_Fan', 'fan1');
$fan3 = $this->objFromFixture('DataObjectTest_Fan', 'fan3');
$team1->Fans()->remove($fan3);
$team1FanIDs = $team1->Fans()->sort('ID')->column('ID');
$this->assertEquals(array($fan1->ID, $newFan->ID), $team1FanIDs);
// Test that removing an item from a list doesn't remove it from the same
// relation belonging to a different object
$team1 = $this->objFromFixture('DataObjectTest_Team', 'team1');
$player1 = $this->objFromFixture('DataObjectTest_Player', 'player1');
$player1->Fans()->remove($fan1);
$team1FanIDs = $team1->Fans()->sort('ID')->column('ID');
$this->assertEquals(array($fan1->ID, $newFan->ID), $team1FanIDs);
}
public function testHasOneRelationship() {
$team1 = $this->objFromFixture('DataObjectTest_Team', 'team1');
$player1 = $this->objFromFixture('DataObjectTest_Player', 'player1');
$fan1 = $this->objFromFixture('DataObjectTest_Fan', 'fan1');
// Add a captain to team 1
$team1->setField('CaptainID', $player1->ID);
@ -302,6 +402,24 @@ class DataObjectTest extends SapphireTest {
'Player 1 is the captain');
$this->assertEquals($team1->getComponent('Captain')->FirstName, 'Player 1',
'Player 1 is the captain');
// Set the favourite team for fan1
$fan1->setField('FavouriteID', $team1->ID);
$fan1->setField('FavouriteClass', $team1->class);
$this->assertEquals($team1->ID, $fan1->Favourite()->ID, 'The team is assigned to fan 1');
$this->assertInstanceOf($team1->class, $fan1->Favourite(), 'The team is assigned to fan 1');
$this->assertEquals($team1->ID, $fan1->getComponent('Favourite')->ID,
'The team exists through the component getter'
);
$this->assertInstanceOf($team1->class, $fan1->getComponent('Favourite'),
'The team exists through the component getter'
);
$this->assertEquals($fan1->Favourite()->Title, 'Team 1',
'Team 1 is the favourite');
$this->assertEquals($fan1->getComponent('Favourite')->Title, 'Team 1',
'Team 1 is the favourite');
}
/**
@ -465,6 +583,14 @@ class DataObjectTest extends SapphireTest {
$team->CaptainID = $captainID;
$this->assertNotNull($team->Captain());
$this->assertEquals($captainID, $team->Captain()->ID);
// Test for polymorphic has_one relations
$fan = $this->objFromFixture('DataObjectTest_Fan', 'fan1');
$fan->FavouriteID = $team->ID;
$fan->FavouriteClass = $team->class;
$this->assertNotNull($fan->Favourite());
$this->assertEquals($team->ID, $fan->Favourite()->ID);
$this->assertInstanceOf($team->class, $fan->Favourite());
}
public function testFieldNamesThatMatchMethodNamesWork() {
@ -569,9 +695,9 @@ class DataObjectTest extends SapphireTest {
'hasDatabaseField() doesnt include extended dynamic getters in instances');
/* hasDatabaseField() subclass checks */
$this->assertTrue($subteamInstance->hasField('DatabaseField'),
$this->assertTrue($subteamInstance->hasDatabaseField('DatabaseField'),
'hasField() finds custom fields in subclass instances');
$this->assertTrue($subteamInstance->hasField('SubclassDatabaseField'),
$this->assertTrue($subteamInstance->hasDatabaseField('SubclassDatabaseField'),
'hasField() finds custom fields in subclass instances');
}
@ -1078,13 +1204,26 @@ class DataObjectTest extends SapphireTest {
public function testGetRemoteJoinField() {
$company = new DataObjectTest_Company();
$this->assertEquals('CurrentCompanyID', $company->getRemoteJoinField('CurrentStaff'));
$this->assertEquals('PreviousCompanyID', $company->getRemoteJoinField('PreviousStaff'));
$staffJoinField = $company->getRemoteJoinField('CurrentStaff', 'has_many', $polymorphic);
$this->assertEquals('CurrentCompanyID', $staffJoinField);
$this->assertFalse($polymorphic, 'DataObjectTest_Company->CurrentStaff is not polymorphic');
$previousStaffJoinField = $company->getRemoteJoinField('PreviousStaff', 'has_many', $polymorphic);
$this->assertEquals('PreviousCompanyID', $previousStaffJoinField);
$this->assertFalse($polymorphic, 'DataObjectTest_Company->PreviousStaff is not polymorphic');
$ceo = new DataObjectTest_CEO();
$this->assertEquals('CEOID', $ceo->getRemoteJoinField('Company', 'belongs_to'));
$this->assertEquals('PreviousCEOID', $ceo->getRemoteJoinField('PreviousCompany', 'belongs_to'));
$this->assertEquals('CEOID', $ceo->getRemoteJoinField('Company', 'belongs_to', $polymorphic));
$this->assertFalse($polymorphic, 'DataObjectTest_CEO->Company is not polymorphic');
$this->assertEquals('PreviousCEOID', $ceo->getRemoteJoinField('PreviousCompany', 'belongs_to', $polymorphic));
$this->assertFalse($polymorphic, 'DataObjectTest_CEO->PreviousCompany is not polymorphic');
$team = new DataObjectTest_Team();
$this->assertEquals('Favourite', $team->getRemoteJoinField('Fans', 'has_many', $polymorphic));
$this->assertTrue($polymorphic, 'DataObjectTest_Team->Fans is polymorphic');
$this->assertEquals('TeamID', $team->getRemoteJoinField('Comments', 'has_many', $polymorphic));
$this->assertFalse($polymorphic, 'DataObjectTest_Team->Comments is not polymorphic');
}
public function testBelongsTo() {
@ -1094,11 +1233,13 @@ class DataObjectTest extends SapphireTest {
$company->write();
$ceo->write();
// Test belongs_to assignment
$company->CEOID = $ceo->ID;
$company->write();
$this->assertEquals($company->ID, $ceo->Company()->ID, 'belongs_to returns the right results.');
// Test automatic creation of class where no assigment exists
$ceo = new DataObjectTest_CEO();
$ceo->write();
@ -1108,6 +1249,7 @@ class DataObjectTest extends SapphireTest {
);
$this->assertEquals($ceo->ID, $ceo->Company()->CEOID, 'Remote IDs are automatically set.');
// Write object with components
$ceo->write(false, false, false, true);
$this->assertTrue($ceo->Company()->isInDB(), 'write() writes belongs_to components to the database.');
@ -1117,6 +1259,44 @@ class DataObjectTest extends SapphireTest {
);
}
public function testBelongsToPolymorphic() {
$company = new DataObjectTest_Company();
$ceo = new DataObjectTest_CEO();
$company->write();
$ceo->write();
// Test belongs_to assignment
$company->OwnerID = $ceo->ID;
$company->OwnerClass = $ceo->class;
$company->write();
$this->assertEquals($company->ID, $ceo->CompanyOwned()->ID, 'belongs_to returns the right results.');
$this->assertEquals($company->class, $ceo->CompanyOwned()->class, 'belongs_to returns the right results.');
// Test automatic creation of class where no assigment exists
$ceo = new DataObjectTest_CEO();
$ceo->write();
$this->assertTrue (
$ceo->CompanyOwned() instanceof DataObjectTest_Company,
'DataObjects across polymorphic belongs_to relations are automatically created.'
);
$this->assertEquals($ceo->ID, $ceo->CompanyOwned()->OwnerID, 'Remote IDs are automatically set.');
$this->assertInstanceOf($ceo->CompanyOwned()->OwnerClass, $ceo, 'Remote class is automatically set');
// Write object with components
$ceo->write(false, false, false, true);
$this->assertTrue($ceo->CompanyOwned()->isInDB(), 'write() writes belongs_to components to the database.');
$newCEO = DataObject::get_by_id('DataObjectTest_CEO', $ceo->ID);
$this->assertEquals (
$ceo->CompanyOwned()->ID,
$newCEO->CompanyOwned()->ID,
'polymorphic belongs_to can be retrieved from the database.'
);
}
/**
* @expectedException LogicException
*/
@ -1228,6 +1408,14 @@ class DataObjectTest_Player extends Member implements TestOnly {
private static $belongs_many_many = array(
'Teams' => 'DataObjectTest_Team'
);
private static $has_many = array(
'Fans' => 'DataObjectTest_Fan.Favourite' // Polymorphic - Player fans
);
private static $belongs_to = array (
'CompanyOwned' => 'DataObjectTest_Company.Owner'
);
private static $searchable_fields = array(
'IsRetired',
@ -1249,7 +1437,8 @@ class DataObjectTest_Team extends DataObject implements TestOnly {
private static $has_many = array(
'SubTeams' => 'DataObjectTest_SubTeam',
'Comments' => 'DataObjectTest_TeamComment'
'Comments' => 'DataObjectTest_TeamComment',
'Fans' => 'DataObjectTest_Fan.Favourite' // Polymorphic - Team fans
);
private static $many_many = array(
@ -1369,9 +1558,15 @@ class DataObjectTest_ValidatedObject extends DataObject implements TestOnly {
}
class DataObjectTest_Company extends DataObject {
private static $db = array(
'Name' => 'Varchar'
);
private static $has_one = array (
'CEO' => 'DataObjectTest_CEO',
'PreviousCEO' => 'DataObjectTest_CEO'
'PreviousCEO' => 'DataObjectTest_CEO',
'Owner' => 'DataObject' // polymorphic
);
private static $has_many = array (
@ -1390,7 +1585,8 @@ class DataObjectTest_Staff extends DataObject {
class DataObjectTest_CEO extends DataObjectTest_Staff {
private static $belongs_to = array (
'Company' => 'DataObjectTest_Company.CEO',
'PreviousCompany' => 'DataObjectTest_Company.PreviousCEO'
'PreviousCompany' => 'DataObjectTest_Company.PreviousCEO',
'CompanyOwned' => 'DataObjectTest_Company.Owner'
);
}
@ -1406,5 +1602,17 @@ class DataObjectTest_TeamComment extends DataObject {
}
class DataObjectTest_Fan extends DataObject {
private static $db = array(
'Name' => 'Varchar(255)'
);
private static $has_one = array(
'Favourite' => 'DataObject', // Polymorphic relation
'SecondFavourite' => 'DataObject'
);
}
DataObjectTest_Team::add_extension('DataObjectTest_Team_Extension');

View File

@ -45,3 +45,24 @@ DataObjectTest_TeamComment:
Name: Phil
Comment: Phil is a unique guy, and comments on team2
Team: =>DataObjectTest_Team.team2
DataObjectTest_Fan:
fan1:
Name: Damian
Favourite: =>DataObjectTest_Team.team1
fan2:
Name: Stephen
Favourite: =>DataObjectTest_Player.player1
SecondFavourite: =>DataObjectTest_Team.team2
fan3:
Name: Richard
Favourite: =>DataObjectTest_Team.team1
fan4:
Name: Mitch
Favourite: =>DataObjectTest_SubTeam.subteam1
DataObjectTest_Company:
company1:
Name: Company corp
Owner: =>DataObjectTest_Player.player1
company1:
Name: 'Team co.'
Owner: =>DataObjectTest_Player.player2

View File

@ -6,8 +6,23 @@ namespace DataObjectTest;
* Right now this is only used in DataListTest, but extending it to DataObjectTest in the future would make sense.
* Note that it was deliberated named to include "\N" to try and trip bad code up.
*/
class NamespacedClass extends \DataObject {
class NamespacedClass extends \DataObject implements \TestOnly {
private static $db = array(
'Name' => 'Varchar',
);
private static $has_many = array(
'Relations' => 'DataObjectTest\RelationClass'
);
}
class RelationClass extends \DataObject implements \TestOnly {
private static $db = array(
'Title' => 'Varchar'
);
private static $has_one = array(
'Parent' => 'DataObject'
);
}

View File

@ -0,0 +1,106 @@
<?php
/**
* Tests the PolymorphicHasManyList class
*
* @see PolymorphicHasManyList
*
* @todo Complete
*
* @package framework
* @subpackage tests
*/
class PolymorphicHasManyListTest extends SapphireTest {
// Borrow the model from DataObjectTest
protected static $fixture_file = 'DataObjectTest.yml';
protected $extraDataObjects = array(
'DataObjectTest_Team',
'DataObjectTest_SubTeam',
'DataObjectTest_Player',
'DataObjectTest_Fan'
);
public function testRelationshipEmptyOnNewRecords() {
// Relies on the fact that (unrelated) comments exist in the fixture file already
$newTeam = new DataObjectTest_Team(); // has_many Comments
$this->assertEquals(array(), $newTeam->Fans()->column('ID'));
}
/**
* Test that DataList::relation works with PolymorphicHasManyList
*/
public function testFilterRelation() {
// Check that expected teams exist
$list = DataObjectTest_Team::get();
$this->assertEquals(
array('Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'),
$list->sort('Title')->column('Title')
);
// Check that fan list exists
$fans = $list->relation('Fans');
$this->assertEquals(array('Damian', 'Mitch', 'Richard'), $fans->sort('Name')->column('Name'));
// Modify list of fans and retest
$team1 = $this->objFromFixture('DataObjectTest_Team', 'team1');
$subteam1 = $this->objFromFixture('DataObjectTest_SubTeam', 'subteam1');
$newFan1 = DataObjectTest_Fan::create();
$newFan1->Name = 'Bobby';
$newFan1->write();
$newFan2 = DataObjectTest_Fan::create();
$newFan2->Name = 'Mindy';
$newFan2->write();
$team1->Fans()->add($newFan1);
$subteam1->Fans()->add($newFan2);
$fans = DataObjectTest_Team::get()->relation('Fans');
$this->assertEquals(array('Bobby', 'Damian', 'Richard'), $team1->Fans()->sort('Name')->column('Name'));
$this->assertEquals(array('Mindy', 'Mitch'), $subteam1->Fans()->sort('Name')->column('Name'));
$this->assertEquals(array('Bobby', 'Damian', 'Mindy', 'Mitch', 'Richard'), $fans->sort('Name')->column('Name'));
}
/**
* Test that related objects can be removed from a relation
*/
public function testRemoveRelation() {
// Check that expected teams exist
$list = DataObjectTest_Team::get();
$this->assertEquals(
array('Subteam 1', 'Subteam 2', 'Subteam 3', 'Team 1', 'Team 2', 'Team 3'),
$list->sort('Title')->column('Title')
);
// Test that each team has the correct fans
$team1 = $this->objFromFixture('DataObjectTest_Team', 'team1');
$subteam1 = $this->objFromFixture('DataObjectTest_SubTeam', 'subteam1');
$this->assertEquals(array('Damian', 'Richard'), $team1->Fans()->sort('Name')->column('Name'));
$this->assertEquals(array('Mitch'), $subteam1->Fans()->sort('Name')->column('Name'));
// Test that removing items from unrelated team has no effect
$team1fan = $this->objFromFixture('DataObjectTest_Fan', 'fan1');
$subteam1fan = $this->objFromFixture('DataObjectTest_Fan', 'fan4');
$team1->Fans()->remove($subteam1fan);
$subteam1->Fans()->remove($team1fan);
$this->assertEquals(array('Damian', 'Richard'), $team1->Fans()->sort('Name')->column('Name'));
$this->assertEquals(array('Mitch'), $subteam1->Fans()->sort('Name')->column('Name'));
$this->assertEquals($team1->ID, $team1fan->FavouriteID);
$this->assertEquals('DataObjectTest_Team', $team1fan->FavouriteClass);
$this->assertEquals($subteam1->ID, $subteam1fan->FavouriteID);
$this->assertEquals('DataObjectTest_SubTeam', $subteam1fan->FavouriteClass);
// Test that removing items from the related team resets the has_one relations on the fan
$team1fan = $this->objFromFixture('DataObjectTest_Fan', 'fan1');
$subteam1fan = $this->objFromFixture('DataObjectTest_Fan', 'fan4');
$team1->Fans()->remove($team1fan);
$subteam1->Fans()->remove($subteam1fan);
$this->assertEquals(array('Richard'), $team1->Fans()->sort('Name')->column('Name'));
$this->assertEquals(array(), $subteam1->Fans()->sort('Name')->column('Name'));
$this->assertEmpty($team1fan->FavouriteID);
$this->assertEmpty($team1fan->FavouriteClass);
$this->assertEmpty($subteam1fan->FavouriteID);
$this->assertEmpty($subteam1fan->FavouriteClass);
}
}

View File

@ -103,6 +103,33 @@ class UnsavedRelationListTest extends SapphireTest {
array('Name' => 'C')
), $object->Children());
}
public function testHasManyPolymorphic() {
$object = new UnsavedRelationListTest_DataObject;
$children = $object->RelatedObjects();
$children->add(new UnsavedRelationListTest_DataObject(array('Name' => 'A')));
$children->add(new UnsavedRelationListTest_DataObject(array('Name' => 'B')));
$children->add(new UnsavedRelationListTest_DataObject(array('Name' => 'C')));
$children = $object->RelatedObjects();
$this->assertDOSEquals(array(
array('Name' => 'A'),
array('Name' => 'B'),
array('Name' => 'C')
), $children);
$object->write();
$this->assertNotEquals($children, $object->RelatedObjects());
$this->assertDOSEquals(array(
array('Name' => 'A'),
array('Name' => 'B'),
array('Name' => 'C')
), $object->RelatedObjects());
}
public function testManyManyNew() {
$object = new UnsavedRelationListTest_DataObject;
@ -192,10 +219,12 @@ class UnsavedRelationListTest_DataObject extends DataObject implements TestOnly
private static $has_one = array(
'Parent' => 'UnsavedRelationListTest_DataObject',
'RelatedObject' => 'DataObject'
);
private static $has_many = array(
'Children' => 'UnsavedRelationListTest_DataObject',
'Children' => 'UnsavedRelationListTest_DataObject.Parent',
'RelatedObjects' => 'UnsavedRelationListTest_DataObject.RelatedObject'
);
private static $many_many = array(