Skip to content

Commit

Permalink
API Polymorphic has_one behaviour
Browse files Browse the repository at this point in the history
  • Loading branch information
tractorcow committed Mar 17, 2014
1 parent 26f805f commit 7c60c73
Show file tree
Hide file tree
Showing 14 changed files with 930 additions and 75 deletions.
25 changes: 15 additions & 10 deletions dev/FixtureBlueprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,15 @@ public function createObject($identifier, $data = null, $fixtures = null) {
$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();
Expand Down Expand Up @@ -261,11 +264,13 @@ protected function invokeCallbacks($type, $args = array()) {
* 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);
Expand Down
1 change: 1 addition & 0 deletions dev/TestRunner.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public static function use_test_manifest() {
// 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() {
Expand Down
3 changes: 2 additions & 1 deletion docs/en/reference/database-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions docs/en/topics/datamodel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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""
Expand Down
5 changes: 4 additions & 1 deletion forms/FormScaffolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,16 @@ public function getFieldList() {
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);
Expand Down
174 changes: 131 additions & 43 deletions model/DataObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,20 @@ public static function custom_database_fields($class) {
// 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;
Expand Down Expand Up @@ -1412,7 +1425,8 @@ public function getClassAncestry() {

/**
* 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
*
Expand All @@ -1427,24 +1441,40 @@ public function getComponent($componentName) {
$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'.");
Expand Down Expand Up @@ -1489,15 +1519,21 @@ public function getComponents($componentName, $filter = "", $sort = "", $join =
return $this->unsavedRelations[$componentName];
}

$joinField = $this->getRemoteJoinField($componentName, 'has_many');

$result = HasManyList::create($componentClass, $joinField);
if($this->model) $result->setDataModel($this->model);
$result = $result->forForeignID($this->ID);
// 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);
}

$result = $result->where($filter)->limit($limit)->sort($sort);
if($this->model) $result->setDataModel($this->model);

return $result;
return $result
->forForeignID($this->ID)
->where($filter)
->limit($limit)
->sort($sort);
}

/**
Expand Down Expand Up @@ -1540,17 +1576,23 @@ public function getRelationClass($relationName) {
}

/**
* 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, '.'))) {
Expand All @@ -1559,28 +1601,56 @@ public function getRemoteJoinField($component, $type = 'has_many') {
);
}

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);
}


// Reference remote has_one to check against
$remoteRelations = Config::inst()->get($remoteClass, 'has_one');
if(!is_array($remoteRelations)) {
$remoteRelations = array();

// 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;
}
}
}
$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';
// 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);
}

/**
Expand All @@ -1602,20 +1672,24 @@ public function getManyManyComponents($componentName, $filter = "", $sort = "",
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
*
Expand Down Expand Up @@ -2463,9 +2537,17 @@ public function hasOwnTableDatabaseField($field) {

// 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
Expand Down Expand Up @@ -2704,6 +2786,12 @@ public function dbObject($fieldName) {
} 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);

}
}

Expand Down
Loading

0 comments on commit 7c60c73

Please sign in to comment.