diff --git a/dev/FixtureBlueprint.php b/dev/FixtureBlueprint.php index 532c16b56..0e287f608 100644 --- a/dev/FixtureBlueprint.php +++ b/dev/FixtureBlueprint.php @@ -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); diff --git a/dev/TestRunner.php b/dev/TestRunner.php index 786499de4..679b5ee68 100755 --- a/dev/TestRunner.php +++ b/dev/TestRunner.php @@ -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() { diff --git a/docs/en/reference/database-structure.md b/docs/en/reference/database-structure.md index b90e4e6c2..61d7a37b2 100644 --- a/docs/en/reference/database-structure.md +++ b/docs/en/reference/database-structure.md @@ -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 diff --git a/docs/en/topics/datamodel.md b/docs/en/topics/datamodel.md index 94f6214d1..081e1b114 100755 --- a/docs/en/topics/datamodel.md +++ b/docs/en/topics/datamodel.md @@ -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 "``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" + ); + } + +
+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. +
+ ### has_many Defines 1-to-many joins. A database-column named ""``ID"" diff --git a/forms/FormScaffolder.php b/forms/FormScaffolder.php index 5955b98e6..fb128713e 100644 --- a/forms/FormScaffolder.php +++ b/forms/FormScaffolder.php @@ -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); diff --git a/model/DataObject.php b/model/DataObject.php index c9fbb63ac..334dbf6a3 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -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); + } } diff --git a/model/PolymorphicHasManyList.php b/model/PolymorphicHasManyList.php new file mode 100644 index 000000000..065669383 --- /dev/null +++ b/model/PolymorphicHasManyList.php @@ -0,0 +1,129 @@ +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(); + } + + } +} diff --git a/model/fieldtypes/PolymorphicForeignKey.php b/model/fieldtypes/PolymorphicForeignKey.php new file mode 100644 index 000000000..d4c69e099 --- /dev/null +++ b/model/fieldtypes/PolymorphicForeignKey.php @@ -0,0 +1,193 @@ +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(); + } +} diff --git a/tests/forms/FormScaffolderTest.php b/tests/forms/FormScaffolderTest.php index 74bd1ac1c..6a6a00347 100644 --- a/tests/forms/FormScaffolderTest.php +++ b/tests/forms/FormScaffolderTest.php @@ -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( diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index 05b484a16..bb469f062 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -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'); diff --git a/tests/model/DataObjectTest.yml b/tests/model/DataObjectTest.yml index 309e46511..b3e5b156b 100644 --- a/tests/model/DataObjectTest.yml +++ b/tests/model/DataObjectTest.yml @@ -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 diff --git a/tests/model/DataObjectTest_Namespaced.php b/tests/model/DataObjectTest_Namespaced.php index e0402d62a..6370e2cfc 100644 --- a/tests/model/DataObjectTest_Namespaced.php +++ b/tests/model/DataObjectTest_Namespaced.php @@ -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' + ); } diff --git a/tests/model/PolymorphicHasManyListTest.php b/tests/model/PolymorphicHasManyListTest.php new file mode 100644 index 000000000..8a377453d --- /dev/null +++ b/tests/model/PolymorphicHasManyListTest.php @@ -0,0 +1,106 @@ +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); + } +} diff --git a/tests/model/UnsavedRelationListTest.php b/tests/model/UnsavedRelationListTest.php index b65a32751..7ad96c567 100644 --- a/tests/model/UnsavedRelationListTest.php +++ b/tests/model/UnsavedRelationListTest.php @@ -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(