mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 12:05:37 +00:00
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:
parent
406ff5b183
commit
e7303170c2
@ -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'];
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
29
ORM/DataQueryManipulator.php
Normal file
29
ORM/DataQueryManipulator.php
Normal 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);
|
||||
}
|
@ -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
189
ORM/ManyManyThroughList.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
219
ORM/ManyManyThroughQueryManipulator.php
Normal file
219
ORM/ManyManyThroughQueryManipulator.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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.'
|
||||
);
|
||||
|
||||
|
275
tests/model/ManyManyThroughListTest.php
Normal file
275
tests/model/ManyManyThroughListTest.php
Normal 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'
|
||||
];
|
||||
}
|
||||
|
34
tests/model/ManyManyThroughListTest.yml
Normal file
34
tests/model/ManyManyThroughListTest.yml
Normal 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
|
Loading…
x
Reference in New Issue
Block a user