From e7303170c2ff62adc0fb5d09fdffaacf88c614dd Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 26 Sep 2016 18:22:19 +1300 Subject: [PATCH] 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 --- ORM/DataList.php | 6 +- ORM/DataObject.php | 354 +++++---------- ORM/DataObjectSchema.php | 409 +++++++++++++++++- ORM/DataQuery.php | 81 +++- ORM/DataQueryManipulator.php | 29 ++ ORM/ManyManyList.php | 18 +- ORM/ManyManyThroughList.php | 189 ++++++++ ORM/ManyManyThroughQueryManipulator.php | 219 ++++++++++ ORM/Queries/SQLSelect.php | 14 +- ORM/RelationList.php | 8 +- ORM/Versioning/Versioned.php | 40 +- .../00_Model/02_Relations.md | 145 +++++-- docs/en/04_Changelogs/4.0.0.md | 12 +- tests/model/DataObjectTest.php | 80 +--- tests/model/ManyManyThroughListTest.php | 275 ++++++++++++ tests/model/ManyManyThroughListTest.yml | 34 ++ 16 files changed, 1514 insertions(+), 399 deletions(-) create mode 100644 ORM/DataQueryManipulator.php create mode 100644 ORM/ManyManyThroughList.php create mode 100644 ORM/ManyManyThroughQueryManipulator.php create mode 100644 tests/model/ManyManyThroughListTest.php create mode 100644 tests/model/ManyManyThroughListTest.yml diff --git a/ORM/DataList.php b/ORM/DataList.php index 1113c3416..63972be41 100644 --- a/ORM/DataList.php +++ b/ORM/DataList.php @@ -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']; diff --git a/ORM/DataObject.php b/ORM/DataObject.php index f63b45ad4..90ae1ce8a 100644 --- a/ORM/DataObject.php +++ b/ORM/DataObject.php @@ -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( - * , The class that relation is defined in e.g. "Product" - * , The target class of the relation e.g. "Category" - * , The field name pointing to 's table e.g. "ProductID" - * , The field name pointing to 's table e.g. "CategoryID" - * The join table between the two classes e.g. "Product_Categories" + * , Name of class for relation + * , The class that relation is defined in e.g. "Product" + * , The target class of the relation e.g. "Category" + * , The field name pointing to 's table e.g. "ProductID" + * , The field name pointing to 's table e.g. "CategoryID" + * 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; + } + } diff --git a/ORM/DataObjectSchema.php b/ORM/DataObjectSchema.php index 763a626e1..0431143bd 100644 --- a/ORM/DataObjectSchema.php +++ b/ORM/DataObjectSchema.php @@ -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( + * , Name of class for relation + * , The class that relation is defined in e.g. "Product" + * , The target class of the relation e.g. "Category" + * , The field name pointing to 's table e.g. "ProductID". + * , The field name pointing to 's table e.g. "CategoryID". + * 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 + ); + } + } } diff --git a/ORM/DataQuery.php b/ORM/DataQuery.php index 2d388adaf..5aa0aed4d 100644 --- a/ORM/DataQuery.php +++ b/ORM/DataQuery.php @@ -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; + } } diff --git a/ORM/DataQueryManipulator.php b/ORM/DataQueryManipulator.php new file mode 100644 index 000000000..a050df419 --- /dev/null +++ b/ORM/DataQueryManipulator.php @@ -0,0 +1,29 @@ +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)) { diff --git a/ORM/ManyManyThroughList.php b/ORM/ManyManyThroughList.php new file mode 100644 index 000000000..c9a036ba8 --- /dev/null +++ b/ORM/ManyManyThroughList.php @@ -0,0 +1,189 @@ +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(); + } + } +} diff --git a/ORM/ManyManyThroughQueryManipulator.php b/ORM/ManyManyThroughQueryManipulator.php new file mode 100644 index 000000000..6b93a5619 --- /dev/null +++ b/ORM/ManyManyThroughQueryManipulator.php @@ -0,0 +1,219 @@ +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); + } + } +} diff --git a/ORM/Queries/SQLSelect.php b/ORM/Queries/SQLSelect.php index c5d51b06a..b00f040fd 100644 --- a/ORM/Queries/SQLSelect.php +++ b/ORM/Queries/SQLSelect.php @@ -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; diff --git a/ORM/RelationList.php b/ORM/RelationList.php index 78252c5e3..4e2491996 100644 --- a/ORM/RelationList.php +++ b/ORM/RelationList.php @@ -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 { diff --git a/ORM/Versioning/Versioned.php b/ORM/Versioning/Versioned.php index 68775b04b..36989305e 100644 --- a/ORM/Versioning/Versioned.php +++ b/ORM/Versioning/Versioned.php @@ -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); + } + } } diff --git a/docs/en/02_Developer_Guides/00_Model/02_Relations.md b/docs/en/02_Developer_Guides/00_Model/02_Relations.md index 83ddbbae3..5a1ad1089 100644 --- a/docs/en/02_Developer_Guides/00_Model/02_Relations.md +++ b/docs/en/02_Developer_Guides/00_Model/02_Relations.md @@ -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.
-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.
- :::php - "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 + "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 + [ + '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 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 diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index dc2269c47..ed91dc987 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -852,7 +852,12 @@ A very small number of methods were chosen for deprecation, and will be removed #### 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 #### 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`. ### Filesystem API diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index cb875c652..89d865d34 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -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.' ); diff --git a/tests/model/ManyManyThroughListTest.php b/tests/model/ManyManyThroughListTest.php new file mode 100644 index 000000000..0127a474a --- /dev/null +++ b/tests/model/ManyManyThroughListTest.php @@ -0,0 +1,275 @@ +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' + ]; +} + diff --git a/tests/model/ManyManyThroughListTest.yml b/tests/model/ManyManyThroughListTest.yml new file mode 100644 index 000000000..4a2bef998 --- /dev/null +++ b/tests/model/ManyManyThroughListTest.yml @@ -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