API Implement many_many through

API Remove DataObject::validateModelDefinitions, and move to DataObjectSchema
API Remove deprecated 3.0 syntax for addSelect()
API made DataList::createDataObject public
API Move component parsing logic to DataObjectSchema
API Remove support for triangular has_many / belongs_many relationships
This commit is contained in:
Damian Mooyman 2016-09-26 18:22:19 +13:00
parent 406ff5b183
commit e7303170c2
16 changed files with 1514 additions and 399 deletions

View File

@ -736,9 +736,13 @@ class DataList extends ViewableData implements SS_List, Filterable, Sortable, Li
* @param array $row
* @return DataObject
*/
protected function createDataObject($row) {
public function createDataObject($row) {
$class = $this->dataClass;
if (empty($row['ClassName'])) {
$row['ClassName'] = $class;
}
// Failover from RecordClassName to ClassName
if(empty($row['RecordClassName'])) {
$row['RecordClassName'] = $row['ClassName'];

View File

@ -153,6 +153,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
*/
protected $record;
/**
* If selected through a many_many through relation, this is the instance of the through record
*
* @var DataObject
*/
protected $joinRecord;
/**
* Represents a field that hasn't changed (before === after, thus before == after)
*/
@ -1422,13 +1429,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return DataObject $this
*/
public function writeComponents($recursive = false) {
if(!$this->components) {
return $this;
if($this->components) {
foreach ($this->components as $component) {
$component->write(false, false, false, $recursive);
}
}
foreach($this->components as $component) {
$component->write(false, false, false, $recursive);
if ($join = $this->getJoin()) {
$join->write(false, false, false, $recursive);
}
return $this;
}
@ -1513,7 +1523,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$joinID = $this->getField($joinField);
// Extract class name for polymorphic relations
if($class === 'SilverStripe\\ORM\\DataObject') {
if($class === __CLASS__) {
$class = $this->getField($componentName . 'Class');
if(empty($class)) return null;
}
@ -1624,12 +1634,20 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return string Class name, or null if not found.
*/
public function getRelationClass($relationName) {
// Parse many_many
$manyManyComponent = $this->manyManyComponent($relationName);
if ($manyManyComponent) {
list(
$relationClass, $parentClass, $componentClass,
$parentField, $childField, $tableOrClass
) = $manyManyComponent;
return $componentClass;
}
// Go through all relationship configuration fields.
$candidates = array_merge(
($relations = Config::inst()->get($this->class, 'has_one')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'has_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'many_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'belongs_many_many')) ? $relations : array(),
($relations = Config::inst()->get($this->class, 'belongs_to')) ? $relations : array()
);
@ -1682,8 +1700,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @throws InvalidArgumentException
*/
public function inferReciprocalComponent($remoteClass, $remoteRelation) {
/** @var DataObject $remote */
$remote = $remoteClass::singleton();
$remote = DataObject::singleton($remoteClass);
$class = $remote->getRelationClass($remoteRelation);
// Validate arguments
@ -1755,13 +1772,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
case 'many_many':
case 'belongs_many_many': {
// Get components and extra fields from parent
list($componentClass, $parentClass, $componentField, $parentField, $table)
list($relationClass, $componentClass, $parentClass, $componentField, $parentField, $table)
= $remote->manyManyComponent($remoteRelation);
$extraFields = $remote->manyManyExtraFieldsForComponent($remoteRelation) ?: array();
// Reverse parent and component fields and create an inverse ManyManyList
/** @var ManyManyList $result */
$result = ManyManyList::create($componentClass, $table, $componentField, $parentField, $extraFields);
/** @var RelationList $result */
$result = Injector::inst()->create(
$relationClass, $componentClass, $table, $componentField, $parentField, $extraFields
);
if($this->model) {
$result->setDataModel($this->model);
}
@ -1794,78 +1813,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @throws Exception
*/
public function getRemoteJoinField($component, $type = 'has_many', &$polymorphic = false) {
// Extract relation from current object
if($type === 'has_many') {
$remoteClass = $this->hasManyComponent($component, false);
} else {
$remoteClass = $this->belongsToComponent($component, false);
}
if(empty($remoteClass)) {
throw new Exception("Unknown $type component '$component' on class '$this->class'");
}
if(!ClassInfo::exists(strtok($remoteClass, '.'))) {
throw new Exception(
"Class '$remoteClass' not found, but used in $type component '$component' on class '$this->class'"
);
}
// 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');
// 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}"
);
}
// Inspect resulting found relation
if($remoteRelations[$remoteField] === 'SilverStripe\ORM\DataObject') {
$polymorphic = true;
return $remoteField; // Composite polymorphic field does not include 'ID' suffix
} else {
$polymorphic = false;
return $remoteField . 'ID';
}
return $this
->getSchema()
->getRemoteJoinField(get_class($this), $component, $type, $polymorphic);
}
/**
* Returns a many-to-many component, as a ManyManyList.
* @param string $componentName Name of the many-many component
* @return ManyManyList|UnsavedRelationList The set of components
* @return RelationList|UnsavedRelationList The set of components
*/
public function getManyManyComponents($componentName) {
$manyManyComponent = $this->manyManyComponent($componentName);
@ -1877,7 +1833,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
));
}
list($parentClass, $componentClass, $parentField, $componentField, $table) = $manyManyComponent;
list($relationClass, $parentClass, $componentClass, $parentField, $componentField, $tableOrClass)
= $manyManyComponent;
// If we haven't been written yet, we can't save these relations, so use a list that handles this case
if(!$this->ID) {
@ -1889,8 +1846,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
}
$extraFields = $this->manyManyExtraFieldsForComponent($componentName) ?: array();
/** @var ManyManyList $result */
$result = ManyManyList::create($componentClass, $table, $componentField, $parentField, $extraFields);
/** @var RelationList $result */
$result = Injector::inst()->create(
$relationClass,
$componentClass,
$tableOrClass,
$componentField,
$parentField,
$extraFields
);
// Store component data in query meta-data
@ -1929,36 +1893,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return string|null
*/
public function hasOneComponent($component) {
$classes = ClassInfo::ancestry($this, true);
foreach(array_reverse($classes) as $class) {
$hasOnes = Config::inst()->get($class, 'has_one', Config::UNINHERITED);
if(isset($hasOnes[$component])) {
return $hasOnes[$component];
}
}
return null;
return $this->getSchema()->hasOneComponent(get_class($this), $component);
}
/**
* Returns the class of a remote belongs_to relationship. If no component is specified a map of all components and
* their class name will be returned.
*
* @param string $component - Name of component
* @param bool $classOnly If this is TRUE, than any has_many relationships in the form "ClassName.Field" will have
* the field data stripped off. It defaults to TRUE.
* @return string|array
*/
public function belongsTo($component = null, $classOnly = true) {
if($component) {
Deprecation::notice(
'4.0',
'Please use DataObject::belongsToComponent() instead of passing a component name to belongsTo()',
Deprecation::SCOPE_GLOBAL
);
return $this->belongsToComponent($component, $classOnly);
}
public function belongsTo($classOnly = true) {
$belongsTo = (array)Config::inst()->get($this->class, 'belongs_to', Config::INHERITED);
if($belongsTo && $classOnly) {
return preg_replace('/(.+)?\..+/', '$1', $belongsTo);
@ -1975,15 +1921,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return string|null
*/
public function belongsToComponent($component, $classOnly = true) {
$belongsTo = (array)Config::inst()->get($this->class, 'belongs_to', Config::INHERITED);
if($belongsTo && array_key_exists($component, $belongsTo)) {
$belongsTo = $belongsTo[$component];
} else {
return null;
}
return ($classOnly) ? preg_replace('/(.+)?\..+/', '$1', $belongsTo) : $belongsTo;
return $this->getSchema()->belongsToComponent(get_class($this), $component, $classOnly);
}
/**
@ -2033,21 +1971,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* Gets the class of a one-to-many relationship. If no $component is specified then an array of all the one-to-many
* relationships and their classes will be returned.
*
* @param string $component Deprecated - Name of component
* @param bool $classOnly If this is TRUE, than any has_many relationships in the form "ClassName.Field" will have
* the field data stripped off. It defaults to TRUE.
* @return string|array|false
*/
public function hasMany($component = null, $classOnly = true) {
if($component) {
Deprecation::notice(
'4.0',
'Please use DataObject::hasManyComponent() instead of passing a component name to hasMany()',
Deprecation::SCOPE_GLOBAL
);
return $this->hasManyComponent($component, $classOnly);
}
public function hasMany($classOnly = true) {
$hasMany = (array)Config::inst()->get($this->class, 'has_many', Config::INHERITED);
if($hasMany && $classOnly) {
return preg_replace('/(.+)?\..+/', '$1', $hasMany);
@ -2064,15 +1992,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
* @return string|null
*/
public function hasManyComponent($component, $classOnly = true) {
$hasMany = (array)Config::inst()->get($this->class, 'has_many', Config::INHERITED);
if($hasMany && array_key_exists($component, $hasMany)) {
$hasMany = $hasMany[$component];
} else {
return null;
}
return ($classOnly) ? preg_replace('/(.+)?\..+/', '$1', $hasMany) : $hasMany;
return $this->getSchema()->hasManyComponent(get_class($this), $component, $classOnly);
}
/**
@ -2149,78 +2069,27 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
}
/**
* Return information about a specific many_many component. Returns a numeric array of:
* Return information about a specific many_many component. Returns a numeric array.
* The first item in the array will be the class name of the relation: either
* RELATION_MANY_MANY or RELATION_MANY_MANY_THROUGH constant value.
*
* Standard many_many return type is:
*
* array(
* <classname>, The class that relation is defined in e.g. "Product"
* <candidateName>, The target class of the relation e.g. "Category"
* <parentField>, The field name pointing to <classname>'s table e.g. "ProductID"
* <childField>, The field name pointing to <candidatename>'s table e.g. "CategoryID"
* <joinTable> The join table between the two classes e.g. "Product_Categories"
* <manyManyClass>, Name of class for relation
* <classname>, The class that relation is defined in e.g. "Product"
* <candidateName>, The target class of the relation e.g. "Category"
* <parentField>, The field name pointing to <classname>'s table e.g. "ProductID"
* <childField>, The field name pointing to <candidatename>'s table e.g. "CategoryID"
* <joinTableOrRelation> The join table between the two classes e.g. "Product_Categories".
* If the class name is 'ManyManyThroughList' then this is the name of the
* has_many relation.
* )
* @param string $component The component name
* @return array|null
*/
public function manyManyComponent($component) {
$classes = $this->getClassAncestry();
foreach($classes as $class) {
$manyMany = Config::inst()->get($class, 'many_many', Config::UNINHERITED);
// Check if the component is defined in many_many on this class
if(isset($manyMany[$component])) {
$candidate = $manyMany[$component];
$classTable = static::getSchema()->tableName($class);
$candidateTable = static::getSchema()->tableName($candidate);
$parentField = "{$classTable}ID";
$childField = $class === $candidate ? "ChildID" : "{$candidateTable}ID";
$joinTable = "{$classTable}_{$component}";
return array($class, $candidate, $parentField, $childField, $joinTable);
}
// Check if the component is defined in belongs_many_many on this class
$belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED);
if(!isset($belongsManyMany[$component])) {
continue;
}
// Extract class and relation name from dot-notation
$candidate = $belongsManyMany[$component];
$relationName = null;
if(strpos($candidate, '.') !== false) {
list($candidate, $relationName) = explode('.', $candidate, 2);
}
$candidateTable = static::getSchema()->tableName($candidate);
$childField = $candidateTable . "ID";
// We need to find the inverse component name, if not explicitly given
$otherManyMany = Config::inst()->get($candidate, 'many_many', Config::UNINHERITED);
if(!$relationName && $otherManyMany) {
foreach($otherManyMany as $inverseComponentName => $childClass) {
if($childClass === $class || is_subclass_of($class, $childClass)) {
$relationName = $inverseComponentName;
break;
}
}
}
// Check valid relation found
if(!$relationName || !$otherManyMany || !isset($otherManyMany[$relationName])) {
throw new LogicException("Inverse component of $candidate not found ({$this->class})");
}
// If we've got a relation name (extracted from dot-notation), we can already work out
// the join table and candidate class name...
$childClass = $otherManyMany[$relationName];
$joinTable = "{$candidateTable}_{$relationName}";
// If we could work out the join table, we've got all the info we need
if ($childClass === $candidate) {
$parentField = "ChildID";
} else {
$childTable = static::getSchema()->tableName($childClass);
$parentField = "{$childTable}ID";
}
return array($class, $candidate, $parentField, $childField, $joinTable);
}
return null;
return $this->getSchema()->manyManyComponent(get_class($this), $component);
}
/**
@ -3407,10 +3276,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$indexes = $this->databaseIndexes();
// Validate relationship configuration
$this->validateModelDefinitions();
if($fields) {
$hasAutoIncPK = get_parent_class($this) === 'SilverStripe\ORM\DataObject';
$hasAutoIncPK = get_parent_class($this) === __CLASS__;
DB::require_table(
$table, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), $extensions
);
@ -3421,29 +3288,34 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
// Build any child tables for many_many items
if($manyMany = $this->uninherited('many_many')) {
$extras = $this->uninherited('many_many_extraFields');
foreach($manyMany as $relationship => $childClass) {
// Build field list
if($this->class === $childClass) {
$childField = "ChildID";
} else {
$childTable = $this->getSchema()->tableName($childClass);
$childField = "{$childTable}ID";
foreach($manyMany as $component => $spec) {
// Get many_many spec
$manyManyComponent = $this->getSchema()->manyManyComponent(get_class($this), $component);
list(
$relationClass, $parentClass, $componentClass,
$parentField, $childField, $tableOrClass
) = $manyManyComponent;
// Skip if backed by actual class
if (class_exists($tableOrClass)) {
continue;
}
// Build fields
$manymanyFields = array(
"{$table}ID" => "Int",
$parentField => "Int",
$childField => "Int",
);
if(isset($extras[$relationship])) {
$manymanyFields = array_merge($manymanyFields, $extras[$relationship]);
if(isset($extras[$component])) {
$manymanyFields = array_merge($manymanyFields, $extras[$component]);
}
// Build index list
$manymanyIndexes = array(
"{$table}ID" => true,
$parentField => true,
$childField => true,
);
$manyManyTable = "{$table}_$relationship";
DB::require_table($manyManyTable, $manymanyFields, $manymanyIndexes, true, null, $extensions);
DB::require_table($tableOrClass, $manymanyFields, $manymanyIndexes, true, null, $extensions);
}
}
@ -3451,42 +3323,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
$this->extend('augmentDatabase', $dummy);
}
/**
* Validate that the configured relations for this class use the correct syntaxes
* @throws LogicException
*/
protected function validateModelDefinitions() {
$modelDefinitions = array(
'db' => Config::inst()->get($this->class, 'db', Config::UNINHERITED),
'has_one' => Config::inst()->get($this->class, 'has_one', Config::UNINHERITED),
'has_many' => Config::inst()->get($this->class, 'has_many', Config::UNINHERITED),
'belongs_to' => Config::inst()->get($this->class, 'belongs_to', Config::UNINHERITED),
'many_many' => Config::inst()->get($this->class, 'many_many', Config::UNINHERITED),
'belongs_many_many' => Config::inst()->get($this->class, 'belongs_many_many', Config::UNINHERITED),
'many_many_extraFields' => Config::inst()->get($this->class, 'many_many_extraFields', Config::UNINHERITED)
);
foreach($modelDefinitions as $defType => $relations) {
if( ! $relations) continue;
foreach($relations as $k => $v) {
if($defType === 'many_many_extraFields') {
if(!is_array($v)) {
throw new LogicException("$this->class::\$many_many_extraFields has a bad entry: "
. var_export($k, true) . " => " . var_export($v, true)
. ". Each many_many_extraFields entry should map to a field specification array.");
}
} else {
if(!is_string($k) || is_numeric($k) || !is_string($v)) {
throw new LogicException("$this->class::$defType has a bad entry: "
. var_export($k, true). " => " . var_export($v, true) . ". Each map key should be a
relationship name, and the map value should be the data class to join to.");
}
}
}
}
}
/**
* Add default records to database. This function is called whenever the
* database is built, after the database tables have all been created. Overload
@ -4021,4 +3857,24 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity
}
}
/**
* If selected through a many_many through relation, this is the instance of the joined record
*
* @return DataObject
*/
public function getJoin() {
return $this->joinRecord;
}
/**
* Set joining object
*
* @param DataObject $object
* @return $this
*/
public function setJoin(DataObject $object) {
$this->joinRecord = $object;
return $this;
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\ORM;
use Exception;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\ORM\FieldType\DBComposite;
@ -118,7 +119,7 @@ class DataObjectSchema {
$class = ClassInfo::class_name($class);
$current = $class;
while ($next = get_parent_class($current)) {
if ($next === 'SilverStripe\ORM\DataObject') {
if ($next === DataObject::class) {
return $current;
}
$current = $next;
@ -169,8 +170,8 @@ class DataObjectSchema {
return;
}
$this->tableNames = [];
foreach(ClassInfo::subclassesFor('SilverStripe\ORM\DataObject') as $class) {
if($class === 'SilverStripe\ORM\DataObject') {
foreach(ClassInfo::subclassesFor(DataObject::class) as $class) {
if($class === DataObject::class) {
continue;
}
$table = $this->buildTableName($class);
@ -216,7 +217,7 @@ class DataObjectSchema {
*/
public function databaseFields($class) {
$class = ClassInfo::class_name($class);
if($class === 'SilverStripe\ORM\DataObject') {
if($class === DataObject::class) {
return [];
}
$this->cacheDatabaseFields($class);
@ -238,7 +239,7 @@ class DataObjectSchema {
*/
public function compositeFields($class, $aggregated = true) {
$class = ClassInfo::class_name($class);
if($class === 'SilverStripe\ORM\DataObject') {
if($class === DataObject::class) {
return [];
}
$this->cacheDatabaseFields($class);
@ -270,7 +271,7 @@ class DataObjectSchema {
// Ensure fixed fields appear at the start
$fixedFields = DataObject::config()->fixed_fields;
if(get_parent_class($class) === 'SilverStripe\ORM\DataObject') {
if(get_parent_class($class) === DataObject::class) {
// Merge fixed with ClassName spec and custom db fields
$dbFields = $fixedFields;
} else {
@ -291,7 +292,7 @@ class DataObjectSchema {
// Add in all has_ones
$hasOne = Config::inst()->get($class, 'has_one', Config::UNINHERITED) ?: array();
foreach($hasOne as $fieldName => $hasOneClass) {
if($hasOneClass === 'SilverStripe\ORM\DataObject') {
if($hasOneClass === DataObject::class) {
$compositeFields[$fieldName] = 'PolymorphicForeignKey';
} else {
$dbFields["{$fieldName}ID"] = 'ForeignKey';
@ -347,7 +348,7 @@ class DataObjectSchema {
public function classForField($candidateClass, $fieldName) {
// normalise class name
$candidateClass = ClassInfo::class_name($candidateClass);
if($candidateClass === 'SilverStripe\\ORM\\DataObject') {
if($candidateClass === DataObject::class) {
return null;
}
@ -367,4 +368,396 @@ class DataObjectSchema {
}
return null;
}
/**
* Return information about a specific many_many component. Returns a numeric array.
* The first item in the array will be the class name of the relation: either
* RELATION_MANY_MANY or RELATION_MANY_MANY_THROUGH constant value.
*
* Standard many_many return type is:
*
* array(
* <manyManyClass>, Name of class for relation
* <classname>, The class that relation is defined in e.g. "Product"
* <candidateName>, The target class of the relation e.g. "Category"
* <parentField>, The field name pointing to <classname>'s table e.g. "ProductID".
* <childField>, The field name pointing to <candidatename>'s table e.g. "CategoryID".
* <joinTableOrRelation> The join table between the two classes e.g. "Product_Categories".
* If the class name is 'ManyManyThroughList' then this is the name of the
* has_many relation.
* )
* @param string $class Name of class to get component for
* @param string $component The component name
* @return array|null
*/
public function manyManyComponent($class, $component) {
$classes = ClassInfo::ancestry($class);
foreach($classes as $parentClass) {
// Check if the component is defined in many_many on this class
$manyMany = Config::inst()->get($parentClass, 'many_many', Config::UNINHERITED);
if(isset($manyMany[$component])) {
return $this->parseManyManyComponent($parentClass, $component, $manyMany[$component]);
}
// Check if the component is defined in belongs_many_many on this class
$belongsManyMany = Config::inst()->get($parentClass, 'belongs_many_many', Config::UNINHERITED);
if(!isset($belongsManyMany[$component])) {
continue;
}
// Extract class and relation name from dot-notation
$childClass = $belongsManyMany[$component];
$relationName = null;
if(strpos($childClass, '.') !== false) {
list($childClass, $relationName) = explode('.', $childClass, 2);
}
// We need to find the inverse component name, if not explicitly given
if (!$relationName) {
$relationName = $this->getManyManyInverseRelationship($childClass, $parentClass);
}
// Check valid relation found
if (!$relationName) {
throw new LogicException("Inverse component of $childClass not found ({$class})");
}
// Build inverse relationship from other many_many, and swap parent/child
list($relationClass, $childClass, $parentClass, $childField, $parentField, $joinTable)
= $this->parseManyManyComponent($childClass, $relationName, $parentClass);
return [$relationClass, $parentClass, $childClass, $parentField, $childField, $joinTable];
}
return null;
}
/**
* Return data for a specific has_many component.
*
* @param string $class Parent class
* @param string $component
* @param bool $classOnly If this is TRUE, than any has_many relationships in the form
* "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
* @return string|null
*/
public function hasManyComponent($class, $component, $classOnly = true) {
$hasMany = (array)Config::inst()->get($class, 'has_many');
if(!isset($hasMany[$component])) {
return null;
}
// Remove has_one specifier if given
$hasMany = $hasMany[$component];
$hasManyClass = strtok($hasMany, '.');
// Validate
$this->checkRelationClass($class, $component, $hasManyClass, 'has_many');
return $classOnly ? $hasManyClass : $hasMany;
}
/**
* Return data for a specific has_one component.
*
* @param string $class
* @param string $component
* @return string|null
*/
public function hasOneComponent($class, $component) {
$hasOnes = Config::inst()->get($class, 'has_one');
if(!isset($hasOnes[$component])) {
return null;
}
// Validate
$relationClass = $hasOnes[$component];
$this->checkRelationClass($class, $component, $relationClass, 'has_one');
return $relationClass;
}
/**
* Return data for a specific belongs_to component.
*
* @param string $class
* @param string $component
* @param bool $classOnly If this is TRUE, than any has_many relationships in the
* form "ClassName.Field" will have the field data stripped off. It defaults to TRUE.
* @return string|null
*/
public function belongsToComponent($class, $component, $classOnly = true) {
$belongsTo = (array)Config::inst()->get($class, 'belongs_to');
if(!isset($belongsTo[$component])) {
return null;
}
// Remove has_one specifier if given
$belongsTo = $belongsTo[$component];
$belongsToClass = strtok($belongsTo, '.');
// Validate
$this->checkRelationClass($class, $component, $belongsToClass, 'belongs_to');
return $classOnly ? $belongsToClass : $belongsTo;
}
/**
*
* @param string $parentClass Parent class name
* @param string $component ManyMany name
* @param string|array $specification Declaration of many_many relation type
* @return array
*/
protected function parseManyManyComponent($parentClass, $component, $specification)
{
// Check if this is many_many_through
if (is_array($specification)) {
// Validate join, parent and child classes
$joinClass = $this->checkManyManyJoinClass($parentClass, $component, $specification);
$parentClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'from');
$joinChildClass = $this->checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, 'to');
return [
ManyManyThroughList::class,
$parentClass,
$joinChildClass,
$specification['from'] . 'ID',
$specification['to'] . 'ID',
$joinClass,
];
}
// Validate $specification class is valid
$this->checkRelationClass($parentClass, $component, $specification, 'many_many');
// automatic scaffolded many_many table
$classTable = $this->tableName($parentClass);
$parentField = "{$classTable}ID";
if ($parentClass === $specification) {
$childField = "ChildID";
} else {
$candidateTable = $this->tableName($specification);
$childField = "{$candidateTable}ID";
}
$joinTable = "{$classTable}_{$component}";
return [
ManyManyList::class,
$parentClass,
$specification,
$parentField,
$childField,
$joinTable,
];
}
/**
* Find a many_many on the child class that points back to this many_many
*
* @param string $childClass
* @param string $parentClass
* @return string|null
*/
protected function getManyManyInverseRelationship($childClass, $parentClass)
{
$otherManyMany = Config::inst()->get($childClass, 'many_many', Config::UNINHERITED);
if (!$otherManyMany) {
return null;
}
foreach ($otherManyMany as $inverseComponentName => $nextClass) {
if ($nextClass === $parentClass) {
return $inverseComponentName;
}
}
return null;
}
/**
* 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 $class
* @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
* @throws Exception
*/
public function getRemoteJoinField($class, $component, $type = 'has_many', &$polymorphic = false) {
// Extract relation from current object
if($type === 'has_many') {
$remoteClass = $this->hasManyComponent($class, $component, false);
} else {
$remoteClass = $this->belongsToComponent($class, $component, false);
}
if(empty($remoteClass)) {
throw new Exception("Unknown $type component '$component' on class '$class'");
}
if(!ClassInfo::exists(strtok($remoteClass, '.'))) {
throw new Exception(
"Class '$remoteClass' not found, but used in $type component '$component' on class '$class'"
);
}
// 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');
// 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($class)) 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 '$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 {$class}"
);
}
// Inspect resulting found relation
if($remoteRelations[$remoteField] === DataObject::class) {
$polymorphic = true;
return $remoteField; // Composite polymorphic field does not include 'ID' suffix
} else {
$polymorphic = false;
return $remoteField . 'ID';
}
}
/**
* Validate the to or from field on a has_many mapping class
*
* @param string $parentClass Name of parent class
* @param string $component Name of many_many component
* @param string $joinClass Class for the joined table
* @param array $specification Complete many_many specification
* @param string $key Name of key to check ('from' or 'to')
* @return string Class that matches the given relation
* @throws InvalidArgumentException
*/
protected function checkManyManyFieldClass($parentClass, $component, $joinClass, $specification, $key)
{
// Ensure value for this key exists
if (empty($specification[$key])) {
throw new InvalidArgumentException(
"many_many relation {$parentClass}.{$component} has missing {$key} which "
. "should be a has_one on class {$joinClass}"
);
}
// Check that the field exists on the given object
$relation = $specification[$key];
$relationClass = $this->hasOneComponent($joinClass, $relation);
if (empty($relationClass)) {
throw new InvalidArgumentException(
"many_many through relation {$parentClass}.{$component} {$key} references a field name "
. "{$joinClass}::{$relation} which is not a has_one"
);
}
// Check for polymorphic
if ($relationClass === DataObject::class) {
throw new InvalidArgumentException(
"many_many through relation {$parentClass}.{$component} {$key} references a polymorphic field "
. "{$joinClass}::{$relation} which is not supported"
);
}
// Validate bad types on parent relation
if ($key === 'from' && $relationClass !== $parentClass) {
throw new InvalidArgumentException(
"many_many through relation {$parentClass}.{$component} {$key} references a field name "
. "{$joinClass}::{$relation} of type {$relationClass}; {$parentClass} expected"
);
}
return $relationClass;
}
/**
* @param string $parentClass Name of parent class
* @param string $component Name of many_many component
* @param array $specification Complete many_many specification
* @return string Name of join class
*/
protected function checkManyManyJoinClass($parentClass, $component, $specification)
{
if (empty($specification['through'])) {
throw new InvalidArgumentException(
"many_many relation {$parentClass}.{$component} has missing through which should be "
. "a DataObject class name to be used as a join table"
);
}
$joinClass = $specification['through'];
if (!class_exists($joinClass)) {
throw new InvalidArgumentException(
"many_many relation {$parentClass}.{$component} has through class \"{$joinClass}\" which does not exist"
);
}
return $joinClass;
}
/**
* Validate a given class is valid for a relation
*
* @param string $class Parent class
* @param string $component Component name
* @param string $relationClass Candidate class to check
* @param string $type Relation type (e.g. has_one)
*/
protected function checkRelationClass($class, $component, $relationClass, $type)
{
if (!is_string($component) || is_numeric($component)) {
throw new InvalidArgumentException(
"{$class} has invalid {$type} relation name"
);
}
if (!is_string($relationClass)) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} is not a class name"
);
}
if (!class_exists($relationClass)) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} references class {$relationClass} which doesn't exist"
);
}
// Support polymorphic has_one
if ($type === 'has_one') {
$valid = is_a($relationClass, DataObject::class, true);
} else {
$valid = is_subclass_of($relationClass, DataObject::class, true);
}
if (!$valid) {
throw new InvalidArgumentException(
"{$type} relation {$class}.{$component} references class {$relationClass} "
. " which is not a subclass of " . DataObject::class
);
}
}
}

View File

@ -46,6 +46,13 @@ class DataQuery {
*/
protected $collidingFields = array();
/**
* Allows custom callback to be registered before getFinalisedQuery is called.
*
* @var DataQueryManipulator[]
*/
protected $dataQueryManipulators = [];
private $queriedColumns = null;
/**
@ -187,9 +194,14 @@ class DataQuery {
if($queriedColumns) {
$queriedColumns = array_merge($queriedColumns, array('Created', 'LastEdited', 'ClassName'));
}
$query = clone $this->query;
// Apply manipulators before finalising query
foreach($this->getDataQueryManipulators() as $manipulator) {
$manipulator->beforeGetFinalisedQuery($this, $queriedColumns, $query);
}
$schema = DataObject::getSchema();
$query = clone $this->query;
$baseDataClass = $schema->baseDataClass($this->dataClass());
$baseIDColumn = $schema->sqlColumnForField($baseDataClass, 'ID');
$ancestorClasses = ClassInfo::ancestry($this->dataClass(), true);
@ -310,6 +322,11 @@ class DataQuery {
$this->ensureSelectContainsOrderbyColumns($query);
// Apply post-finalisation manipulations
foreach($this->getDataQueryManipulators() as $manipulator) {
$manipulator->afterGetFinalisedQuery($this, $queriedColumns, $query);
}
return $query;
}
@ -732,9 +749,10 @@ class DataQuery {
throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass");
}
// Join via many_many
list($parentClass, $componentClass, $parentField, $componentField, $relationTable) = $component;
list($relationClass, $parentClass, $componentClass, $parentField, $componentField, $relationTable)
= $component;
$this->joinManyManyRelationship(
$parentClass, $componentClass, $parentField, $componentField, $relationTable
$relationClass, $parentClass, $componentClass, $parentField, $componentField, $relationTable
);
$modelClass = $componentClass;
@ -778,10 +796,8 @@ class DataQuery {
$localColumn = $schema->sqlColumnForField($localClass, "{$localField}ID");
$this->query->addLeftJoin($foreignBaseTable, "{$foreignIDColumn} = {$localColumn}");
/**
* add join clause to the component's ancestry classes so that the search filter could search on
* its ancestor fields.
*/
// Add join clause to the component's ancestry classes so that the search filter could search on
// its ancestor fields.
$ancestry = ClassInfo::ancestry($foreignClass, true);
if(!empty($ancestry)){
$ancestry = array_reverse($ancestry);
@ -833,10 +849,8 @@ class DataQuery {
$this->query->addLeftJoin($foreignTable, "{$foreignKeyIDColumn} = {$localIDColumn}");
}
/**
* add join clause to the component's ancestry classes so that the search filter could search on
* its ancestor fields.
*/
// Add join clause to the component's ancestry classes so that the search filter could search on
// its ancestor fields.
$ancestry = ClassInfo::ancestry($foreignClass, true);
$ancestry = array_reverse($ancestry);
foreach($ancestry as $ancestor) {
@ -850,20 +864,27 @@ class DataQuery {
/**
* Join table via many_many relationship
*
* @param string $relationClass
* @param string $parentClass
* @param string $componentClass
* @param string $parentField
* @param string $componentField
* @param string $relationTable Name of relation table
* @param string $relationClassOrTable Name of relation table
*/
protected function joinManyManyRelationship($parentClass, $componentClass, $parentField, $componentField, $relationTable) {
protected function joinManyManyRelationship(
$relationClass, $parentClass, $componentClass, $parentField, $componentField, $relationClassOrTable
) {
$schema = DataObject::getSchema();
if (class_exists($relationClassOrTable)) {
$relationClassOrTable = $schema->tableName($relationClassOrTable);
}
// Join on parent table
$parentIDColumn = $schema->sqlColumnForField($parentClass, 'ID');
$this->query->addLeftJoin(
$relationTable,
"\"$relationTable\".\"$parentField\" = {$parentIDColumn}"
$relationClassOrTable,
"\"$relationClassOrTable\".\"$parentField\" = {$parentIDColumn}"
);
// Join on base table of component class
@ -873,14 +894,12 @@ class DataQuery {
if (!$this->query->isJoinedTo($componentBaseTable)) {
$this->query->addLeftJoin(
$componentBaseTable,
"\"$relationTable\".\"$componentField\" = {$componentIDColumn}"
"\"$relationClassOrTable\".\"$componentField\" = {$componentIDColumn}"
);
}
/**
* add join clause to the component's ancestry classes so that the search filter could search on
* its ancestor fields.
*/
// Add join clause to the component's ancestry classes so that the search filter could search on
// its ancestor fields.
$ancestry = ClassInfo::ancestry($componentClass, true);
$ancestry = array_reverse($ancestry);
foreach($ancestry as $ancestor) {
@ -1017,4 +1036,26 @@ class DataQuery {
public function getQueryParams() {
return $this->queryParams;
}
/**
* Get query manipulators
*
* @return DataQueryManipulator[]
*/
public function getDataQueryManipulators()
{
return $this->dataQueryManipulators;
}
/**
* Assign callback to be invoked in getFinalisedQuery()
*
* @param DataQueryManipulator $manipulator
* @return $this
*/
public function pushQueryManipulator(DataQueryManipulator $manipulator)
{
$this->dataQueryManipulators[] = $manipulator;
return $this;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace SilverStripe\ORM;
use SilverStripe\ORM\Queries\SQLSelect;
/**
* Allows middleware to modily finalised dataquery on a per-instance basis
*/
interface DataQueryManipulator
{
/**
* Invoked prior to getFinalisedQuery()
*
* @param DataQuery $dataQuery
* @param array $queriedColumns
* @param SQLSelect $sqlSelect
*/
public function beforeGetFinalisedQuery(DataQuery $dataQuery, $queriedColumns = [], SQLSelect $sqlSelect);
/**
* Invoked after getFinalisedQuery()
*
* @param DataQuery $dataQuery
* @param array $queriedColumns
* @param SQLSelect $sqlQuery
*/
public function afterGetFinalisedQuery(DataQuery $dataQuery, $queriedColumns = [], SQLSelect $sqlQuery);
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\ORM;
use BadMethodCallException;
use SilverStripe\Core\Object;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\ORM\Queries\SQLDelete;
@ -122,7 +123,7 @@ class ManyManyList extends RelationList {
* @param array $row
* @return DataObject
*/
protected function createDataObject($row) {
public function createDataObject($row) {
// remove any composed fields
$add = array();
@ -210,23 +211,30 @@ class ManyManyList extends RelationList {
if(empty($extraFields)) $extraFields = array();
// Determine ID of new record
$itemID = null;
if(is_numeric($item)) {
$itemID = $item;
} elseif($item instanceof $this->dataClass) {
$itemID = $item->ID;
} else {
throw new InvalidArgumentException("ManyManyList::add() expecting a $this->dataClass object, or ID value",
E_USER_ERROR);
throw new InvalidArgumentException(
"ManyManyList::add() expecting a $this->dataClass object, or ID value"
);
}
if (empty($itemID)) {
throw new InvalidArgumentException("ManyManyList::add() doesn't accept unsaved records");
}
// Validate foreignID
$foreignIDs = $this->getForeignID();
if(empty($foreignIDs)) {
throw new Exception("ManyManyList::add() can't be called until a foreign ID is set", E_USER_WARNING);
throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set", E_USER_WARNING);
}
// Apply this item to each given foreign ID record
if(!is_array($foreignIDs)) $foreignIDs = array($foreignIDs);
if(!is_array($foreignIDs)) {
$foreignIDs = array($foreignIDs);
}
foreach($foreignIDs as $foreignID) {
// Check for existing records for this item
if($foreignFilter = $this->foreignIDWriteFilter($foreignID)) {

189
ORM/ManyManyThroughList.php Normal file
View File

@ -0,0 +1,189 @@
<?php
namespace SilverStripe\ORM;
use BadMethodCallException;
use InvalidArgumentException;
use SilverStripe\Core\Injector\Injector;
/**
* ManyManyList backed by a dataobject join table
*/
class ManyManyThroughList extends RelationList
{
/**
* @var ManyManyThroughQueryManipulator
*/
protected $manipulator;
/**
* Create a new ManyManyRelationList object. This relation will utilise an intermediary dataobject
* as a join table, unlike ManyManyList which scaffolds a table automatically.
*
* @param string $dataClass The class of the DataObjects that this will list.
* @param string $joinClass Class name of the joined dataobject record
* @param string $localKey The key in the join table that maps to the dataClass' PK.
* @param string $foreignKey The key in the join table that maps to joined class' PK.
*
* @example new ManyManyThroughList('Banner', 'PageBanner', 'BannerID', 'PageID');
*/
public function __construct($dataClass, $joinClass, $localKey, $foreignKey) {
parent::__construct($dataClass);
// Inject manipulator
$this->manipulator = ManyManyThroughQueryManipulator::create($joinClass, $localKey, $foreignKey);
$this->dataQuery->pushQueryManipulator($this->manipulator);
}
/**
* Don't apply foreign ID filter until getFinalisedQuery()
*
* @param array|integer $id (optional) An ID or an array of IDs - if not provided, will use the current ids as
* per getForeignID
* @return array Condition In array(SQL => parameters format)
*/
protected function foreignIDFilter($id = null) {
// foreignIDFilter is applied to the HasManyList via ManyManyThroughQueryManipulator, not here
return [];
}
public function createDataObject($row) {
// Add joined record
$joinRow = [];
$prefix = ManyManyThroughQueryManipulator::JOIN_TABLE_ALIAS . '_';
foreach ($row as $key => $value) {
if (strpos($key, $prefix) === 0) {
$joinKey = substr($key, strlen($prefix));
$joinRow[$joinKey] = $value;
unset($row[$key]);
}
}
// Create parent record
$record = parent::createDataObject($row);
// Create joined record
if ($joinRow) {
$joinClass = $this->manipulator->getJoinClass();
$joinQueryParams = $this->manipulator->extractInheritableQueryParameters($this->dataQuery);
$joinRecord = Injector::inst()->create($joinClass, $joinRow, false, $this->model, $joinQueryParams);
$record->setJoin($joinRecord);
}
return $record;
}
/**
* Remove the given item from this list.
*
* Note that for a ManyManyList, the item is never actually deleted, only
* the join table is affected.
*
* @param DataObject $item
*/
public function remove($item) {
if(!($item instanceof $this->dataClass)) {
throw new InvalidArgumentException(
"ManyManyThroughList::remove() expecting a {$this->dataClass} object"
);
}
$this->removeByID($item->ID);
}
/**
* Remove the given item from this list.
*
* Note that for a ManyManyList, the item is never actually deleted, only
* the join table is affected
*
* @param int $itemID The item ID
*/
public function removeByID($itemID) {
if(!is_numeric($itemID)) {
throw new InvalidArgumentException("ManyManyThroughList::removeById() expecting an ID");
}
// Find has_many row with a local key matching the given id
$hasManyList = $this->manipulator->getParentRelationship($this->dataQuery());
$records = $hasManyList->filter($this->manipulator->getLocalKey(), $itemID);
// Rather than simple un-associating the record (as in has_many list)
// Delete the actual mapping row as many_many deletions behave.
/** @var DataObject $record */
foreach($records as $record) {
$record->delete();
}
}
public function removeAll()
{
// Empty has_many table matching the current foreign key
$hasManyList = $this->manipulator->getParentRelationship($this->dataQuery());
$hasManyList->removeAll();
}
/**
* @param mixed $item
* @param array $extraFields
*/
public function add($item, $extraFields = []) {
// Ensure nulls or empty strings are correctly treated as empty arrays
if(empty($extraFields)) {
$extraFields = array();
}
// Determine ID of new record
$itemID = null;
if(is_numeric($item)) {
$itemID = $item;
} elseif($item instanceof $this->dataClass) {
$itemID = $item->ID;
} else {
throw new InvalidArgumentException(
"ManyManyThroughList::add() expecting a $this->dataClass object, or ID value"
);
}
if (empty($itemID)) {
throw new InvalidArgumentException("ManyManyThroughList::add() doesn't accept unsaved records");
}
// Validate foreignID
$foreignIDs = $this->getForeignID();
if (empty($foreignIDs)) {
throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set", E_USER_WARNING);
}
// Apply this item to each given foreign ID record
if(!is_array($foreignIDs)) {
$foreignIDs = [$foreignIDs];
}
$foreignIDsToAdd = array_combine($foreignIDs, $foreignIDs);
// Update existing records
$localKey = $this->manipulator->getLocalKey();
$foreignKey = $this->manipulator->getForeignKey();
$hasManyList = $this->manipulator->getParentRelationship($this->dataQuery());
$records = $hasManyList->filter($localKey, $itemID);
/** @var DataObject $record */
foreach($records as $record) {
if ($extraFields) {
foreach ($extraFields as $field => $value) {
$record->$field = $value;
}
$record->write();
}
//
$foreignID = $record->$foreignKey;
unset($foreignIDsToAdd[$foreignID]);
}
// Once existing records are updated, add missing mapping records
foreach($foreignIDsToAdd as $foreignID) {
$record = $hasManyList->createDataObject($extraFields ?: []);
$record->$foreignKey = $foreignID;
$record->$localKey = $itemID;
$record->write();
}
}
}

View File

@ -0,0 +1,219 @@
<?php
namespace SilverStripe\ORM;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
use SilverStripe\ORM\Queries\SQLSelect;
/**
* Injected into DataQuery to augment getFinalisedQuery() with a join table
*/
class ManyManyThroughQueryManipulator implements DataQueryManipulator
{
/**
* Alias to use for sql join table
*/
const JOIN_TABLE_ALIAS = 'Join';
use Injectable;
/**
* DataObject that backs the joining table
*
* @var string
*/
protected $joinClass;
/**
* Key that joins to the data class
*
* @var string $localKey
*/
protected $localKey;
/**
* Key that joins to the parent class
*
* @var string $foreignKey
*/
protected $foreignKey;
/**
* Build query manipulator for a given join table. Additional parameters (foreign key, etc)
* will be infered at evaluation from query parameters set via the ManyManyThroughList
*
* @param string $joinClass Class name of the joined dataobject record
* @param string $localKey The key in the join table that maps to the dataClass' PK.
* @param string $foreignKey The key in the join table that maps to joined class' PK.
*/
public function __construct($joinClass, $localKey, $foreignKey)
{
$this->setJoinClass($joinClass);
$this->setLocalKey($localKey);
$this->setForeignKey($foreignKey);
}
/**
* @return string
*/
public function getJoinClass()
{
return $this->joinClass;
}
/**
* @param mixed $joinClass
* @return $this
*/
public function setJoinClass($joinClass)
{
$this->joinClass = $joinClass;
return $this;
}
/**
* @return string
*/
public function getLocalKey()
{
return $this->localKey;
}
/**
* @param string $localKey
* @return $this
*/
public function setLocalKey($localKey)
{
$this->localKey = $localKey;
return $this;
}
/**
* @return string
*/
public function getForeignKey()
{
return $this->foreignKey;
}
/**
* @param string $foreignKey
* @return $this
*/
public function setForeignKey($foreignKey)
{
$this->foreignKey = $foreignKey;
return $this;
}
/**
* Get has_many relationship between parent and join table (for a given DataQuery)
*
* @param DataQuery $query
* @return HasManyList
*/
public function getParentRelationship(DataQuery $query) {
// Create has_many
$list = HasManyList::create($this->getJoinClass(), $this->getForeignKey());
$list = $list->setDataQueryParam($this->extractInheritableQueryParameters($query));
// Limit to given foreign key
if ($foreignID = $query->getQueryParam('Foreign.ID')) {
$list = $list->forForeignID($foreignID);
}
return $list;
}
/**
* Calculate the query parameters that should be inherited from the base many_many
* to the nested has_many list.
*
* @param DataQuery $query
* @return mixed
*/
public function extractInheritableQueryParameters(DataQuery $query) {
$params = $query->getQueryParams();
// Remove `Foreign.` query parameters for created objects,
// as this would interfere with relations on those objects.
foreach(array_keys($params) as $key) {
if(stripos($key, 'Foreign.') === 0) {
unset($params[$key]);
}
}
// Get inheritable parameters from an instance of the base query dataclass
$inst = Injector::inst()->create($query->dataClass());
$inst->setSourceQueryParams($params);
return $inst->getInheritableQueryParams();
}
/**
* Invoked prior to getFinalisedQuery()
*
* @param DataQuery $dataQuery
* @param array $queriedColumns
* @param SQLSelect $sqlSelect
*/
public function beforeGetFinalisedQuery(DataQuery $dataQuery, $queriedColumns = [], SQLSelect $sqlSelect)
{
// Get metadata and SQL from join table
$hasManyRelation = $this->getParentRelationship($dataQuery);
$joinTableSQLSelect = $hasManyRelation->dataQuery()->query();
$joinTableSQL = $joinTableSQLSelect->sql($joinTableParameters);
$joinTableColumns = array_keys($joinTableSQLSelect->getSelect()); // Get aliases (keys) only
$joinTableAlias = static::JOIN_TABLE_ALIAS;
// Get fields to join on
$localKey = $this->getLocalKey();
$schema = DataObject::getSchema();
$baseTable = $schema->baseDataClass($dataQuery->dataClass());
$childField = $schema->sqlColumnForField($baseTable, 'ID');
// Add select fields
foreach($joinTableColumns as $joinTableColumn) {
$sqlSelect->selectField(
"\"{$joinTableAlias}\".\"{$joinTableColumn}\"",
"{$joinTableAlias}_{$joinTableColumn}"
);
}
// Apply join and record sql for later insertion (at end of replacements)
$sqlSelect->addInnerJoin(
'(SELECT $$_SUBQUERY_$$)',
"\"{$joinTableAlias}\".\"{$localKey}\" = {$childField}",
$joinTableAlias,
20,
$joinTableParameters
);
$dataQuery->setQueryParam('Foreign.JoinTableSQL', $joinTableSQL);
// After this join, and prior to afterGetFinalisedQuery, $sqlSelect will be populated with the
// necessary sql rewrites (versioned, etc) that effect the base table.
// By using a placeholder for the subquery we can protect the subquery (already rewritten)
// from being re-written a second time. However we DO want the join predicate (above) to be rewritten.
// See http://php.net/manual/en/function.str-replace.php#refsect1-function.str-replace-notes
// for the reason we only add the final substitution at the end of getFinalisedQuery()
}
/**
* Invoked after getFinalisedQuery()
*
* @param DataQuery $dataQuery
* @param array $queriedColumns
* @param SQLSelect $sqlQuery
*/
public function afterGetFinalisedQuery(DataQuery $dataQuery, $queriedColumns = [], SQLSelect $sqlQuery)
{
// Inject final replacement after manipulation has been performed on the base dataquery
$joinTableSQL = $dataQuery->getQueryParam('Foreign.JoinTableSQL');
if ($joinTableSQL) {
$sqlQuery->replaceText('SELECT $$_SUBQUERY_$$', $joinTableSQL);
$dataQuery->setQueryParam('Foreign.JoinTableSQL', null);
}
}
}

View File

@ -146,12 +146,7 @@ class SQLSelect extends SQLConditionalExpression {
$fields = array($fields);
}
foreach($fields as $idx => $field) {
if(preg_match('/^(.*) +AS +"([^"]*)"/i', $field, $matches)) {
Deprecation::notice("3.0", "Use selectField() to specify column aliases");
$this->selectField($matches[1], $matches[2]);
} else {
$this->selectField($field, is_numeric($idx) ? null : $idx);
}
$this->selectField($field, is_numeric($idx) ? null : $idx);
}
return $this;
@ -167,8 +162,11 @@ class SQLSelect extends SQLConditionalExpression {
*/
public function selectField($field, $alias = null) {
if(!$alias) {
if(preg_match('/"([^"]+)"$/', $field, $matches)) $alias = $matches[1];
else $alias = $field;
if(preg_match('/"([^"]+)"$/', $field, $matches)) {
$alias = $matches[1];
} else {
$alias = $field;
}
}
$this->select[$alias] = $field;
return $this;

View File

@ -12,7 +12,9 @@ use Exception;
abstract class RelationList extends DataList implements Relation {
/**
* @return string|null
* Any number of foreign keys to apply to this list
*
* @return string|array|null
*/
public function getForeignID() {
return $this->dataQuery->getQueryParam('Foreign.ID');
@ -47,10 +49,8 @@ abstract class RelationList extends DataList implements Relation {
// Calculate the new filter
$filter = $this->foreignIDFilter($id);
$list = $this->alterDataQuery(function($query) use ($id, $filter){
/** @var DataQuery $query */
$list = $this->alterDataQuery(function(DataQuery $query) use ($id, $filter){
// Check if there is an existing filter, remove if there is
/** @var DataQuery $query */
$currentFilter = $query->getQueryParam('Foreign.Filter');
if($currentFilter) {
try {

View File

@ -1041,7 +1041,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider {
if(!$ownedClass) {
continue;
}
if($ownedClass === 'SilverStripe\ORM\DataObject') {
if($ownedClass === DataObject::class) {
throw new LogicException(sprintf(
"Relation %s on class %s cannot be owned as it is polymorphic",
$owned, $class
@ -1131,17 +1131,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider {
/** @var Versioned|DataObject $item */
foreach($items as $item) {
// Identify item
$itemKey = $item->class . '/' . $item->ID;
// Skip unsaved, unversioned, or already checked objects
if(!$item->isInDB() || !$item->has_extension('SilverStripe\ORM\Versioning\Versioned') || isset($list[$itemKey])) {
continue;
}
// Save record
$list[$itemKey] = $item;
$added[$itemKey] = $item;
$this->mergeRelatedObject($list, $added, $item);
}
return $added;
}
@ -2494,4 +2484,30 @@ class Versioned extends DataExtension implements TemplateGlobalProvider {
public function hasStages() {
return $this->mode === static::STAGEDVERSIONED;
}
/**
* Merge single object into a list
*
* @param ArrayList $list Global list. Object will not be added if already added to this list.
* @param ArrayList $added Additional list to insert into
* @param DataObject $item Item to add
* @return mixed
*/
protected function mergeRelatedObject($list, $added, $item)
{
// Identify item
$itemKey = get_class($item) . '/' . $item->ID;
// Write if saved, versioned, and not already added
if ($item->isInDB() && $item->has_extension('SilverStripe\ORM\Versioning\Versioned') && !isset($list[$itemKey])) {
$list[$itemKey] = $item;
$added[$itemKey] = $item;
}
// Add joined record (from many_many through) automatically
$joined = $item->getJoin();
if ($joined) {
$this->mergeRelatedObject($list, $added, $joined);
}
}
}

View File

@ -213,32 +213,18 @@ This is not mandatory unless the relationship would be otherwise ambiguous.
## many_many
Defines many-to-many joins. A new table, (this-class)_(relationship-name), will be created with a pair of ID fields.
Defines many-to-many joins, which uses a third table created between the two to join pairs.
There are two ways in which this can be declared, which are described below, depending on
how the developer wishes to manage this join table.
<div class="warning" markdown='1'>
Please specify a $belongs_many_many-relationship on the related class as well, in order to have the necessary accessors
available on both ends.
Please specify a $belongs_many_many-relationship on the related class as well, in order
to have the necessary accessors available on both ends.
</div>
:::php
<?php
class Team extends DataObject {
private static $many_many = array(
"Supporters" => "Supporter",
);
}
class Supporter extends DataObject {
private static $belongs_many_many = array(
"Supports" => "Team",
);
}
Much like the `has_one` relationship, `many_many` can be navigated through the `ORM` as well. The only difference being
you will get an instance of [api:ManyManyList] rather than the object.
Much like the `has_one` relationship, `many_many` can be navigated through the `ORM` as well.
The only difference being you will get an instance of [api:SilverStripe\ORM\ManyManyList] or
[api:SilverStripe\ORM\ManyManyThroughList] rather than the object.
:::php
$team = Team::get()->byId(1);
@ -247,16 +233,119 @@ you will get an instance of [api:ManyManyList] rather than the object.
// returns a 'ManyManyList' instance.
### Automatic many_many table
If you specify only a single class as the other side of the many-many relationship, then a
table will be automatically created between the two (this-class)_(relationship-name), will
be created with a pair of ID fields.
Extra fields on the mapping table can be created by declaring a `many_many_extraFields`
config to add extra columns.
:::php
<?php
class Team extends DataObject {
private static $many_many = [
"Supporters" => "Supporter",
];
private static $many_many_extraFields = [
'Supporters' => [
'Ranking' => 'Int'
]
];
}
class Supporter extends DataObject {
private static $belongs_many_many = [
"Supports" => "Team",
];
}
### many_many through relationship joined on a separate DataObject
If necessary, a third DataObject class can instead be specified as the joining table,
rather than having the ORM generate an automatically scaffolded table. This has the following
advantages:
- Allows versioning of the mapping table, including support for the
[ownership api](/developer_guides/model/versioning).
- Allows support of other extensions on the mapping table (e.g. subsites).
- Extra fields can be managed separately to the joined dataobject, even via a separate
GridField or form.
This is declared via array syntax, with the following keys on the many_many:
- `through` Class name of the mapping table
- `from` Name of the has_one relationship pointing back at the object declaring many_many
- `to` Name of the has_one relationship pointing to the object declaring belongs_many_many.
The syntax for `belongs_many_many` is unchanged.
:::php
<?php
class Team extends DataObject {
private static $many_many = [
"Supporters" => [
'through' => 'TeamSupporter',
'from' => 'Team',
'to' => 'Supporter',
]
];
}
class Supporter extends DataObject {
private static $belongs_many_many = [
"Supports" => "Team",
];
}
class TeamSupporter extends DataObject {
private static $db = [
'Ranking' => 'Int',
];
private static $has_one = [
'Team' => 'Team',
'Supporter' => 'Supporter'
];
}
In order to filter on the join table during queries, you can use the "Join" table alias
for any sql conditions.
:::php
$team = Team::get()->byId(1);
$supporters = $team->Supporters()->where(['"Join"."Ranking"' => 1]);
Note: ->filter() currently does not support joined fields natively.
### Using many_many in templates
The relationship can also be navigated in [templates](../templates).
The joined record can be accessed via getJoin() (many_many through only)
:::ss
<% with $Supporter %>
<% loop $Supports %>
Supports $Title
Supports $Title <% if $Join %>(rank $Join.Ranking)<% end_if %>
<% end_if %>
<% end_with %>
To specify multiple $many_manys between the same classes, use the dot notation to distinguish them like below:
## belongs_many_many
The belongs_many_many represents the other side of the relationship on the target data class.
When using either a basic many_many or a many_many through, the syntax for belongs_many_many is the same.
To specify multiple $many_manys between the same classes, specify use the dot notation to
distinguish them like below:
:::php
<?php
@ -277,10 +366,12 @@ To specify multiple $many_manys between the same classes, use the dot notation t
);
}
## many_many or belongs_many_many?
If you're unsure about whether an object should take on `many_many` or `belongs_many_many`, the best way to think about it is that the object where the relationship will be edited (i.e. via checkboxes) should contain the `many_many`. For instance, in a `many_many` of Product => Categories, the `Product` should contain the `many_many`, because it is much more likely that the user will select Categories for a Product than vice-versa.
If you're unsure about whether an object should take on `many_many` or `belongs_many_many`,
the best way to think about it is that the object where the relationship will be edited
(i.e. via checkboxes) should contain the `many_many`. For instance, in a `many_many` of
Product => Categories, the `Product` should contain the `many_many`, because it is much
more likely that the user will select Categories for a Product than vice-versa.
## Adding relations

View File

@ -852,7 +852,12 @@ A very small number of methods were chosen for deprecation, and will be removed
#### <a name="overview-orm-api"></a>ORM API Additions / Changes
* Deprecate `SQLQuery` in favour `SQLSelect`
* `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions appropriately on queries
* `DataObject.many_many` 'through' relationships now support join dataobjects in place of
automatically generated join tables. See the [/developer_guides/relations](datamodel relationship docs)
for more info.
* `DataList::filter` by null now internally generates "IS NULL" or "IS NOT NULL" conditions
appropriately on queries.
* `DataList::createDataObject` is now public.
* `DataObject` constructor now has an additional parameter, which must be included in subclasses.
* `DataObject::database_fields` now returns all fields on that table.
* `DataObject::db` now returns composite fields.
@ -905,6 +910,7 @@ A very small number of methods were chosen for deprecation, and will be removed
#### <a name="overview-orm-removed"></a>ORM Removed API
* Removed `DataObject::validateModelDefinitions`. Relations are now validated within `DataObjectSchema`
* Removed `DataList::getRelation`, as it was mutable. Use `DataList::applyRelation` instead, which is immutable.
* Removed `DataList::applyFilterContext` private method
* `Member` Field 'RememberLoginToken' removed, replaced with 'RememberLoginHashes' has_many relationship
@ -913,6 +919,10 @@ A very small number of methods were chosen for deprecation, and will be removed
* Removed `DBString::LimitWordCountXML()` method. Use `LimitWordCount` for XML safe version.
* Removed `SiteTree::getExistsOnLive()`. Use `isPublished()` instead.
* Removed `SiteTree::getIsDeletedFromStage()`. Use `isOnDraft()` instead (inverse case).
* `DataObject.many_many` no longer supports triangular resolution. Both the `many_many` and `belongs_many_many`
must point directly to the specific class on the opposing side, not a subclass or parent.
* `DataObject::validateModelDefinitions()` has been removed. Validation and parsing of config is now handled
within `DataObjectSchema`.
### <a name="overview-filesystem"></a>Filesystem API

View File

@ -3,11 +3,13 @@
use SilverStripe\Core\Config\Config;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObjectSchema;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Connect\MySQLDatabase;
use SilverStripe\ORM\DataExtension;
use SilverStripe\ORM\ManyManyList;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\Member;
@ -1186,77 +1188,24 @@ class DataObjectTest extends SapphireTest {
}
public function testValidateModelDefinitionsFailsWithArray() {
Config::nest();
$object = new DataObjectTest_Team;
$method = $this->makeAccessible($object, 'validateModelDefinitions');
Config::inst()->update('DataObjectTest_Team', 'has_one', array('NotValid' => array('NoArraysAllowed')));
$this->setExpectedException('LogicException');
try {
$method->invoke($object);
} catch(Exception $e) {
Config::unnest(); // Catch the exception so we can unnest config before failing the test
throw $e;
}
$this->setExpectedException(InvalidArgumentException::class);
$object = new DataObjectTest_Team();
$object->hasOneComponent('NotValid');
}
public function testValidateModelDefinitionsFailsWithIntKey() {
Config::nest();
$object = new DataObjectTest_Team;
$method = $this->makeAccessible($object, 'validateModelDefinitions');
Config::inst()->update('DataObjectTest_Team', 'has_many', array(12 => 'DataObjectTest_Player'));
$this->setExpectedException('LogicException');
try {
$method->invoke($object);
} catch(Exception $e) {
Config::unnest(); // Catch the exception so we can unnest config before failing the test
throw $e;
}
$this->setExpectedException(InvalidArgumentException::class);
$object = new DataObjectTest_Team();
$object->hasManyComponent(12);
}
public function testValidateModelDefinitionsFailsWithIntValue() {
Config::nest();
$object = new DataObjectTest_Team;
$method = $this->makeAccessible($object, 'validateModelDefinitions');
Config::inst()->update('DataObjectTest_Team', 'many_many', array('Players' => 12));
$this->setExpectedException('LogicException');
try {
$method->invoke($object);
} catch(Exception $e) {
Config::unnest(); // Catch the exception so we can unnest config before failing the test
throw $e;
}
}
/**
* many_many_extraFields is allowed to have an array value, so shouldn't throw an exception
*/
public function testValidateModelDefinitionsPassesWithExtraFields() {
Config::nest();
$object = new DataObjectTest_Team;
$method = $this->makeAccessible($object, 'validateModelDefinitions');
Config::inst()->update('DataObjectTest_Team', 'many_many_extraFields',
array('Relations' => array('Price' => 'Int')));
try {
$method->invoke($object);
} catch(Exception $e) {
Config::unnest();
$this->fail('Exception should not be thrown');
throw $e;
}
Config::unnest();
$this->setExpectedException(InvalidArgumentException::class);
$object = new DataObjectTest_Team();
$object->manyManyComponent('Players');
}
public function testNewClassInstance() {
@ -1292,13 +1241,16 @@ class DataObjectTest extends SapphireTest {
$equipmentSuppliers = $team->EquipmentSuppliers();
// Check that DataObject::many_many() works as expected
list($class, $targetClass, $parentField, $childField, $joinTable) = $team->manyManyComponent('Sponsors');
list($relationClass, $class, $targetClass, $parentField, $childField, $joinTable) = $team->manyManyComponent('Sponsors');
$this->assertEquals(ManyManyList::class, $relationClass);
$this->assertEquals('DataObjectTest_Team', $class,
'DataObject::many_many() didn\'t find the correct base class');
$this->assertEquals('DataObjectTest_EquipmentCompany', $targetClass,
'DataObject::many_many() didn\'t find the correct target class for the relation');
$this->assertEquals('DataObjectTest_EquipmentCompany_SponsoredTeams', $joinTable,
'DataObject::many_many() didn\'t find the correct relation table');
$this->assertEquals('DataObjectTest_TeamID', $parentField);
$this->assertEquals('DataObjectTest_EquipmentCompanyID', $childField);
// Check that ManyManyList still works
$this->assertEquals(2, $sponsors->count(), 'Rows are missing from relation');
@ -1579,7 +1531,7 @@ class DataObjectTest extends SapphireTest {
'CurrentStaff' => 'DataObjectTest_Staff.CurrentCompany',
'PreviousStaff' => 'DataObjectTest_Staff.PreviousCompany'
),
$company->hasMany(null, false),
$company->hasMany(false),
'has_many returns field name data when $classOnly is false.'
);

View File

@ -0,0 +1,275 @@
<?php
use SilverStripe\Dev\Debug;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\ManyManyThroughList;
use SilverStripe\ORM\Versioning\Versioned;
class ManyManyThroughListTest extends SapphireTest
{
protected static $fixture_file = 'ManyManyThroughListTest.yml';
protected $extraDataObjects = [
ManyManyThroughListTest_Item::class,
ManyManyThroughListTest_JoinObject::class,
ManyManyThroughListTest_Object::class,
ManyManyThroughListTest_VersionedItem::class,
ManyManyThroughListTest_VersionedJoinObject::class,
ManyManyThroughListTest_VersionedObject::class,
];
public function testSelectJoin() {
/** @var ManyManyThroughListTest_Object $parent */
$parent = $this->objFromFixture(ManyManyThroughListTest_Object::class, 'parent1');
$this->assertDOSEquals(
[
['Title' => 'item 1'],
['Title' => 'item 2']
],
$parent->Items()
);
// Check filters on list work
$item1 = $parent->Items()->filter('Title', 'item 1')->first();
$this->assertNotNull($item1);
$this->assertNotNull($item1->getJoin());
$this->assertEquals('join 1', $item1->getJoin()->Title);
// Check filters on list work
$item2 = $parent->Items()->filter('Title', 'item 2')->first();
$this->assertNotNull($item2);
$this->assertNotNull($item2->getJoin());
$this->assertEquals('join 2', $item2->getJoin()->Title);
// To filter on join table need to use some raw sql
$item2 = $parent->Items()->where(['"Join"."Title"' => 'join 2'])->first();
$this->assertNotNull($item2);
$this->assertEquals('item 2', $item2->Title);
$this->assertNotNull($item2->getJoin());
$this->assertEquals('join 2', $item2->getJoin()->Title);
}
public function testAdd() {
/** @var ManyManyThroughListTest_Object $parent */
$parent = $this->objFromFixture(ManyManyThroughListTest_Object::class, 'parent1');
$newItem = new ManyManyThroughListTest_Item();
$newItem->Title = 'my new item';
$newItem->write();
$parent->Items()->add($newItem, ['Title' => 'new join record']);
// Check select
$newItem = $parent->Items()->filter(['Title' => 'my new item'])->first();
$this->assertNotNull($newItem);
$this->assertEquals('my new item', $newItem->Title);
$this->assertNotNull($newItem->getJoin());
$this->assertEquals('new join record', $newItem->getJoin()->Title);
}
public function testRemove() {
/** @var ManyManyThroughListTest_Object $parent */
$parent = $this->objFromFixture(ManyManyThroughListTest_Object::class, 'parent1');
$this->assertDOSEquals(
[
['Title' => 'item 1'],
['Title' => 'item 2']
],
$parent->Items()
);
$item1 = $parent->Items()->filter(['Title' => 'item 1'])->first();
$parent->Items()->remove($item1);
$this->assertDOSEquals(
[['Title' => 'item 2']],
$parent->Items()
);
}
public function testPublishing() {
/** @var ManyManyThroughListTest_VersionedObject $draftParent */
$draftParent = $this->objFromFixture(ManyManyThroughListTest_VersionedObject::class, 'parent1');
$draftParent->publishRecursive();
// Modify draft stage
$item1 = $draftParent->Items()->filter(['Title' => 'versioned item 1'])->first();
$item1->Title = 'new versioned item 1';
$item1->getJoin()->Title = 'new versioned join 1';
$item1->write(false, false, false, true); // Write joined components
$draftParent->Title = 'new versioned title';
$draftParent->write();
// Check owned objects on stage
$draftOwnedObjects = $draftParent->findOwned(true);
$this->assertDOSEquals(
[
['Title' => 'new versioned join 1'],
['Title' => 'versioned join 2'],
['Title' => 'new versioned item 1'],
['Title' => 'versioned item 2'],
],
$draftOwnedObjects
);
// Check live record is still old values
// This tests that both the join table and many_many tables
// inherit the necessary query parameters from the parent object.
/** @var ManyManyThroughListTest_VersionedObject $liveParent */
$liveParent = Versioned::get_by_stage(
ManyManyThroughListTest_VersionedObject::class,
Versioned::LIVE
)->byID($draftParent->ID);
$liveOwnedObjects = $liveParent->findOwned(true);
$this->assertDOSEquals(
[
['Title' => 'versioned join 1'],
['Title' => 'versioned join 2'],
['Title' => 'versioned item 1'],
['Title' => 'versioned item 2'],
],
$liveOwnedObjects
);
// Publish draft changes
$draftParent->publishRecursive();
$liveParent = Versioned::get_by_stage(
ManyManyThroughListTest_VersionedObject::class,
Versioned::LIVE
)->byID($draftParent->ID);
$liveOwnedObjects = $liveParent->findOwned(true);
$this->assertDOSEquals(
[
['Title' => 'new versioned join 1'],
['Title' => 'versioned join 2'],
['Title' => 'new versioned item 1'],
['Title' => 'versioned item 2'],
],
$liveOwnedObjects
);
}
}
/**
* Basic parent object
*
* @property string $Title
* @method ManyManyThroughList Items()
*/
class ManyManyThroughListTest_Object extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $many_many = [
'Items' => [
'through' => ManyManyThroughListTest_JoinObject::class,
'from' => 'Parent',
'to' => 'Child',
]
];
}
/**
* @property string $Title
* @method ManyManyThroughListTest_Object Parent()
* @method ManyManyThroughListTest_Item Child()
*/
class ManyManyThroughListTest_JoinObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $has_one = [
'Parent' => ManyManyThroughListTest_Object::class,
'Child' => ManyManyThroughListTest_Item::class,
];
}
/**
* @property string $Title
* @method ManyManyThroughList Objects()
*/
class ManyManyThroughListTest_Item extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $belongs_many_many = [
'Objects' => 'ManyManyThroughListTest_Object.Items'
];
}
/**
* Basic parent object
*
* @property string $Title
* @method ManyManyThroughList Items()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $owns = [
'Items' // Should automatically own both mapping and child records
];
private static $many_many = [
'Items' => [
'through' => ManyManyThroughListTest_VersionedJoinObject::class,
'from' => 'Parent',
'to' => 'Child',
]
];
}
/**
* @property string $Title
* @method ManyManyThroughListTest_VersionedObject Parent()
* @method ManyManyThroughListTest_VersionedItem Child()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedJoinObject extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $has_one = [
'Parent' => ManyManyThroughListTest_VersionedObject::class,
'Child' => ManyManyThroughListTest_VersionedItem::class,
];
}
/**
* @property string $Title
* @method ManyManyThroughList Objects()
* @mixin Versioned
*/
class ManyManyThroughListTest_VersionedItem extends DataObject implements TestOnly
{
private static $db = [
'Title' => 'Varchar'
];
private static $extensions = [
Versioned::class
];
private static $belongs_many_many = [
'Objects' => 'ManyManyThroughListTest_VersionedObject.Items'
];
}

View File

@ -0,0 +1,34 @@
ManyManyThroughListTest_Object:
parent1:
Title: 'my object'
ManyManyThroughListTest_Item:
child1:
Title: 'item 1'
child2:
Title: 'item 2'
ManyManyThroughListTest_JoinObject:
join1:
Title: 'join 1'
Parent: =>ManyManyThroughListTest_Object.parent1
Child: =>ManyManyThroughListTest_Item.child1
join2:
Title: 'join 2'
Parent: =>ManyManyThroughListTest_Object.parent1
Child: =>ManyManyThroughListTest_Item.child2
ManyManyThroughListTest_VersionedObject:
parent1:
Title: 'versioned object'
ManyManyThroughListTest_VersionedItem:
child1:
Title: 'versioned item 1'
child2:
Title: 'versioned item 2'
ManyManyThroughListTest_VersionedJoinObject:
join1:
Title: 'versioned join 1'
Parent: =>ManyManyThroughListTest_VersionedObject.parent1
Child: =>ManyManyThroughListTest_VersionedItem.child1
join2:
Title: 'versioned join 2'
Parent: =>ManyManyThroughListTest_VersionedObject.parent1
Child: =>ManyManyThroughListTest_VersionedItem.child2