diff --git a/docs/en/topics/datamodel.md b/docs/en/topics/datamodel.md index bb437734a..be0f2a1c7 100755 --- a/docs/en/topics/datamodel.md +++ b/docs/en/topics/datamodel.md @@ -553,13 +553,25 @@ See `[api:DataObject::$has_many]` for more info on the described relations. // can be accessed by $myTeam->ActivePlayers() public function ActivePlayers() { - return $this->Players("Status='Active'"); + return $this->Players()->filter('Status', 'Active'); } } Note: Adding new records to a filtered `RelationList` like in the example above doesn't automatically set the filtered criteria on the added record. +### Relations on Unsaved Objects + +You can also set *has_many* and *many_many* relations before the `DataObject` is saved. This behaviour uses the +`[api:UnsavedRelationList]` and converts it into the correct `RelationList` when saving the `DataObject` for the +first time. + +This unsaved lists will also recursively save any unsaved objects that they contain. + +As these lists are not backed by the database, most of the filtering methods on `DataList` cannot be used on a +list of this type. As such, an `UnsavedRelationList` should only be used for setting a relation before saving an +object, not for displaying the objects contained in the relation. + ## Validation and Constraints Traditionally, validation in SilverStripe has been mostly handled on the controller diff --git a/model/ArrayList.php b/model/ArrayList.php index 3496fe84c..dccd6b3e2 100644 --- a/model/ArrayList.php +++ b/model/ArrayList.php @@ -12,7 +12,7 @@ class ArrayList extends ViewableData implements SS_List, SS_Filterable, SS_Sorta * * @var array */ - protected $items; + protected $items = array(); /** * diff --git a/model/DataObject.php b/model/DataObject.php index 112e8b019..a5f37b79c 100644 --- a/model/DataObject.php +++ b/model/DataObject.php @@ -157,6 +157,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ protected $components; + /** + * Non-static cache of has_many and many_many relations that can't be written until this object is saved. + */ + protected $unsavedRelations; + /** * Returns when validation on DataObjects is enabled. * @return bool @@ -1188,6 +1193,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } DB::manipulate($manipulation); + + // If there's any relations that couldn't be saved before, save them now (we have an ID here) + if($this->unsavedRelations) { + foreach($this->unsavedRelations as $name => $list) { + $list->changeToList($this->$name()); + } + $this->unsavedRelations = array(); + } + $this->onAfterWrite(); $this->changed = null; @@ -1365,6 +1379,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity . " on class '$this->class'", E_USER_ERROR); } + // If we haven't been written yet, we can't save these relations, so use a list that handles this case + if(!$this->ID) { + if(!isset($this->unsavedRelations[$componentName])) { + $this->unsavedRelations[$componentName] = + new UnsavedRelationList($this->class, $componentName, $componentClass); + } + return $this->unsavedRelations[$componentName]; + } + $joinField = $this->getRemoteJoinField($componentName, 'has_many'); $result = new HasManyList($componentClass, $joinField); @@ -1473,6 +1496,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getManyManyComponents($componentName, $filter = "", $sort = "", $join = "", $limit = "") { list($parentClass, $componentClass, $parentField, $componentField, $table) = $this->many_many($componentName); + + // If we haven't been written yet, we can't save these relations, so use a list that handles this case + if(!$this->ID) { + if(!isset($this->unsavedRelations[$componentName])) { + $this->unsavedRelations[$componentName] = + new UnsavedRelationList($parentClass, $componentName, $componentClass); + } + return $this->unsavedRelations[$componentName]; + } $result = Injector::inst()->create('ManyManyList', $componentClass, $table, $componentField, $parentField, $this->many_many_extraFields($componentName)); diff --git a/model/UnsavedRelationList.php b/model/UnsavedRelationList.php new file mode 100644 index 000000000..39b52e973 --- /dev/null +++ b/model/UnsavedRelationList.php @@ -0,0 +1,432 @@ +baseClass = $baseClass; + $this->relationName = $relationName; + $this->dataClass = $dataClass; + } + + /** + * Add an item to this relationship + * + * @param $extraFields A map of additional columns to insert into the joinTable in the case of a many_many relation + */ + public function add($item, $extraFields = null) { + $this->push($item, $extraFields); + } + + /** + * Save all the items in this list into the RelationList + * + * @param RelationList $list + */ + public function changeToList(RelationList $list) { + foreach($this->items as $key => $item) { + if(is_object($item)) { + $item->write(); + } + $list->add($item, $this->extraFields[$key]); + } + } + + /** + * Pushes an item onto the end of this list. + * + * @param array|object $item + */ + public function push($item, $extraFields = null) { + if((is_object($item) && !$item instanceof $this->dataClass) + || (!is_object($item) && !is_numeric($item))) { + throw new InvalidArgumentException( + "UnsavedRelationList::add() expecting a $this->dataClass object, or ID value", + E_USER_ERROR); + } + if(is_object($item) && $item->ID) { + $item = $item->ID; + } + $this->extraFields[] = $extraFields; + parent::push($item); + } + + /** + * Get the dataClass name for this relation, ie the DataObject ClassName + * + * @return string + */ + public function dataClass() { + return $this->dataClass; + } + + /** + * Returns an Iterator for this relation. + * + * @return ArrayIterator + */ + public function getIterator() { + return new ArrayIterator($this->toArray()); + } + + /** + * Return an array of the actual items that this relation contains at this stage. + * This is when the query is actually executed. + * + * @return array + */ + public function toArray() { + $items = array(); + foreach($this->items as $key => $item) { + if(is_numeric($item)) { + $item = DataObject::get_by_id($this->dataClass, $item); + } + if(!empty($this->extraFields[$key])) { + $item->update($this->extraFields[$key]); + } + $items[] = $item; + } + return $items; + } + + /** + * Add a number of items to the relation. + * + * @param array $items Items to add, as either DataObjects or IDs. + * @return DataList + */ + public function addMany($items) { + foreach($items as $item) { + $this->add($item); + } + return $this; + } + + /** + * Returns true if the given column can be used to filter the records. + */ + public function canFilterBy($by) { + return false; + } + + + /** + * Returns true if the given column can be used to sort the records. + */ + public function canSortBy($by) { + return false; + } + + /** + * Remove all items from this relation. + */ + public function removeAll() { + $this->items = array(); + $this->extraFields = array(); + } + + /** + * Remove the items from this list with the given IDs + * + * @param array $idList + */ + public function removeMany($items) { + $this->items = array_diff($this->items, $items); + return $this; + } + + /** + * Removes items from this list which are equal. + * + * @param string $field unused + */ + public function removeDuplicates($field = 'ID') { + $this->items = array_unique($this->items); + } + + /** + * Sets the Relation to be the given ID list. + * Records will be added and deleted as appropriate. + * + * @param array $idList List of IDs. + */ + public function setByIDList($idList) { + $this->removeAll(); + $this->addMany($idList); + } + + /** + * Returns the first item in the list + * + * @return mixed + */ + public function first() { + $item = reset($this->items); + if(is_numeric($item)) { + $item = DataObject::get_by_id($this->dataClass, $item); + } + if(!empty($this->extraFields[key($this->items)])) { + $item->update($this->extraFields[key($this->items)]); + } + return $item; + } + + /** + * Returns the last item in the list + * + * @return mixed + */ + public function last() { + $item = end($this->items); + if(!empty($this->extraFields[key($this->items)])) { + $item->update($this->extraFields[key($this->items)]); + } + return $item; + } + + /** + * Returns an array of a single field value for all items in the list. + * + * @param string $colName + * @return array + */ + public function column($colName = 'ID') { + $list = new ArrayList($this->toArray()); + return $list->column('ID'); + } + + /** + * Set the ID of the record that this RelationList is linking. + * + * Adds the + * + * @param $id A single ID, or an array of IDs + */ + public function setForeignID($id) { + $class = singleton($this->baseClass); + $class->ID = 1; + return $class->{$this->relationName}()->setForeignID($id); + } + + /** + * Returns a copy of this list with the relationship linked to the given foreign ID. + * @param $id An ID or an array of IDs. + */ + public function forForeignID($id) { + return $this->setForeignID($id); + } + + /** + * Return the DBField object that represents the given field on the related class. + * + * @param string $fieldName Name of the field + * @return DBField The field as a DBField object + */ + public function dbObject($fieldName) { + return singleton($this->dataClass)->dbObject($fieldName); + } + + /**#@+ + * Prevents calling DataList methods that rely on the objects being saved + */ + public function addFilter() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function alterDataQuery() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function avg() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function byIDs() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function byID($id) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function dataQuery() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function exclude() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function filter() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function getIDList() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function getRange($offset, $length) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function getRelationName() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function innerJoin() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function insertFirst() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function join() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function leftJoin() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function limit($length, $offset = 0) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function map($keyField = 'ID', $titleField = 'Title') { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function max() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function merge($with) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function min() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function newObject() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function offsetExists($offset) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function offsetGet($offset) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function offsetSet($offset, $value) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function offsetUnset($offset) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function pop() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function relation() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function removeByFilter() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function removeByID() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function reverse() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function setDataModel() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function setDataQuery() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function setQueriedColumns() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function shift() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function sql() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function subtract() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function sum() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function unshift($item) { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + + public function where() { + throw new LogicException(__FUNCTION__ . " can't be called on an UnsavedRelationList."); + } + /**#@-*/ +} diff --git a/tests/model/UnsavedRelationListTest.php b/tests/model/UnsavedRelationListTest.php new file mode 100644 index 000000000..57f011eb6 --- /dev/null +++ b/tests/model/UnsavedRelationListTest.php @@ -0,0 +1,184 @@ +Children(); + $siblings = $object->Siblings(); + $this->assertEquals($children, $object->Children(), + 'Returned UnsavedRelationList should be the same.'); + $this->assertEquals($siblings, $object->Siblings(), + 'Returned UnsavedRelationList should be the same.'); + + $object->write(); + $this->assertInstanceOf('RelationList', $object->Children()); + $this->assertNotEquals($children, $object->Children(), + 'Return should be a RelationList after first write'); + $this->assertInstanceOf('RelationList', $object->Siblings()); + $this->assertNotEquals($siblings, $object->Siblings(), + 'Return should be a RelationList after first write'); + } + + public function testHasManyExisting() { + $object = new UnsavedRelationListTest_DataObject; + + $children = $object->Children(); + $children->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectA')); + $children->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectB')); + $children->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectC')); + + $children = $object->Children(); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $children); + + $object->write(); + + $this->assertNotEquals($children, $object->Children()); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $object->Children()); + } + + public function testManyManyExisting() { + $object = new UnsavedRelationListTest_DataObject; + + $Siblings = $object->Siblings(); + $Siblings->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectA')); + $Siblings->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectB')); + $Siblings->add($this->objFromFixture('UnsavedRelationListTest_DataObject', 'ObjectC')); + + $siblings = $object->Siblings(); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $siblings); + + $object->write(); + + $this->assertNotEquals($siblings, $object->Siblings()); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $object->Siblings()); + } + + public function testHasManyNew() { + $object = new UnsavedRelationListTest_DataObject; + + $children = $object->Children(); + $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->Children(); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $children); + + $object->write(); + + $this->assertNotEquals($children, $object->Children()); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $object->Children()); + } + + public function testManyManyNew() { + $object = new UnsavedRelationListTest_DataObject; + + $Siblings = $object->Siblings(); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'A'))); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'B'))); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'C'))); + + $siblings = $object->Siblings(); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $siblings); + + $object->write(); + + $this->assertNotEquals($siblings, $object->Siblings()); + + $this->assertDOSEquals(array( + array('Name' => 'A'), + array('Name' => 'B'), + array('Name' => 'C') + ), $object->Siblings()); + } + + public function testManyManyExtraFields() { + $object = new UnsavedRelationListTest_DataObject; + + $Siblings = $object->Siblings(); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'A')), array('Number' => 1)); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'B')), array('Number' => 2)); + $Siblings->add(new UnsavedRelationListTest_DataObject(array('Name' => 'C')), array('Number' => 3)); + + $siblings = $object->Siblings(); + + $this->assertDOSEquals(array( + array('Name' => 'A', 'Number' => 1), + array('Name' => 'B', 'Number' => 2), + array('Name' => 'C', 'Number' => 3) + ), $siblings); + + $object->write(); + + $this->assertNotEquals($siblings, $object->Siblings()); + + $this->assertDOSEquals(array( + array('Name' => 'A', 'Number' => 1), + array('Name' => 'B', 'Number' => 2), + array('Name' => 'C', 'Number' => 3) + ), $object->Siblings()); + } +} + +class UnsavedRelationListTest_DataObject extends DataObject implements TestOnly { + public static $db = array( + 'Name' => 'Varchar', + ); + + public static $has_one = array( + 'Parent' => 'UnsavedRelationListTest_DataObject', + ); + + public static $has_many = array( + 'Children' => 'UnsavedRelationListTest_DataObject', + ); + + public static $many_many = array( + 'Siblings' => 'UnsavedRelationListTest_DataObject', + ); + + public static $many_many_extraFields = array( + 'Siblings' => array( + 'Number' => 'Int', + ), + ); +} diff --git a/tests/model/UnsavedRelationListTest.yml b/tests/model/UnsavedRelationListTest.yml new file mode 100644 index 000000000..e0ffd14be --- /dev/null +++ b/tests/model/UnsavedRelationListTest.yml @@ -0,0 +1,7 @@ +UnsavedRelationListTest_DataObject: + ObjectA: + Name: A + ObjectB: + Name: B + ObjectC: + Name: C