Skip to content
Browse files

Merge pull request #922 from simonwelsh/rellist-presave

NEW: Allows setting of has_many and many_many relations before writing
  • Loading branch information...
2 parents 65002f6 + bbc4443 commit a108f67476877124cca1a282ba408a06e9a181a1 @chillu chillu committed
View
14 docs/en/topics/datamodel.md
@@ -569,13 +569,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
View
2 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();
/**
*
View
32 model/DataObject.php
@@ -158,6 +158,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 @@ public function write($showDebug = false, $forceInsert = false, $forceWrite = fa
}
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 @@ public function getComponents($componentName, $filter = "", $sort = "", $join =
. " 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 @@ public function getRemoteJoinField($component, $type = 'has_many') {
*/
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));
View
432 model/UnsavedRelationList.php
@@ -0,0 +1,432 @@
+<?php
+
+/**
+ * An ArrayList that represents an unsaved relation.
+ *
+ * has_many and many_many relations cannot be saved until after the DataObject they're
+ * on has been written. This List pretends to be a RelationList and stores the related
+ * objects in memory.
+ *
+ * It can store both saved objects (as IDs) or unsaved objects (as instances of
+ * $dataClass). Unsaved objects are then written when the list is saved into an instance
+ * of RelationList.
+ *
+ * Most methods that alter the list of objects throw LogicExceptions.
+ */
+class UnsavedRelationList extends ArrayList {
+ /**
+ * The DataObject class name that this relation is on
+ *
+ * @var string
+ */
+ protected $baseClass;
+
+ /**
+ * The name of the relation
+ *
+ * @var string
+ */
+ protected $relationName;
+
+ /**
+ * The DataObject class name that this relation is querying
+ *
+ * @var string
+ */
+ protected $dataClass;
+
+ /**
+ * The extra fields associated with the relation
+ *
+ * @var array
+ */
+ protected $extraFields = array();
+
+ /**
+ * Create a new UnsavedRelationList
+ *
+ * @param string $dataClass The DataObject class used in the relation
+ */
+ public function __construct($baseClass, $relationName, $dataClass) {
+ $this->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.");
+ }
+ /**#@-*/
+}
View
184 tests/model/UnsavedRelationListTest.php
@@ -0,0 +1,184 @@
+<?php
+
+class UnsavedRelationListTest extends SapphireTest {
+ public static $fixture_file = 'UnsavedRelationListTest.yml';
+
+ protected $extraDataObjects = array('UnsavedRelationListTest_DataObject');
+
+ public function testReturnedList() {
+ $object = new UnsavedRelationListTest_DataObject;
+ $children = $object->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',
+ ),
+ );
+}
View
7 tests/model/UnsavedRelationListTest.yml
@@ -0,0 +1,7 @@
+UnsavedRelationListTest_DataObject:
+ ObjectA:
+ Name: A
+ ObjectB:
+ Name: B
+ ObjectC:
+ Name: C

0 comments on commit a108f67

Please sign in to comment.
Something went wrong with that request. Please try again.