From e7303170c2ff62adc0fb5d09fdffaacf88c614dd Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 26 Sep 2016 18:22:19 +1300 Subject: [PATCH 1/3] 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 From f0dd9af699cd03e3e315ff6e8297efdc0a9cfd23 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Mon, 3 Oct 2016 14:51:34 +1300 Subject: [PATCH 2/3] API Support named join alias for many_many through list Add tests for sorting on joined alias --- ORM/DataObject.php | 44 ++++------- ORM/DataObjectSchema.php | 58 +++++++++++++++ ORM/ManyManyList.php | 8 +- ORM/ManyManyThroughList.php | 7 +- ORM/ManyManyThroughQueryManipulator.php | 16 ++-- .../00_Model/02_Relations.md | 20 +++-- tests/model/ManyManyThroughListTest.php | 74 +++++++++++++++++-- tests/model/ManyManyThroughListTest.yml | 2 + 8 files changed, 175 insertions(+), 54 deletions(-) diff --git a/ORM/DataObject.php b/ORM/DataObject.php index 90ae1ce8a..7f533d0d9 100644 --- a/ORM/DataObject.php +++ b/ORM/DataObject.php @@ -1935,35 +1935,10 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * format, or RecordClass.FieldClass(args) format if $includeClass is true */ public function db($fieldName = null, $includeClass = false) { - $classes = ClassInfo::ancestry($this, true); - - // If we're looking for a specific field, we want to hit subclasses first as they may override field types - if($fieldName) { - $classes = array_reverse($classes); - } - - $db = array(); - foreach($classes as $class) { - // Merge fields with new fields and composite fields - $fields = self::database_fields($class); - $compositeFields = self::composite_fields($class, false); - $db = array_merge($db, $fields, $compositeFields); - - // Check for search field - if($fieldName && isset($db[$fieldName])) { - // Return found field - if(!$includeClass) { - return $db[$fieldName]; - } - return $class . "." . $db[$fieldName]; - } - } - - // At end of search complete - if($fieldName) { - return null; + if ($fieldName) { + return static::getSchema()->fieldSpecification(static::class, $fieldName, $includeClass); } else { - return $db; + return static::getSchema()->fieldSpecifications(static::class); } } @@ -2071,7 +2046,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * 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. + * ManyManyList or ManyManyThroughList. * * Standard many_many return type is: * @@ -3870,10 +3845,19 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Set joining object * * @param DataObject $object + * @param string $alias Alias * @return $this */ - public function setJoin(DataObject $object) { + public function setJoin(DataObject $object, $alias = null) { $this->joinRecord = $object; + if ($alias) { + if ($this->db($alias)) { + throw new InvalidArgumentException( + "Joined record $alias cannot also be a db field" + ); + } + $this->record[$alias] = $object; + } return $this; } diff --git a/ORM/DataObjectSchema.php b/ORM/DataObjectSchema.php index 0431143bd..5c758e3c4 100644 --- a/ORM/DataObjectSchema.php +++ b/ORM/DataObjectSchema.php @@ -137,6 +137,54 @@ class DataObjectSchema { return $this->tableName($this->baseDataClass($class)); } + /** + * Get all DB field specifications for a class, including ancestors and composite fields. + * + * @param string $class + * @return array + */ + public function fieldSpecifications($class) { + $classes = ClassInfo::ancestry($class, true); + $db = []; + foreach($classes as $tableClass) { + // Merge fields with new fields and composite fields + $fields = $this->databaseFields($tableClass); + $compositeFields = $this->compositeFields($tableClass, false); + $db = array_merge($db, $fields, $compositeFields); + } + return $db; + } + + + /** + * Get specifications for a single class field + * + * @param string $class + * @param string $fieldName + * @param bool $includeClass If returning a single column, prefix the column with the class name + * in RecordClass.Column(spec) format + * @return string|null Field will be a string in FieldClass(args) format, or + * RecordClass.FieldClass(args) format if $includeClass is true. Will be null if no field is found. + */ + public function fieldSpecification($class, $fieldName, $includeClass = false) { + $classes = array_reverse(ClassInfo::ancestry($class, true)); + foreach($classes as $tableClass) { + // Merge fields with new fields and composite fields + $fields = $this->databaseFields($tableClass); + $compositeFields = $this->compositeFields($tableClass, false); + $db = array_merge($fields, $compositeFields); + + // Check for search field + if(isset($db[$fieldName])) { + $prefix = $includeClass ? "{$tableClass}." : ""; + return $prefix . $db[$fieldName]; + } + } + + // At end of search complete + return null; + } + /** * Find the class for the given table * @@ -689,6 +737,16 @@ class DataObjectSchema { ); } + // Validate the join class isn't also the name of a field or relation on either side + // of the relation + $field = $this->fieldSpecification($relationClass, $joinClass); + if ($field) { + throw new InvalidArgumentException( + "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} " + . " cannot have a db field of the same name of the join class {$joinClass}" + ); + } + // Validate bad types on parent relation if ($key === 'from' && $relationClass !== $parentClass) { throw new InvalidArgumentException( diff --git a/ORM/ManyManyList.php b/ORM/ManyManyList.php index a2558ee76..048d036b7 100644 --- a/ORM/ManyManyList.php +++ b/ORM/ManyManyList.php @@ -228,7 +228,7 @@ class ManyManyList extends RelationList { // 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); + throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set"); } // Apply this item to each given foreign ID record @@ -317,7 +317,7 @@ class ManyManyList extends RelationList { if($filter = $this->foreignIDWriteFilter($this->getForeignID())) { $query->setWhere($filter); } else { - user_error("Can't call ManyManyList::remove() until a foreign ID is set", E_USER_WARNING); + user_error("Can't call ManyManyList::remove() until a foreign ID is set"); } $query->addWhere(array( @@ -380,7 +380,7 @@ class ManyManyList extends RelationList { } if(!is_numeric($itemID)) { - user_error('ComponentSet::getExtraData() passed a non-numeric child ID', E_USER_ERROR); + throw new InvalidArgumentException('ManyManyList::getExtraData() passed a non-numeric child ID'); } $cleanExtraFields = array(); @@ -392,7 +392,7 @@ class ManyManyList extends RelationList { if($filter) { $query->setWhere($filter); } else { - user_error("Can't call ManyManyList::getExtraData() until a foreign ID is set", E_USER_WARNING); + throw new BadMethodCallException("Can't call ManyManyList::getExtraData() until a foreign ID is set"); } $query->addWhere(array( "\"{$this->joinTable}\".\"{$this->localKey}\"" => $itemID diff --git a/ORM/ManyManyThroughList.php b/ORM/ManyManyThroughList.php index c9a036ba8..32b662c1c 100644 --- a/ORM/ManyManyThroughList.php +++ b/ORM/ManyManyThroughList.php @@ -50,7 +50,8 @@ class ManyManyThroughList extends RelationList public function createDataObject($row) { // Add joined record $joinRow = []; - $prefix = ManyManyThroughQueryManipulator::JOIN_TABLE_ALIAS . '_'; + $joinAlias = $this->manipulator->getJoinAlias(); + $prefix = $joinAlias . '_'; foreach ($row as $key => $value) { if (strpos($key, $prefix) === 0) { $joinKey = substr($key, strlen($prefix)); @@ -67,7 +68,7 @@ class ManyManyThroughList extends RelationList $joinClass = $this->manipulator->getJoinClass(); $joinQueryParams = $this->manipulator->extractInheritableQueryParameters($this->dataQuery); $joinRecord = Injector::inst()->create($joinClass, $joinRow, false, $this->model, $joinQueryParams); - $record->setJoin($joinRecord); + $record->setJoin($joinRecord, $joinAlias); } return $record; @@ -151,7 +152,7 @@ class ManyManyThroughList extends RelationList // 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); + throw new BadMethodCallException("ManyManyList::add() can't be called until a foreign ID is set"); } // Apply this item to each given foreign ID record diff --git a/ORM/ManyManyThroughQueryManipulator.php b/ORM/ManyManyThroughQueryManipulator.php index 6b93a5619..073000350 100644 --- a/ORM/ManyManyThroughQueryManipulator.php +++ b/ORM/ManyManyThroughQueryManipulator.php @@ -5,7 +5,6 @@ namespace SilverStripe\ORM; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Dev\Debug; use SilverStripe\ORM\Queries\SQLSelect; /** @@ -13,10 +12,6 @@ use SilverStripe\ORM\Queries\SQLSelect; */ class ManyManyThroughQueryManipulator implements DataQueryManipulator { - /** - * Alias to use for sql join table - */ - const JOIN_TABLE_ALIAS = 'Join'; use Injectable; @@ -152,6 +147,15 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator return $inst->getInheritableQueryParams(); } + /** + * Get name of join table alias for use in queries. + * + * @return string + */ + public function getJoinAlias() { + return $this->getJoinClass(); + } + /** * Invoked prior to getFinalisedQuery() * @@ -166,7 +170,7 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator $joinTableSQLSelect = $hasManyRelation->dataQuery()->query(); $joinTableSQL = $joinTableSQLSelect->sql($joinTableParameters); $joinTableColumns = array_keys($joinTableSQLSelect->getSelect()); // Get aliases (keys) only - $joinTableAlias = static::JOIN_TABLE_ALIAS; + $joinTableAlias = $this->getJoinAlias(); // Get fields to join on $localKey = $this->getLocalKey(); 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 5a1ad1089..54299d580 100644 --- a/docs/en/02_Developer_Guides/00_Model/02_Relations.md +++ b/docs/en/02_Developer_Guides/00_Model/02_Relations.md @@ -281,6 +281,9 @@ 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. + +Note: The `through` class must not also be the name of any field or relation on the parent +or child record. The syntax for `belongs_many_many` is unchanged. @@ -314,30 +317,35 @@ The syntax for `belongs_many_many` is unchanged. ]; } -In order to filter on the join table during queries, you can use the "Join" table alias +In order to filter on the join table during queries, you can use the class name of the joining table for any sql conditions. :::php $team = Team::get()->byId(1); - $supporters = $team->Supporters()->where(['"Join"."Ranking"' => 1]); + $supporters = $team->Supporters()->where(['"TeamSupporter"."Ranking"' => 1]); -Note: ->filter() currently does not support joined fields natively. - +Note: ->filter() currently does not support joined fields natively due to the fact that the +query for the join table is isolated from the outer query controlled by DataList. + ### 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) +The joined record can be accessed via `Join` or `TeamSupporter` property (many_many through only) :::ss <% with $Supporter %> <% loop $Supports %> - Supports $Title <% if $Join %>(rank $Join.Ranking)<% end_if %> + Supports $Title <% if $TeamSupporter %>(rank $TeamSupporter.Ranking)<% end_if %> <% end_if %> <% end_with %> + +You can also use `$Join` in place of the join class alias (`$TeamSupporter`), if your template +is class-agnostic and doesn't know the type of the join table. + ## belongs_many_many The belongs_many_many represents the other side of the relationship on the target data class. diff --git a/tests/model/ManyManyThroughListTest.php b/tests/model/ManyManyThroughListTest.php index 0127a474a..fb97d3362 100644 --- a/tests/model/ManyManyThroughListTest.php +++ b/tests/model/ManyManyThroughListTest.php @@ -4,7 +4,6 @@ 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; @@ -21,6 +20,16 @@ class ManyManyThroughListTest extends SapphireTest ManyManyThroughListTest_VersionedObject::class, ]; + public function setUp() { + parent::setUp(); + DataObject::reset(); + } + + public function tearDown() { + DataObject::reset(); + parent::tearDown(); + } + public function testSelectJoin() { /** @var ManyManyThroughListTest_Object $parent */ $parent = $this->objFromFixture(ManyManyThroughListTest_Object::class, 'parent1'); @@ -36,19 +45,53 @@ class ManyManyThroughListTest extends SapphireTest $this->assertNotNull($item1); $this->assertNotNull($item1->getJoin()); $this->assertEquals('join 1', $item1->getJoin()->Title); + $this->assertInstanceOf( + ManyManyThroughListTest_JoinObject::class, + $item1->ManyManyThroughListTest_JoinObject + ); + $this->assertEquals('join 1', $item1->ManyManyThroughListTest_JoinObject->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); + $this->assertEquals('join 2', $item2->ManyManyThroughListTest_JoinObject->Title); // To filter on join table need to use some raw sql - $item2 = $parent->Items()->where(['"Join"."Title"' => 'join 2'])->first(); + $item2 = $parent->Items()->where(['"ManyManyThroughListTest_JoinObject"."Title"' => 'join 2'])->first(); $this->assertNotNull($item2); $this->assertEquals('item 2', $item2->Title); $this->assertNotNull($item2->getJoin()); $this->assertEquals('join 2', $item2->getJoin()->Title); + $this->assertEquals('join 2', $item2->ManyManyThroughListTest_JoinObject->Title); + + // Test sorting on join table + $items = $parent->Items()->sort('"ManyManyThroughListTest_JoinObject"."Sort"'); + $this->assertDOSEquals( + [ + ['Title' => 'item 2'], + ['Title' => 'item 1'], + ], + $items + ); + + $items = $parent->Items()->sort('"ManyManyThroughListTest_JoinObject"."Sort" ASC'); + $this->assertDOSEquals( + [ + ['Title' => 'item 1'], + ['Title' => 'item 2'], + ], + $items + ); + $items = $parent->Items()->sort('"ManyManyThroughListTest_JoinObject"."Title" DESC'); + $this->assertDOSEquals( + [ + ['Title' => 'item 2'], + ['Title' => 'item 1'], + ], + $items + ); } public function testAdd() { @@ -63,8 +106,15 @@ class ManyManyThroughListTest extends SapphireTest $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); + $this->assertInstanceOf( + ManyManyThroughListTest_JoinObject::class, + $newItem->getJoin() + ); + $this->assertInstanceOf( + ManyManyThroughListTest_JoinObject::class, + $newItem->ManyManyThroughListTest_JoinObject + ); + $this->assertEquals('new join record', $newItem->ManyManyThroughListTest_JoinObject->Title); } public function testRemove() { @@ -146,6 +196,19 @@ class ManyManyThroughListTest extends SapphireTest $liveOwnedObjects ); } + + /** + * Test validation + */ + public function testValidateModelValidatesJoinType() { + DataObject::reset(); + ManyManyThroughListTest_Item::config()->update('db', [ + 'ManyManyThroughListTest_JoinObject' => 'Text' + ]); + $this->setExpectedException(InvalidArgumentException::class); + $object = new ManyManyThroughListTest_Object(); + $object->manyManyComponent('Items'); + } } /** @@ -177,7 +240,8 @@ class ManyManyThroughListTest_Object extends DataObject implements TestOnly class ManyManyThroughListTest_JoinObject extends DataObject implements TestOnly { private static $db = [ - 'Title' => 'Varchar' + 'Title' => 'Varchar', + 'Sort' => 'Int', ]; private static $has_one = [ diff --git a/tests/model/ManyManyThroughListTest.yml b/tests/model/ManyManyThroughListTest.yml index 4a2bef998..c446a2451 100644 --- a/tests/model/ManyManyThroughListTest.yml +++ b/tests/model/ManyManyThroughListTest.yml @@ -9,10 +9,12 @@ ManyManyThroughListTest_Item: ManyManyThroughListTest_JoinObject: join1: Title: 'join 1' + Sort: 4 Parent: =>ManyManyThroughListTest_Object.parent1 Child: =>ManyManyThroughListTest_Item.child1 join2: Title: 'join 2' + Sort: 2 Parent: =>ManyManyThroughListTest_Object.parent1 Child: =>ManyManyThroughListTest_Item.child2 ManyManyThroughListTest_VersionedObject: From 11bbed4f76b2dab35ed0c1db50114eef4a29c4b7 Mon Sep 17 00:00:00 2001 From: Damian Mooyman Date: Thu, 6 Oct 2016 17:31:38 +1300 Subject: [PATCH 3/3] API Move many methods from DataObject to DataObjectSchema --- Assets/AssetControlExtension.php | 7 +- Core/ClassInfo.php | 4 +- Dev/CsvBulkLoader.php | 6 +- Dev/FixtureBlueprint.php | 14 +- Forms/FileField.php | 4 +- Forms/GridField/GridFieldSortableHeader.php | 3 +- Forms/UploadField.php | 4 +- ORM/DataObject.php | 486 +++++------------- ORM/DataObjectSchema.php | 212 ++++++-- ORM/DataQuery.php | 22 +- ORM/DatabaseAdmin.php | 28 +- ORM/FieldType/DBForeignKey.php | 2 +- ORM/Hierarchy/Hierarchy.php | 8 +- ORM/ManyManyThroughQueryManipulator.php | 2 +- ORM/Versioning/Versioned.php | 24 +- Security/PermissionCheckboxSetField.php | 7 +- Security/Security.php | 15 +- docs/en/04_Changelogs/4.0.0.md | 18 +- tests/forms/GridFieldTest.php | 11 +- tests/model/DBCompositeTest.php | 17 +- .../model/DataObjectSchemaGenerationTest.php | 12 +- tests/model/DataObjectTest.php | 149 +++--- tests/model/ManyManyThroughListTest.php | 33 +- tests/model/VersionedTest.php | 31 +- 24 files changed, 533 insertions(+), 586 deletions(-) diff --git a/Assets/AssetControlExtension.php b/Assets/AssetControlExtension.php index d3e9679c6..026aac0df 100644 --- a/Assets/AssetControlExtension.php +++ b/Assets/AssetControlExtension.php @@ -212,10 +212,11 @@ class AssetControlExtension extends DataExtension { // Search for dbfile instances $files = array(); - foreach ($record->db() as $field => $db) { + $fields = DataObject::getSchema()->fieldSpecs($record); + foreach ($fields as $field => $db) { $fieldObj = $record->$field; - if(!is_object($fieldObj) || !($record->$field instanceof DBFile)) { - continue; + if (!($fieldObj instanceof DBFile)) { + continue; } // Omit variant and merge with set diff --git a/Core/ClassInfo.php b/Core/ClassInfo.php index 0fdfffd40..1a8d68821 100644 --- a/Core/ClassInfo.php +++ b/Core/ClassInfo.php @@ -112,7 +112,7 @@ class ClassInfo { ); foreach ($classes as $class) { - if (DataObject::has_own_table($class)) { + if (DataObject::getSchema()->classHasTable($class)) { $result[$class] = $class; } } @@ -201,7 +201,7 @@ class ClassInfo { if(!isset(self::$_cache_ancestry[$cacheKey])) { $ancestry = array(); do { - if (!$tablesOnly || DataObject::has_own_table($parent)) { + if (!$tablesOnly || DataObject::getSchema()->classHasTable($parent)) { $ancestry[$parent] = $parent; } } while ($parent = get_parent_class($parent)); diff --git a/Dev/CsvBulkLoader.php b/Dev/CsvBulkLoader.php index eaedfb642..2ee3e1eef 100644 --- a/Dev/CsvBulkLoader.php +++ b/Dev/CsvBulkLoader.php @@ -222,7 +222,9 @@ class CsvBulkLoader extends BulkLoader { // find existing object, or create new one $existingObj = $this->findExistingObject($record, $columnMap); + /** @var DataObject $obj */ $obj = ($existingObj) ? $existingObj : new $class(); + $schema = DataObject::getSchema(); // first run: find/create any relations and store them on the object // we can't combine runs, as other columns might rely on the relation being present @@ -243,7 +245,7 @@ class CsvBulkLoader extends BulkLoader { $relationObj = $obj->{$this->relationCallbacks[$fieldName]['callback']}($val, $record); } if(!$relationObj || !$relationObj->exists()) { - $relationClass = $obj->hasOneComponent($relationName); + $relationClass = $schema->hasOneComponent(get_class($obj), $relationName); $relationObj = new $relationClass(); //write if we aren't previewing if (!$preview) $relationObj->write(); @@ -327,7 +329,7 @@ class CsvBulkLoader extends BulkLoader { * * @param array $record CSV data column * @param array $columnMap - * @return mixed + * @return DataObject */ public function findExistingObject($record, $columnMap = []) { $SNG_objectClass = singleton($this->objectClass); diff --git a/Dev/FixtureBlueprint.php b/Dev/FixtureBlueprint.php index 2630af7a8..5392ed789 100644 --- a/Dev/FixtureBlueprint.php +++ b/Dev/FixtureBlueprint.php @@ -87,6 +87,7 @@ class FixtureBlueprint { try { $class = $this->class; + $schema = DataObject::getSchema(); $obj = DataModel::inst()->$class->newObject(); // If an ID is explicitly passed, then we'll sort out the initial write straight away @@ -120,11 +121,10 @@ class FixtureBlueprint { // Populate overrides if($data) foreach($data as $fieldName => $fieldVal) { - // Defer relationship processing if( - $obj->manyManyComponent($fieldName) - || $obj->hasManyComponent($fieldName) - || $obj->hasOneComponent($fieldName) + $schema->manyManyComponent($class, $fieldName) + || $schema->hasManyComponent($class, $fieldName) + || $schema->hasOneComponent($class, $fieldName) ) { continue; } @@ -142,8 +142,8 @@ class FixtureBlueprint { // Populate all relations if($data) foreach($data as $fieldName => $fieldVal) { - $isManyMany = $obj->manyManyComponent($fieldName); - $isHasMany = $obj->hasManyComponent($fieldName); + $isManyMany = $schema->manyManyComponent($class, $fieldName); + $isHasMany = $schema->hasManyComponent($class, $fieldName); if ($isManyMany && $isHasMany) { throw new InvalidArgumentException("$fieldName is both many_many and has_many"); } @@ -207,7 +207,7 @@ class FixtureBlueprint { } } else { $hasOneField = preg_replace('/ID$/', '', $fieldName); - if($className = $obj->hasOneComponent($hasOneField)) { + if($className = $schema->hasOneComponent($class, $hasOneField)) { $obj->{$hasOneField.'ID'} = $this->parseValue($fieldVal, $fixtures, $fieldClass); // Inject class for polymorphic relation if($className === 'SilverStripe\\ORM\\DataObject') { diff --git a/Forms/FileField.php b/Forms/FileField.php index 139a6559a..0f2e2e9c8 100644 --- a/Forms/FileField.php +++ b/Forms/FileField.php @@ -123,8 +123,8 @@ class FileField extends FormField { /** @var File $file */ if($this->relationAutoSetting) { // assume that the file is connected via a has-one - $objectClass = $record->hasOneComponent($this->name); - if($objectClass === 'SilverStripe\\Assets\\File' || empty($objectClass)) { + $objectClass = DataObject::getSchema()->hasOneComponent(get_class($record), $this->name); + if($objectClass === File::class || empty($objectClass)) { // Create object of the appropriate file class $file = Object::create($fileClass); } else { diff --git a/Forms/GridField/GridFieldSortableHeader.php b/Forms/GridField/GridFieldSortableHeader.php index 1c273709a..2f2c99a98 100644 --- a/Forms/GridField/GridFieldSortableHeader.php +++ b/Forms/GridField/GridFieldSortableHeader.php @@ -114,6 +114,7 @@ class GridFieldSortableHeader implements GridField_HTMLProvider, GridField_DataM $columns = $gridField->getColumns(); $currentColumn = 0; + $schema = DataObject::getSchema(); foreach($columns as $columnField) { $currentColumn++; $metadata = $gridField->getColumnMetadata($columnField); @@ -139,7 +140,7 @@ class GridFieldSortableHeader implements GridField_HTMLProvider, GridField_DataM } elseif(method_exists($tmpItem, 'hasMethod') && $tmpItem->hasMethod($methodName)) { // The part is a relation name, so get the object/list from it $tmpItem = $tmpItem->$methodName(); - } elseif($tmpItem instanceof DataObject && $tmpItem->hasDatabaseField($methodName)) { + } elseif ($tmpItem instanceof DataObject && $schema->fieldSpec($tmpItem, $methodName, ['dbOnly'])) { // Else, if we've found a database field at the end of the chain, we can sort on it. // If a method is applied further to this field (E.g. 'Cost.Currency') then don't try to sort. $allowSort = $idx === sizeof($parts) - 1; diff --git a/Forms/UploadField.php b/Forms/UploadField.php index a3cee06f0..0db64cb21 100644 --- a/Forms/UploadField.php +++ b/Forms/UploadField.php @@ -543,7 +543,7 @@ class UploadField extends FileField { if($relation && ($relation instanceof RelationList || $relation instanceof UnsavedRelationList)) { // has_many or many_many $relation->setByIDList($idList); - } elseif($record->hasOneComponent($fieldname)) { + } elseif(DataObject::getSchema()->hasOneComponent(get_class($record), $fieldname)) { // has_one $record->{"{$fieldname}ID"} = $idList ? reset($idList) : 0; } @@ -631,7 +631,7 @@ class UploadField extends FileField { if(empty($allowedMaxFileNumber)) { $record = $this->getRecord(); $name = $this->getName(); - if($record && $record->hasOneComponent($name)) { + if($record && DataObject::getSchema()->hasOneComponent(get_class($record), $name)) { return 1; // Default for has_one } else { return null; // Default for has_many and many_many diff --git a/ORM/DataObject.php b/ORM/DataObject.php index 7f533d0d9..793e10d69 100644 --- a/ORM/DataObject.php +++ b/ORM/DataObject.php @@ -219,10 +219,16 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity /** * Static caches used by relevant functions. + * + * @var array */ - protected static $_cache_has_own_table = array(); protected static $_cache_get_one; - protected static $_cache_get_class_ancestry; + + /** + * Cache of field labels + * + * @var array + */ protected static $_cache_field_labels = array(); /** @@ -277,88 +283,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DataObjectSchema */ public static function getSchema() { - return Injector::inst()->get('SilverStripe\ORM\DataObjectSchema'); - } - - /** - * Return the complete map of fields to specification on this object, including fixed_fields. - * "ID" will be included on every table. - * - * Composite DB field specifications are returned by reference if necessary, but not in the return - * array. - * - * Can be called directly on an object. E.g. Member::database_fields() - * - * @param string $class Class name to query from - * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}. - */ - public static function database_fields($class = null) { - if(empty($class)) { - $class = get_called_class(); - } - return static::getSchema()->databaseFields($class); - } - - /** - * Get all database columns explicitly defined on a class in {@link DataObject::$db} - * and {@link DataObject::$has_one}. Resolves instances of {@link DBComposite} - * into the actual database fields, rather than the name of the field which - * might not equate a database column. - * - * Does not include "base fields" like "ID", "ClassName", "Created", "LastEdited", - * see {@link database_fields()}. - * - * Can be called directly on an object. E.g. Member::custom_database_fields() - * - * @uses DBComposite->compositeDatabaseFields() - * - * @param string $class Class name to query from - * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}. - */ - public static function custom_database_fields($class = null) { - if(empty($class)) { - $class = get_called_class(); - } - - // Remove fixed fields. This assumes that NO fixed_fields are composite - $fields = static::getSchema()->databaseFields($class); - $fields = array_diff_key($fields, self::config()->fixed_fields); - return $fields; - } - - /** - * Returns the field class if the given db field on the class is a composite field. - * Will check all applicable ancestor classes and aggregate results. - * - * @param string $class Class to check - * @param string $name Field to check - * @param boolean $aggregated True if parent classes should be checked, or false to limit to this class - * @return string|false Class spec name of composite field if it exists, or false if not - */ - public static function is_composite_field($class, $name, $aggregated = true) { - $fields = self::composite_fields($class, $aggregated); - return isset($fields[$name]) ? $fields[$name] : false; - } - - /** - * Returns a list of all the composite if the given db field on the class is a composite field. - * Will check all applicable ancestor classes and aggregate results. - * - * Can be called directly on an object. E.g. Member::composite_fields(), or Member::composite_fields(null, true) - * to aggregate. - * - * Includes composite has_one (Polymorphic) fields - * - * @param string $class Name of class to check - * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table - * @return array List of composite fields and their class spec - */ - public static function composite_fields($class = null, $aggregated = true) { - // Check $class - if(empty($class)) { - $class = get_called_class(); - } - return static::getSchema()->compositeFields($class, $aggregated); + return Injector::inst()->get(DataObjectSchema::class); } /** @@ -381,8 +306,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!$record) { $record = array( 'ID' => 0, - 'ClassName' => get_class($this), - 'RecordClassName' => get_class($this) + 'ClassName' => static::class, + 'RecordClassName' => static::class ); } @@ -417,13 +342,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Identify fields that should be lazy loaded, but only on existing records if(!empty($record['ID'])) { - $currentObj = get_class($this); - while($currentObj != 'SilverStripe\ORM\DataObject') { - $fields = self::custom_database_fields($currentObj); - foreach($fields as $field => $type) { - if(!array_key_exists($field, $record)) $this->record[$field.'_Lazy'] = $currentObj; + // Get all field specs scoped to class for later lazy loading + $fields = static::getSchema()->fieldSpecs(static::class, ['includeClass', 'dbOnly']); + foreach($fields as $field => $fieldSpec) { + $fieldClass = strtok($fieldSpec, "."); + if(!array_key_exists($field, $record)) { + $this->record[$field.'_Lazy'] = $fieldClass; } - $currentObj = get_parent_class($currentObj); } } @@ -478,7 +403,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function duplicate($doWrite = true) { /** @var static $clone */ - $clone = Injector::inst()->create(get_class($this), $this->toMap(), false, $this->model ); + $clone = Injector::inst()->create(static::class, $this->toMap(), false, $this->model ); $clone->ID = 0; $clone->invokeWithExtensions('onBeforeDuplicate', $this, $doWrite); @@ -563,7 +488,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function getClassName() { $className = $this->getField("ClassName"); if (!ClassInfo::exists($className)) { - return get_class($this); + return static::class; } return $className; } @@ -580,7 +505,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function setClassName($className) { $className = trim($className); - if(!$className || !is_subclass_of($className, __CLASS__)) { + if(!$className || !is_subclass_of($className, self::class)) { return $this; } @@ -607,7 +532,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DataObject The new instance of the new class, The exact type will be of the class name provided. */ public function newClassInstance($newClassName) { - if (!is_subclass_of($newClassName, __CLASS__)) { + if (!is_subclass_of($newClassName, self::class)) { throw new InvalidArgumentException("$newClassName is not a valid subclass of DataObject"); } @@ -645,7 +570,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } - if($this->class == 'SilverStripe\ORM\DataObject') return; + if(static::class === self::class) { + return; + } // Set up accessors for joined items if($manyMany = $this->manyMany()) { @@ -813,8 +740,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return string */ public function getTitle() { - if($this->hasDatabaseField('Title')) return $this->getField('Title'); - if($this->hasDatabaseField('Name')) return $this->getField('Name'); + $schema = static::getSchema(); + if($schema->fieldSpec($this, 'Title')) { + return $this->getField('Title'); + } + if($schema->fieldSpec($this, 'Name')) { + return $this->getField('Name'); + } return "#{$this->ID}"; } @@ -966,8 +898,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } // makes sure we don't merge data like ID or ClassName - $rightData = $rightObj->db(); - + $rightData = DataObject::getSchema()->fieldSpecs(get_class($rightObj)); foreach($rightData as $key=>$rightSpec) { // Don't merge ID if($key === 'ID') { @@ -1032,11 +963,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function forceChange() { // Ensure lazy fields loaded $this->loadLazyFields(); + $fields = static::getSchema()->fieldSpecs(static::class); // $this->record might not contain the blank values so we loop on $this->inheritedDatabaseFields() as well $fieldNames = array_unique(array_merge( array_keys($this->record), - array_keys($this->db()) + array_keys($fields) )); foreach($fieldNames as $fieldName) { @@ -1046,7 +978,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } // @todo Find better way to allow versioned to write a new version after forceChange - if($this->isChanged('Version')) unset($this->changed['Version']); + if($this->isChanged('Version')) { + unset($this->changed['Version']); + } return $this; } @@ -1154,13 +1088,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $this->$fieldName = $fieldValue; } // Set many-many defaults with an array of ids - if(is_array($fieldValue) && $this->manyManyComponent($fieldName)) { + if(is_array($fieldValue) && $this->getSchema()->manyManyComponent(static::class, $fieldName)) { /** @var ManyManyList $manyManyJoin */ $manyManyJoin = $this->$fieldName(); $manyManyJoin->setByIDList($fieldValue); } } - if($class == 'SilverStripe\ORM\DataObject') { + if($class == self::class) { break; } } @@ -1183,7 +1117,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity ); } - if(Config::inst()->get('SilverStripe\ORM\DataObject', 'validation_enabled')) { + if($this->config()->get('validation_enabled')) { $result = $this->validate(); if (!$result->valid()) { return new ValidationException( @@ -1246,22 +1180,24 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @param string $class Class of table to manipulate */ protected function prepareManipulationTable($baseTable, $now, $isNewRecord, &$manipulation, $class) { - $table = $this->getSchema()->tableName($class); + $schema = $this->getSchema(); + $table = $schema->tableName($class); $manipulation[$table] = array(); // Extract records for this table foreach($this->record as $fieldName => $fieldValue) { - // Check if this record pertains to this table, and // we're not attempting to reset the BaseTable->ID - if( empty($this->changed[$fieldName]) - || ($table === $baseTable && $fieldName === 'ID') - || (!self::has_own_table_database_field($class, $fieldName) - && !self::is_composite_field($class, $fieldName, false)) - ) { + // Ignore unchanged fields or attempts to reset the BaseTable->ID + if (empty($this->changed[$fieldName]) || ($table === $baseTable && $fieldName === 'ID')) { continue; } + // Ensure this field pertains to this table + $specification = $schema->fieldSpec($class, $fieldName, ['dbOnly', 'uninherited']); + if (!$specification) { + continue; + } // if database column doesn't correlate to a DBField instance... $fieldObj = $this->dbObject($fieldName); @@ -1323,10 +1259,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity protected function writeManipulation($baseTable, $now, $isNewRecord) { // Generate database manipulations for each class $manipulation = array(); - foreach($this->getClassAncestry() as $class) { - if(self::has_own_table($class)) { - $this->prepareManipulationTable($baseTable, $now, $isNewRecord, $manipulation, $class); - } + foreach(ClassInfo::ancestry(static::class, true) as $class) { + $this->prepareManipulationTable($baseTable, $now, $isNewRecord, $manipulation, $class); } // Allow extensions to extend this manipulation @@ -1501,7 +1435,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return array Class ancestry */ public function getClassAncestry() { - return ClassInfo::ancestry(get_class($this)); + return ClassInfo::ancestry(static::class); } /** @@ -1518,12 +1452,13 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $this->components[$componentName]; } - if($class = $this->hasOneComponent($componentName)) { + $schema = static::getSchema(); + if($class = $schema->hasOneComponent(static::class, $componentName)) { $joinField = $componentName . 'ID'; $joinID = $this->getField($joinField); // Extract class name for polymorphic relations - if($class === __CLASS__) { + if($class === self::class) { $class = $this->getField($componentName . 'Class'); if(empty($class)) return null; } @@ -1539,8 +1474,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(empty($component)) { $component = $this->model->$class->newObject(); } - } elseif($class = $this->belongsToComponent($componentName)) { - $joinField = $this->getRemoteJoinField($componentName, 'belongs_to', $polymorphic); + } elseif($class = $schema->belongsToComponent(static::class, $componentName)) { + $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'belongs_to', $polymorphic); $joinID = $this->ID; if($joinID) { @@ -1591,7 +1526,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function getComponents($componentName) { $result = null; - $componentClass = $this->hasManyComponent($componentName); + $schema = $this->getSchema(); + $componentClass = $schema->hasManyComponent(static::class, $componentName); if(!$componentClass) { throw new InvalidArgumentException(sprintf( "DataObject::getComponents(): Unknown 1-to-many component '%s' on class '%s'", @@ -1610,7 +1546,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } // Determine type and nature of foreign relation - $joinField = $this->getRemoteJoinField($componentName, 'has_many', $polymorphic); + $joinField = $schema->getRemoteJoinField(static::class, $componentName, 'has_many', $polymorphic); /** @var HasManyList $result */ if($polymorphic) { $result = PolymorphicHasManyList::create($componentClass, $joinField, $this->class); @@ -1635,7 +1571,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function getRelationClass($relationName) { // Parse many_many - $manyManyComponent = $this->manyManyComponent($relationName); + $manyManyComponent = $this->getSchema()->manyManyComponent(static::class, $relationName); if ($manyManyComponent) { list( $relationClass, $parentClass, $componentClass, @@ -1702,6 +1638,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function inferReciprocalComponent($remoteClass, $remoteRelation) { $remote = DataObject::singleton($remoteClass); $class = $remote->getRelationClass($remoteRelation); + $schema = static::getSchema(); // Validate arguments if(!$this->isInDB()) { @@ -1715,7 +1652,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $remoteRelation )); } - if($class === 'SilverStripe\ORM\DataObject') { + if($class === self::class) { throw new InvalidArgumentException(sprintf( "%s cannot generate opposite component of relation %s.%s as it is polymorphic. " . "This method does not support polymorphic relationships", @@ -1727,7 +1664,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(!is_a($this, $class, true)) { throw new InvalidArgumentException(sprintf( "Relation %s on %s does not refer to objects of type %s", - $remoteRelation, $remoteClass, get_class($this) + $remoteRelation, $remoteClass, static::class )); } @@ -1737,7 +1674,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity case 'has_one': { // Mock has_many $joinField = "{$remoteRelation}ID"; - $componentClass = static::getSchema()->classForField($remoteClass, $joinField); + $componentClass = $schema->classForField($remoteClass, $joinField); $result = HasManyList::create($componentClass, $joinField); if ($this->model) { $result->setDataModel($this->model); @@ -1749,7 +1686,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity case 'belongs_to': case 'has_many': { // These relations must have a has_one on the other end, so find it - $joinField = $remote->getRemoteJoinField($remoteRelation, $relationType, $polymorphic); + $joinField = $schema->getRemoteJoinField($remoteClass, $remoteRelation, $relationType, $polymorphic); if ($polymorphic) { throw new InvalidArgumentException(sprintf( "%s cannot generate opposite component of relation %s.%s, as the other end appears" . @@ -1773,8 +1710,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity case 'belongs_many_many': { // Get components and extra fields from parent list($relationClass, $componentClass, $parentClass, $componentField, $parentField, $table) - = $remote->manyManyComponent($remoteRelation); - $extraFields = $remote->manyManyExtraFieldsForComponent($remoteRelation) ?: array(); + = $remote->getSchema()->manyManyComponent($remoteClass, $remoteRelation); + $extraFields = $schema->manyManyExtraFieldsForComponent($remoteClass, $remoteRelation) ?: array(); // Reverse parent and component fields and create an inverse ManyManyList /** @var RelationList $result */ @@ -1798,33 +1735,14 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } - /** - * Tries to find the database key on another object that is used to store a - * relationship to this class. If no join field can be found it defaults to 'ParentID'. - * - * If the remote field is polymorphic then $polymorphic is set to true, and the return value - * is in the form 'Relation' instead of 'RelationID', referencing the composite DBField. - * - * @param string $component 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($component, $type = 'has_many', &$polymorphic = false) { - 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 RelationList|UnsavedRelationList The set of components */ public function getManyManyComponents($componentName) { - $manyManyComponent = $this->manyManyComponent($componentName); + $schema = static::getSchema(); + $manyManyComponent = $schema->manyManyComponent(static::class, $componentName); if(!$manyManyComponent) { throw new InvalidArgumentException(sprintf( "DataObject::getComponents(): Unknown many-to-many component '%s' on class '%s'", @@ -1845,7 +1763,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $this->unsavedRelations[$componentName]; } - $extraFields = $this->manyManyExtraFieldsForComponent($componentName) ?: array(); + $extraFields = $schema->manyManyExtraFieldsForComponent(static::class, $componentName) ?: array(); /** @var RelationList $result */ $result = Injector::inst()->create( $relationClass, @@ -1887,15 +1805,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return (array)Config::inst()->get($this->class, 'has_one', Config::INHERITED); } - /** - * Return data for a specific has_one component. - * @param string $component - * @return string|null - */ - public function hasOneComponent($component) { - 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. @@ -1913,35 +1822,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } - /** - * Return data for a specific belongs_to component. - * @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($component, $classOnly = true) { - return $this->getSchema()->belongsToComponent(get_class($this), $component, $classOnly); - } - - /** - * Return all of the database fields in this object - * - * @param string $fieldName Limit the output to a specific field name - * @param bool $includeClass If returning a single column, prefix the column with the class name - * in Table.Column(spec) format - * @return array|string|null The database fields, or if searching a single field, - * just this one field if found. Field will be a string in FieldClass(args) - * format, or RecordClass.FieldClass(args) format if $includeClass is true - */ - public function db($fieldName = null, $includeClass = false) { - if ($fieldName) { - return static::getSchema()->fieldSpecification(static::class, $fieldName, $includeClass); - } else { - return static::getSchema()->fieldSpecifications(static::class); - } - } - /** * 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. @@ -1959,17 +1839,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } - /** - * Return data for a specific has_many component. - * @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($component, $classOnly = true) { - return $this->getSchema()->hasManyComponent(get_class($this), $component, $classOnly); - } - /** * Return the many-to-many extra fields specification. * @@ -1982,58 +1851,12 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return Config::inst()->get($this->class, 'many_many_extraFields', Config::INHERITED); } - /** - * Return the many-to-many extra fields specification for a specific component. - * @param string $component - * @return array|null - */ - public function manyManyExtraFieldsForComponent($component) { - // Get all many_many_extraFields defined in this class or parent classes - $extraFields = (array)Config::inst()->get($this->class, 'many_many_extraFields', Config::INHERITED); - // Extra fields are immediately available - if(isset($extraFields[$component])) { - return $extraFields[$component]; - } - - // Check this class' belongs_many_manys to see if any of their reverse associations contain extra fields - $manyMany = (array)Config::inst()->get($this->class, 'belongs_many_many', Config::INHERITED); - $candidate = (isset($manyMany[$component])) ? $manyMany[$component] : null; - if($candidate) { - $relationName = null; - // Extract class and relation name from dot-notation - if(strpos($candidate, '.') !== false) { - list($candidate, $relationName) = explode('.', $candidate, 2); - } - - // If we've not already found the relation name from dot notation, we need to find a relation that points - // back to this class. As there's no dot-notation, there can only be one relation pointing to this class, - // so it's safe to assume that it's the correct one - if(!$relationName) { - $candidateManyManys = (array)Config::inst()->get($candidate, 'many_many', Config::UNINHERITED); - - foreach($candidateManyManys as $relation => $relatedClass) { - if (is_a($this, $relatedClass)) { - $relationName = $relation; - } - } - } - - // If we've found a matching relation on the target class, see if we can find extra fields for it - $extraFields = (array)Config::inst()->get($candidate, 'many_many_extraFields', Config::UNINHERITED); - if(isset($extraFields[$relationName])) { - return $extraFields[$relationName]; - } - } - - return isset($items) ? $items : null; - } - /** * Return information about a many-to-many component. * The return value is an array of (parentclass, childclass). If $component is null, then all many-many * components are returned. * - * @see DataObject::manyManyComponent() + * @see DataObjectSchema::manyManyComponent() * @return array|null An array of (parentclass, childclass), or an array of all many-many components */ public function manyMany() { @@ -2043,30 +1866,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity return $items; } - /** - * 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 - * ManyManyList or ManyManyThroughList. - * - * 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 $component The component name - * @return array|null - */ - public function manyManyComponent($component) { - return $this->getSchema()->manyManyComponent(get_class($this), $component); - } - /** * This returns an array (if it exists) describing the database extensions that are required, or false if none * @@ -2284,7 +2083,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * Called by {@link __get()} and any getFieldName() methods you might create. * * @param string $field The name of the field - * * @return mixed The field value */ public function getField($field) { @@ -2300,7 +2098,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } // In case of complex fields, return the DBField object - if(self::is_composite_field($this->class, $field)) { + if (static::getSchema()->compositeField(static::class, $field)) { $this->record[$field] = $this->dbObject($field); } @@ -2343,7 +2141,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Limit query to the current record, unless it has the Versioned extension, // in which case it requires special handling through augmentLoadLazyFields() - $baseIDColumn = static::getSchema()->sqlColumnForField($this, 'ID'); + $schema = static::getSchema(); + $baseIDColumn = $schema->sqlColumnForField($this, 'ID'); $dataQuery->where([ $baseIDColumn => $this->record['ID'] ])->limit(1); @@ -2352,8 +2151,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Add SQL for fields, both simple & multi-value // TODO: This is copy & pasted from buildSQL(), it could be moved into a method - $databaseFields = self::database_fields($class); - if($databaseFields) foreach($databaseFields as $k => $v) { + $databaseFields = $schema->databaseFields($class, false); + foreach($databaseFields as $k => $v) { if(!isset($this->record[$k]) || $this->record[$k] === null) { $columns[] = $k; } @@ -2426,7 +2225,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if(is_array($databaseFieldsOnly)) { $fields = array_intersect_key((array)$this->changed, array_flip($databaseFieldsOnly)); } elseif($databaseFieldsOnly) { - $fields = array_intersect_key((array)$this->changed, $this->db()); + $fieldsSpecs = static::getSchema()->fieldSpecs(static::class); + $fields = array_intersect_key((array)$this->changed, $fieldsSpecs); } else { $fields = $this->changed; } @@ -2505,8 +2305,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // Situation 2: Passing a literal or non-DBField object } else { // If this is a proper database field, we shouldn't be getting non-DBField objects - if(is_object($val) && $this->db($fieldName)) { - user_error('DataObject::setField: passed an object that is not a DBField', E_USER_WARNING); + if(is_object($val) && static::getSchema()->fieldSpec(static::class, $fieldName)) { + throw new InvalidArgumentException('DataObject::setField: passed an object that is not a DBField'); } // if a field is not existing or has strictly changed @@ -2516,9 +2316,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity // At the very least, the type has changed $this->changed[$fieldName] = self::CHANGE_STRICT; - if((!isset($this->record[$fieldName]) && $val) || (isset($this->record[$fieldName]) - && $this->record[$fieldName] != $val)) { - + if ((!isset($this->record[$fieldName]) && $val) + || (isset($this->record[$fieldName]) && $this->record[$fieldName] != $val) + ) { // Value has changed as well, not just the type $this->changed[$fieldName] = self::CHANGE_VALUE; } @@ -2558,7 +2358,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * {@inheritdoc} */ public function castingHelper($field) { - if ($fieldSpec = $this->db($field)) { + $fieldSpec = static::getSchema()->fieldSpec(static::class, $field); + if ($fieldSpec) { return $fieldSpec; } @@ -2585,10 +2386,11 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return boolean True if the given field exists */ public function hasField($field) { + $schema = static::getSchema(); return ( array_key_exists($field, $this->record) - || $this->db($field) - || (substr($field,-2) == 'ID') && $this->hasOneComponent(substr($field,0, -2)) + || $schema->fieldSpec(static::class, $field) + || (substr($field,-2) == 'ID') && $schema->hasOneComponent(static::class, substr($field,0, -2)) || $this->hasMethod("get{$field}") ); } @@ -2601,57 +2403,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return boolean */ public function hasDatabaseField($field) { - return $this->db($field) - && ! self::is_composite_field(get_class($this), $field); - } - - /** - * Returns the field type of the given field, if it belongs to this class, and not a parent. - * Note that the field type will not include constructor arguments in round brackets, only the classname. - * - * @param string $field Name of the field - * @return string The field type of the given field - */ - public function hasOwnTableDatabaseField($field) { - return self::has_own_table_database_field($this->class, $field); - } - - /** - * Returns the field type of the given field, if it belongs to this class, and not a parent. - * Note that the field type will not include constructor arguments in round brackets, only the classname. - * - * @param string $class Class name to check - * @param string $field Name of the field - * @return string The field type of the given field - */ - public static function has_own_table_database_field($class, $field) { - $fieldMap = self::database_fields($class); - - // Remove string-based "constructor-arguments" from the DBField definition - if(isset($fieldMap[$field])) { - $spec = $fieldMap[$field]; - if(is_string($spec)) { - return strtok($spec,'('); - } else { - return $spec['type']; - } - } - return null; - } - - /** - * Returns true if given class has its own table. Uses the rules for whether the table should exist rather than - * actually looking in the database. - * - * @param string $dataClass - * @return bool - */ - public static function has_own_table($dataClass) { - if(!is_subclass_of($dataClass, 'SilverStripe\ORM\DataObject')) { - return false; - } - $fields = static::database_fields($dataClass); - return !empty($fields); + $spec = static::getSchema()->fieldSpec(static::class, $field, ['dbOnly']); + return !empty($spec); } /** @@ -2671,7 +2424,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } if(Permission::checkMember($member, "ADMIN")) return true; - if($this->manyManyComponent('Can' . $perm)) { + if($this->getSchema()->manyManyComponent(static::class, 'Can' . $perm)) { if($this->ParentID && $this->SecurityType == 'Inherit') { if(!($p = $this->Parent)) { return false; @@ -2834,8 +2587,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function dbObject($fieldName) { // Check for field in DB - $helper = $this->db($fieldName, true); - + $helper = static::getSchema()->fieldSpec(static::class, $fieldName, ['includeClass']); if(!$helper) { return null; } @@ -2983,15 +2735,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DataList The objects matching the filter, in the class specified by $containerClass */ public static function get($callerClass = null, $filter = "", $sort = "", $join = "", $limit = null, - $containerClass = 'SilverStripe\ORM\DataList') { + $containerClass = DataList::class) { if($callerClass == null) { $callerClass = get_called_class(); - if($callerClass == 'SilverStripe\ORM\DataObject') { + if ($callerClass == self::class) { throw new \InvalidArgumentException('Call ::get() instead of DataObject::get()'); } - if($filter || $sort || $join || $limit || ($containerClass != 'SilverStripe\ORM\DataList')) { + if($filter || $sort || $join || $limit || ($containerClass != DataList::class)) { throw new \InvalidArgumentException('If calling ::get() then you shouldn\'t pass any other' . ' arguments'); } @@ -3070,7 +2822,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity * @return DataObject $this */ public function flushCache($persistent = true) { - if($this->class == __CLASS__) { + if($this->class == self::class) { self::$_cache_get_one = array(); return $this; } @@ -3108,7 +2860,6 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity DBClassName::clear_classname_cache(); ClassInfo::reset_db_cache(); static::getSchema()->reset(); - self::$_cache_has_own_table = array(); self::$_cache_get_one = array(); self::$_cache_field_labels = array(); } @@ -3231,7 +2982,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity } } - if(get_parent_class($this) == 'SilverStripe\ORM\DataObject') { + if(get_parent_class($this) == self::class) { $indexes['ClassName'] = true; } @@ -3245,14 +2996,15 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function requireTable() { // Only build the table if we've actually got fields - $fields = self::database_fields($this->class); - $table = static::getSchema()->tableName($this->class); - $extensions = self::database_extensions($this->class); + $schema = static::getSchema(); + $fields = $schema->databaseFields(static::class, false); + $table = $schema->tableName(static::class); + $extensions = self::database_extensions(static::class); $indexes = $this->databaseIndexes(); if($fields) { - $hasAutoIncPK = get_parent_class($this) === __CLASS__; + $hasAutoIncPK = get_parent_class($this) === self::class; DB::require_table( $table, $fields, $indexes, $hasAutoIncPK, $this->stat('create_table_options'), $extensions ); @@ -3265,7 +3017,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $extras = $this->uninherited('many_many_extraFields'); foreach($manyMany as $component => $spec) { // Get many_many spec - $manyManyComponent = $this->getSchema()->manyManyComponent(get_class($this), $component); + $manyManyComponent = $schema->manyManyComponent(static::class, $component); list( $relationClass, $parentClass, $componentClass, $parentField, $childField, $tableOrClass @@ -3343,6 +3095,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $fields = array(); // remove the custom getters as the search should not include them + $schema = static::getSchema(); if($summaryFields) { foreach($summaryFields as $key => $name) { $spec = $name; @@ -3352,9 +3105,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $name = substr($name, 0, $fieldPos); } - if($this->hasDatabaseField($name)) { + if ($schema->fieldSpec($this, $name)) { $fields[] = $name; - } elseif($this->relObject($spec)) { + } elseif ($this->relObject($spec)) { $fields[] = $spec; } } @@ -3441,7 +3194,9 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity $ancestry = ClassInfo::ancestry($this->class); $ancestry = array_reverse($ancestry); if($ancestry) foreach($ancestry as $ancestorClass) { - if($ancestorClass == 'SilverStripe\\View\\ViewableData') break; + if($ancestorClass === ViewableData::class) { + break; + } $types = array( 'db' => (array)Config::inst()->get($ancestorClass, 'db', Config::UNINHERITED) ); @@ -3500,10 +3255,18 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity if (!$fields) { $fields = array(); // try to scaffold a couple of usual suspects - if ($this->hasField('Name')) $fields['Name'] = 'Name'; - if ($this->hasDatabaseField('Title')) $fields['Title'] = 'Title'; - if ($this->hasField('Description')) $fields['Description'] = 'Description'; - if ($this->hasField('FirstName')) $fields['FirstName'] = 'First Name'; + if ($this->hasField('Name')) { + $fields['Name'] = 'Name'; + } + if (static::getSchema()->fieldSpec($this, 'Title')) { + $fields['Title'] = 'Title'; + } + if ($this->hasField('Description')) { + $fields['Description'] = 'Description'; + } + if ($this->hasField('FirstName')) { + $fields['FirstName'] = 'First Name'; + } } $this->extend("updateSummaryFields", $fields); @@ -3825,7 +3588,8 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity */ public function hasValue($field, $arguments = null, $cache = true) { // has_one fields should not use dbObject to check if a value is given - if(!$this->hasOneComponent($field) && ($obj = $this->dbObject($field))) { + $hasOne = static::getSchema()->hasOneComponent(static::class, $field); + if(!$hasOne && ($obj = $this->dbObject($field))) { return $obj->exists(); } else { return parent::hasValue($field, $arguments, $cache); @@ -3851,7 +3615,7 @@ class DataObject extends ViewableData implements DataObjectInterface, i18nEntity public function setJoin(DataObject $object, $alias = null) { $this->joinRecord = $object; if ($alias) { - if ($this->db($alias)) { + if (static::getSchema()->fieldSpec(static::class, $alias)) { throw new InvalidArgumentException( "Joined record $alias cannot also be a db field" ); diff --git a/ORM/DataObjectSchema.php b/ORM/DataObjectSchema.php index 5c758e3c4..f5396f66f 100644 --- a/ORM/DataObjectSchema.php +++ b/ORM/DataObjectSchema.php @@ -140,17 +140,37 @@ class DataObjectSchema { /** * Get all DB field specifications for a class, including ancestors and composite fields. * - * @param string $class + * @param string|DataObject $classOrInstance + * @param array $options Array of options. Specify any number of the below: + * - `uninherited`: Set to true to limit to only this table + * - `dbOnly`: Exclude virtual fields (such as composite fields), and only include fields with a db column. + * - `includeClass`: If true prefix the field specification with the class name in RecordClass.Column(spec) format. * @return array */ - public function fieldSpecifications($class) { - $classes = ClassInfo::ancestry($class, true); + public function fieldSpecs($classOrInstance, $options = []) { + $class = ClassInfo::class_name($classOrInstance); + $uninherited = !empty($options['uninherited']) || in_array('uninherited', $options); + $dbOnly = !empty($options['dbOnly']) || in_array('dbOnly', $options); + $includeClass = !empty($options['includeClass']) || in_array('includeClass', $options); + + // Walk class hierarchy $db = []; + $classes = $uninherited ? [$class] : ClassInfo::ancestry($class); foreach($classes as $tableClass) { - // Merge fields with new fields and composite fields - $fields = $this->databaseFields($tableClass); - $compositeFields = $this->compositeFields($tableClass, false); - $db = array_merge($db, $fields, $compositeFields); + // Find all fields on this class + $fields = $this->databaseFields($tableClass, false); + + // Merge with composite fields + if (!$dbOnly) { + $compositeFields = $this->compositeFields($tableClass, false); + $fields = array_merge($fields, $compositeFields); + } + + // Record specification + foreach ($fields as $name => $specification) { + $prefix = $includeClass ? "{$tableClass}." : ""; + $db[$name] = $prefix . $specification; + } } return $db; } @@ -159,30 +179,18 @@ class DataObjectSchema { /** * Get specifications for a single class field * - * @param string $class + * @param string|DataObject $classOrInstance Name or instance of class * @param string $fieldName - * @param bool $includeClass If returning a single column, prefix the column with the class name - * in RecordClass.Column(spec) format + * @param array $options Array of options. Specify any number of the below: + * - `uninherited`: Set to true to limit to only this table + * - `dbOnly`: Exclude virtual fields (such as composite fields), and only include fields with a db column. + * - `includeClass`: If true prefix the field specification with the class name in RecordClass.Column(spec) format. * @return string|null Field will be a string in FieldClass(args) format, or * RecordClass.FieldClass(args) format if $includeClass is true. Will be null if no field is found. */ - public function fieldSpecification($class, $fieldName, $includeClass = false) { - $classes = array_reverse(ClassInfo::ancestry($class, true)); - foreach($classes as $tableClass) { - // Merge fields with new fields and composite fields - $fields = $this->databaseFields($tableClass); - $compositeFields = $this->compositeFields($tableClass, false); - $db = array_merge($fields, $compositeFields); - - // Check for search field - if(isset($db[$fieldName])) { - $prefix = $includeClass ? "{$tableClass}." : ""; - return $prefix . $db[$fieldName]; - } - } - - // At end of search complete - return null; + public function fieldSpec($classOrInstance, $fieldName, $options = []) { + $specs = $this->fieldSpecs($classOrInstance, $options); + return isset($specs[$fieldName]) ? $specs[$fieldName] : null; } /** @@ -249,7 +257,7 @@ class DataObjectSchema { // Generate default table name if(!$table) { - $separator = $this->config()->table_namespace_separator; + $separator = $this->config()->get('table_namespace_separator'); $table = str_replace('\\', $separator, trim($class, '\\')); } @@ -261,15 +269,48 @@ class DataObjectSchema { * "ID" will be included on every table. * * @param string $class Class name to query from + * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table * @return array Map of fieldname to specification, similiar to {@link DataObject::$db}. */ - public function databaseFields($class) { + public function databaseFields($class, $aggregated = true) { $class = ClassInfo::class_name($class); if($class === DataObject::class) { return []; } $this->cacheDatabaseFields($class); - return $this->databaseFields[$class]; + $fields = $this->databaseFields[$class]; + + if (!$aggregated) { + return $fields; + } + + // Recursively merge + $parentFields = $this->databaseFields(get_parent_class($class)); + return array_merge($fields, array_diff_key($parentFields, $fields)); + } + + /** + * Gets a single database field. + * + * @param string $class Class name to query from + * @param string $field Field name + * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table + * @return string|null Field specification, or null if not a field + */ + public function databaseField($class, $field, $aggregated = true) { + $fields = $this->databaseFields($class, $aggregated); + return isset($fields[$field]) ? $fields[$field] : null; + } + + /** + * Check if the given class has a table + * + * @param string $class + * @return bool + */ + public function classHasTable($class) { + $fields = $this->databaseFields($class, false); + return !empty($fields); } /** @@ -300,7 +341,20 @@ class DataObjectSchema { // Recursively merge $parentFields = $this->compositeFields(get_parent_class($class)); - return array_merge($compositeFields, $parentFields); + return array_merge($compositeFields, array_diff_key($parentFields, $compositeFields)); + } + + /** + * Get a composite field for a class + * + * @param string $class Class name to query from + * @param string $field Field name + * @param bool $aggregated Include fields in entire hierarchy, rather than just on this table + * @return string|null Field specification, or null if not a field + */ + public function compositeField($class, $field, $aggregated = true) { + $fields = $this->compositeFields($class, $aggregated); + return isset($fields[$field]) ? $fields[$field] : null; } /** @@ -318,7 +372,7 @@ class DataObjectSchema { $dbFields = array(); // Ensure fixed fields appear at the start - $fixedFields = DataObject::config()->fixed_fields; + $fixedFields = DataObject::config()->get('fixed_fields'); if(get_parent_class($class) === DataObject::class) { // Merge fixed with ClassName spec and custom db fields $dbFields = $fixedFields; @@ -357,7 +411,7 @@ class DataObjectSchema { } } - // Prevent field-less tables + // Prevent field-less tables with only 'ID' if(count($dbFields) < 2) { $dbFields = []; } @@ -401,14 +455,14 @@ class DataObjectSchema { } // Short circuit for fixed fields - $fixed = DataObject::config()->fixed_fields; + $fixed = DataObject::config()->get('fixed_fields'); if(isset($fixed[$fieldName])) { return $this->baseDataClass($candidateClass); } // Find regular field while($candidateClass) { - $fields = $this->databaseFields($candidateClass); + $fields = $this->databaseFields($candidateClass, false); if(isset($fields[$fieldName])) { return $candidateClass; } @@ -425,7 +479,7 @@ class DataObjectSchema { * Standard many_many return type is: * * array( - * , Name of class for relation + * , Name of class for relation. E.g. "Categories" * , 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". @@ -449,35 +503,85 @@ class DataObjectSchema { // 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])) { + 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})"); - } + list($childClass, $relationName) + = $this->parseBelongsManyManyComponent($parentClass, $component, $belongsManyMany[$component]); // Build inverse relationship from other many_many, and swap parent/child list($relationClass, $childClass, $parentClass, $childField, $parentField, $joinTable) - = $this->parseManyManyComponent($childClass, $relationName, $parentClass); + = $this->manyManyComponent($childClass, $relationName); return [$relationClass, $parentClass, $childClass, $parentField, $childField, $joinTable]; } return null; } + + + /** + * Parse a belongs_many_many component to extract class and relationship name + * + * @param string $parentClass Name of class + * @param string $component Name of relation on class + * @param string $specification specification for this belongs_many_many + * @return array Array with child class and relation name + */ + protected function parseBelongsManyManyComponent($parentClass, $component, $specification) + { + $childClass = $specification; + $relationName = null; + if (strpos($specification, '.') !== false) { + list($childClass, $relationName) = explode('.', $specification, 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( + "belongs_many_many relation {$parentClass}.{$component} points to " + . "{$specification} without matching many_many" + ); + } + + // Return relatios + return array($childClass, $relationName); + } + + /** + * Return the many-to-many extra fields specification for a specific component. + * + * @param string $class + * @param string $component + * @return array|null + */ + public function manyManyExtraFieldsForComponent($class, $component) { + // Get directly declared many_many_extraFields + $extraFields = Config::inst()->get($class, 'many_many_extraFields'); + if (isset($extraFields[$component])) { + return $extraFields[$component]; + } + + // If not belongs_many_many then there are no components + while ($class && ($class !== DataObject::class)) { + $belongsManyMany = Config::inst()->get($class, 'belongs_many_many', Config::UNINHERITED); + if (isset($belongsManyMany[$component])) { + // Reverse relationship and find extrafields from child class + list($childClass, $relationName) = $this->parseBelongsManyManyComponent($class, $component, + $belongsManyMany[$component]); + return $this->manyManyExtraFieldsForComponent($childClass, $relationName); + } + $class = get_parent_class($class); + } + return null; + } + /** * Return data for a specific has_many component. * @@ -739,7 +843,7 @@ class DataObjectSchema { // Validate the join class isn't also the name of a field or relation on either side // of the relation - $field = $this->fieldSpecification($relationClass, $joinClass); + $field = $this->fieldSpec($relationClass, $joinClass); if ($field) { throw new InvalidArgumentException( "many_many through relation {$parentClass}.{$component} {$key} class {$relationClass} " diff --git a/ORM/DataQuery.php b/ORM/DataQuery.php index 5aa0aed4d..0c5949180 100644 --- a/ORM/DataQuery.php +++ b/ORM/DataQuery.php @@ -240,7 +240,7 @@ class DataQuery { $selectColumns = null; if ($queriedColumns) { // Restrict queried columns to that on the selected table - $tableFields = DataObject::database_fields($tableClass); + $tableFields = $schema->databaseFields($tableClass, false); unset($tableFields['ID']); $selectColumns = array_intersect($queriedColumns, array_keys($tableFields)); } @@ -508,14 +508,15 @@ class DataQuery { */ protected function selectColumnsFromTable(SQLSelect &$query, $tableClass, $columns = null) { // Add SQL for multi-value fields - $databaseFields = DataObject::database_fields($tableClass); - $compositeFields = DataObject::composite_fields($tableClass, false); + $schema = DataObject::getSchema(); + $databaseFields = $schema->databaseFields($tableClass, false); + $compositeFields = $schema->compositeFields($tableClass, false); unset($databaseFields['ID']); foreach($databaseFields as $k => $v) { if((is_null($columns) || in_array($k, $columns)) && !isset($compositeFields[$k])) { // Update $collidingFields if necessary $expressionForField = $query->expressionForField($k); - $quotedField = DataObject::getSchema()->sqlColumnForField($tableClass, $k); + $quotedField = $schema->sqlColumnForField($tableClass, $k); if($expressionForField) { if(!isset($this->collidingFields[$k])) { $this->collidingFields[$k] = array($expressionForField); @@ -528,7 +529,7 @@ class DataQuery { } foreach($compositeFields as $k => $v) { if((is_null($columns) || in_array($k, $columns)) && $v) { - $tableName = DataObject::getSchema()->tableName($tableClass); + $tableName = $schema->tableName($tableClass); $dbO = Object::create_from_string($v, $k); $dbO->setTable($tableName); $dbO->addToQuery($query); @@ -727,14 +728,14 @@ class DataQuery { $modelClass = $this->dataClass; + $schema = DataObject::getSchema(); foreach($relation as $rel) { - $model = singleton($modelClass); - if ($component = $model->hasOneComponent($rel)) { + if ($component = $schema->hasOneComponent($modelClass, $rel)) { // Join via has_one $this->joinHasOneRelation($modelClass, $rel, $component); $modelClass = $component; - } elseif ($component = $model->hasManyComponent($rel)) { + } elseif ($component = $schema->hasManyComponent($modelClass, $rel)) { // Fail on non-linear relations if($linearOnly) { throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass"); @@ -743,7 +744,7 @@ class DataQuery { $this->joinHasManyRelation($modelClass, $rel, $component); $modelClass = $component; - } elseif ($component = $model->manyManyComponent($rel)) { + } elseif ($component = $schema->manyManyComponent($modelClass, $rel)) { // Fail on non-linear relations if($linearOnly) { throw new InvalidArgumentException("$rel is not a linear relation on model $modelClass"); @@ -833,8 +834,7 @@ class DataQuery { // Join table with associated has_one /** @var DataObject $model */ - $model = singleton($localClass); - $foreignKey = $model->getRemoteJoinField($localField, 'has_many', $polymorphic); + $foreignKey = $schema->getRemoteJoinField($localClass, $localField, 'has_many', $polymorphic); $localIDColumn = $schema->sqlColumnForField($localClass, 'ID'); if($polymorphic) { $foreignKeyIDColumn = $schema->sqlColumnForField($foreignClass, "{$foreignKey}ID"); diff --git a/ORM/DatabaseAdmin.php b/ORM/DatabaseAdmin.php index db27a9a06..fd5e3574f 100644 --- a/ORM/DatabaseAdmin.php +++ b/ORM/DatabaseAdmin.php @@ -336,42 +336,46 @@ class DatabaseAdmin extends Controller { * corresponding records in their parent class tables. */ public function cleanup() { - $allClasses = get_declared_classes(); $baseClasses = []; - foreach($allClasses as $class) { - if(get_parent_class($class) == 'SilverStripe\ORM\DataObject') { + foreach(ClassInfo::subclassesFor(DataObject::class) as $class) { + if(get_parent_class($class) == DataObject::class) { $baseClasses[] = $class; } } + $schema = DataObject::getSchema(); foreach($baseClasses as $baseClass) { // Get data classes + $baseTable = $schema->baseDataTable($baseClass); $subclasses = ClassInfo::subclassesFor($baseClass); unset($subclasses[0]); foreach($subclasses as $k => $subclass) { - if(DataObject::has_own_table($subclass)) { + if(!DataObject::getSchema()->classHasTable($subclass)) { unset($subclasses[$k]); } } if($subclasses) { - $records = DB::query("SELECT * FROM \"$baseClass\""); + $records = DB::query("SELECT * FROM \"$baseTable\""); foreach($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); $recordExists[$subclass] = - DB::query("SELECT \"ID\" FROM \"$subclass\"")->keyedColumn(); + DB::query("SELECT \"ID\" FROM \"$subclassTable\"")->keyedColumn(); } foreach($records as $record) { foreach($subclasses as $subclass) { + $subclassTable = $schema->tableName($subclass); $id = $record['ID']; - if(($record['ClassName'] != $subclass) && - (!is_subclass_of($record['ClassName'], $subclass)) && - (isset($recordExists[$subclass][$id]))) { - $sql = "DELETE FROM \"$subclass\" WHERE \"ID\" = $record[ID]"; - echo "
  • $sql"; - DB::query($sql); + if (($record['ClassName'] != $subclass) + && (!is_subclass_of($record['ClassName'], $subclass)) + && isset($recordExists[$subclass][$id]) + ) { + $sql = "DELETE FROM \"$subclassTable\" WHERE \"ID\" = ?"; + echo "
  • $sql [{$id}]
  • "; + DB::prepared_query($sql, [$id]); } } } diff --git a/ORM/FieldType/DBForeignKey.php b/ORM/FieldType/DBForeignKey.php index 62ce353ad..d88439ae9 100644 --- a/ORM/FieldType/DBForeignKey.php +++ b/ORM/FieldType/DBForeignKey.php @@ -39,7 +39,7 @@ class DBForeignKey extends DBInt { return null; } $relationName = substr($this->name,0,-2); - $hasOneClass = $this->object->hasOneComponent($relationName); + $hasOneClass = DataObject::getSchema()->hasOneComponent(get_class($this->object), $relationName); if(empty($hasOneClass)) { return null; } diff --git a/ORM/Hierarchy/Hierarchy.php b/ORM/Hierarchy/Hierarchy.php index 623df11eb..076e534f4 100644 --- a/ORM/Hierarchy/Hierarchy.php +++ b/ORM/Hierarchy/Hierarchy.php @@ -736,7 +736,7 @@ class Hierarchy extends DataExtension { if ($hide_from_cms_tree && $this->showingCMSTree()) { $staged = $staged->exclude('ClassName', $hide_from_cms_tree); } - if (!$showAll && $this->owner->db('ShowInMenus')) { + if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { $staged = $staged->filter('ShowInMenus', 1); } $this->owner->extend("augmentStageChildren", $staged, $showAll); @@ -753,7 +753,7 @@ class Hierarchy extends DataExtension { * @throws Exception */ public function liveChildren($showAll = false, $onlyDeletedFromStage = false) { - if(!$this->owner->hasExtension('SilverStripe\ORM\Versioning\Versioned')) { + if(!$this->owner->hasExtension(Versioned::class)) { throw new Exception('Hierarchy->liveChildren() only works with Versioned extension applied'); } @@ -773,7 +773,9 @@ class Hierarchy extends DataExtension { if ($hide_from_cms_tree && $this->showingCMSTree()) { $children = $children->exclude('ClassName', $hide_from_cms_tree); } - if(!$showAll && $this->owner->db('ShowInMenus')) $children = $children->filter('ShowInMenus', 1); + if(!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { + $children = $children->filter('ShowInMenus', 1); + } return $children; } diff --git a/ORM/ManyManyThroughQueryManipulator.php b/ORM/ManyManyThroughQueryManipulator.php index 073000350..b22bc19f3 100644 --- a/ORM/ManyManyThroughQueryManipulator.php +++ b/ORM/ManyManyThroughQueryManipulator.php @@ -153,7 +153,7 @@ class ManyManyThroughQueryManipulator implements DataQueryManipulator * @return string */ public function getJoinAlias() { - return $this->getJoinClass(); + return DataObject::getSchema()->tableName($this->getJoinClass()); } /** diff --git a/ORM/Versioning/Versioned.php b/ORM/Versioning/Versioned.php index 36989305e..cf00180fa 100644 --- a/ORM/Versioning/Versioned.php +++ b/ORM/Versioning/Versioned.php @@ -575,8 +575,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { public function augmentDatabase() { $owner = $this->owner; $class = get_class($owner); + $schema = $owner->getSchema(); $baseTable = $this->baseTable(); - $classTable = $owner->getSchema()->tableName($owner); + $classTable = $schema->tableName($owner); $isRootClass = $class === $owner->baseClass(); @@ -606,10 +607,10 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $suffixTable = $classTable; } - $fields = DataObject::database_fields($owner->class); + $fields = $schema->databaseFields($class, false); unset($fields['ID']); if($fields) { - $options = Config::inst()->get($owner->class, 'create_table_options', Config::FIRST_SET); + $options = Config::inst()->get($class, 'create_table_options', Config::FIRST_SET); $indexes = $owner->databaseIndexes(); $extensionClass = $allSuffixes[$suffix]; if ($suffix && ($extension = $owner->getExtensionInstance($extensionClass))) { @@ -760,8 +761,9 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { * @param int $recordID ID of record to version */ protected function augmentWriteVersioned(&$manipulation, $class, $table, $recordID) { - $baseDataClass = DataObject::getSchema()->baseDataClass($class); - $baseDataTable = DataObject::getSchema()->tableName($baseDataClass); + $schema = DataObject::getSchema(); + $baseDataClass = $schema->baseDataClass($class); + $baseDataTable = $schema->tableName($baseDataClass); // Set up a new entry in (table)_versions $newManipulation = array( @@ -774,8 +776,7 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { $data = DB::prepared_query("SELECT * FROM \"{$table}\" WHERE \"ID\" = ?", array($recordID))->record(); if ($data) { - $fields = DataObject::database_fields($class); - + $fields = $schema->databaseFields($class, false); if (is_array($fields)) { $data = array_intersect_key($data, $fields); @@ -1383,8 +1384,8 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { */ public function canBeVersioned($class) { return ClassInfo::exists($class) - && is_subclass_of($class, 'SilverStripe\ORM\DataObject') - && DataObject::has_own_table($class); + && is_subclass_of($class, DataObject::class) + && DataObject::getSchema()->classHasTable($class); } /** @@ -1514,11 +1515,12 @@ class Versioned extends DataExtension implements TemplateGlobalProvider { return; } + $schema = DataObject::getSchema(); $ownedHasMany = array_intersect($owns, array_keys($hasMany)); foreach($ownedHasMany as $relationship) { // Find metadata on relationship - $joinClass = $owner->hasManyComponent($relationship); - $joinField = $owner->getRemoteJoinField($relationship, 'has_many', $polymorphic); + $joinClass = $schema->hasManyComponent(get_class($owner), $relationship); + $joinField = $schema->getRemoteJoinField(get_class($owner), $relationship, 'has_many', $polymorphic); $idField = $polymorphic ? "{$joinField}ID" : $joinField; $joinTable = DataObject::getSchema()->tableForField($joinClass, $idField); diff --git a/Security/PermissionCheckboxSetField.php b/Security/PermissionCheckboxSetField.php index d92aae2b8..018757ab2 100644 --- a/Security/PermissionCheckboxSetField.php +++ b/Security/PermissionCheckboxSetField.php @@ -4,6 +4,7 @@ namespace SilverStripe\Security; use SilverStripe\Core\Config\Config; use SilverStripe\Forms\FormField; +use SilverStripe\ORM\DataObject; use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataObjectInterface; @@ -281,7 +282,11 @@ class PermissionCheckboxSetField extends FormField { $permission->delete(); } - if($fieldname && $record && ($record->hasManyComponent($fieldname) || $record->manyManyComponent($fieldname))) { + $schema = DataObject::getSchema(); + if($fieldname && $record && ( + $schema->hasManyComponent(get_class($record), $fieldname) + || $schema->manyManyComponent(get_class($record), $fieldname) + )) { if(!$record->ID) $record->write(); // We need a record ID to write permissions diff --git a/Security/Security.php b/Security/Security.php index 526c78e33..d22ccbc34 100644 --- a/Security/Security.php +++ b/Security/Security.php @@ -12,6 +12,7 @@ use SilverStripe\Core\Convert; use SilverStripe\Core\ClassInfo; use SilverStripe\Core\Injector\Injector; use SilverStripe\Dev\Deprecation; +use SilverStripe\Dev\TestOnly; use SilverStripe\Forms\EmailField; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; @@ -1009,18 +1010,18 @@ class Security extends Controller implements TemplateGlobalProvider { return self::$database_is_ready; } - $requiredClasses = ClassInfo::dataClassesFor('SilverStripe\\Security\\Member'); - $requiredClasses[] = 'SilverStripe\\Security\\Group'; - $requiredClasses[] = 'SilverStripe\\Security\\Permission'; - + $requiredClasses = ClassInfo::dataClassesFor(Member::class); + $requiredClasses[] = Group::class; + $requiredClasses[] = Permission::class; + $schema = DataObject::getSchema(); foreach($requiredClasses as $class) { // Skip test classes, as not all test classes are scaffolded at once - if(is_subclass_of($class, 'SilverStripe\\Dev\\TestOnly')) { + if(is_a($class, TestOnly::class, true)) { continue; } // if any of the tables aren't created in the database - $table = DataObject::getSchema()->tableName($class); + $table = $schema->tableName($class); if(!ClassInfo::hasTable($table)) { return false; } @@ -1035,7 +1036,7 @@ class Security extends Controller implements TemplateGlobalProvider { return false; } - $objFields = DataObject::database_fields($class); + $objFields = $schema->databaseFields($class, false); $missingFields = array_diff_key($objFields, $dbFields); if($missingFields) { diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index ed91dc987..1cf671c46 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -859,7 +859,6 @@ A very small number of methods were chosen for deprecation, and will be removed 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. * `DataObject::ClassName` field has been refactored into a `DBClassName` type field. * `DataObject::can` has new method signature with `$context` parameter. @@ -910,7 +909,24 @@ A very small number of methods were chosen for deprecation, and will be removed #### ORM Removed API +* `DataObject::db` removed and replaced with `DataObjectSchema::fieldSpec` and `DataObjectSchema::fieldSpecs` +* `DataObject::manyManyComponent` moved to `DataObjectSchema` +* `DataObject::belongsToComponent` moved to `DataObjectSchema` +* `DataObject::hasOneComponent` moved to `DataObjectSchema` +* `DataObject::hasManyComponent` moved to `DataObjectSchema` +* `DataObject::getRemoteJoinField` moved to `DataObjectSchema` +* `DataObject::database_fields` renamed and moved to `DataObjectSchema::databaseFields` +* `DataObject::has_own_table` renamed and moved to `DataObjectSchema::classHasTable` +* `DataObject::composite_fields` renamed and moved to `DataObjectSchema::compositeFields`` +* `DataObject::manyManyExtraFieldsForComponent` moved to `DataObjectSchema` * Removed `DataObject::validateModelDefinitions`. Relations are now validated within `DataObjectSchema` +* Removed `DataObject` methods `hasOwnTableDatabaseField`, `has_own_table_database_field` and + `hasDatabaseFields` are superceded by `DataObjectSchema::fieldSpec`. + Use `$schema->fieldSpec($class, $field, ['dbOnly', 'uninherited'])`. + Exclude `uninherited` option to search all tables in the class hierarchy. +* Removed `DataObject::is_composite_field`. Use `DataObjectSchema::compositeField` instead. +* Removed `DataObject::custom_database_fields`. Use `DataObjectSchema::databaseFields` + or `DataObjectSchema::fieldSpecs` instead. * 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 diff --git a/tests/forms/GridFieldTest.php b/tests/forms/GridFieldTest.php index 43fdef5c8..feaf49594 100644 --- a/tests/forms/GridFieldTest.php +++ b/tests/forms/GridFieldTest.php @@ -26,12 +26,15 @@ use SilverStripe\Forms\GridField\GridField_ActionProvider; use SilverStripe\Forms\GridField\GridField_DataManipulator; use SilverStripe\Forms\GridField\GridField_HTMLProvider; - - - - class GridFieldTest extends SapphireTest { + protected $extraDataObjects = [ + GridFieldTest_Permissions::class, + GridFieldTest_Cheerleader::class, + GridFieldTest_Player::class, + GridFieldTest_Team::class, + ]; + /** * @covers SilverStripe\Forms\GridField\GridField::__construct */ diff --git a/tests/model/DBCompositeTest.php b/tests/model/DBCompositeTest.php index c2c1eea83..736173b2f 100644 --- a/tests/model/DBCompositeTest.php +++ b/tests/model/DBCompositeTest.php @@ -48,28 +48,29 @@ class DBCompositeTest extends SapphireTest { * Test DataObject::composite_fields() and DataObject::is_composite_field() */ public function testCompositeFieldMetaDataFunctions() { - $this->assertEquals('Money', DataObject::is_composite_field('DBCompositeTest_DataObject', 'MyMoney')); - $this->assertFalse(DataObject::is_composite_field('DBCompositeTest_DataObject', 'Title')); + $schema = DataObject::getSchema(); + $this->assertEquals('Money', $schema->compositeField(DBCompositeTest_DataObject::class, 'MyMoney')); + $this->assertNull($schema->compositeField(DBCompositeTest_DataObject::class, 'Title')); $this->assertEquals( array( 'MyMoney' => 'Money', 'OverriddenMoney' => 'Money' ), - DataObject::composite_fields('DBCompositeTest_DataObject') + $schema->compositeFields(DBCompositeTest_DataObject::class) ); - $this->assertEquals('Money', DataObject::is_composite_field('SubclassedDBFieldObject', 'MyMoney')); - $this->assertEquals('Money', DataObject::is_composite_field('SubclassedDBFieldObject', 'OtherMoney')); - $this->assertFalse(DataObject::is_composite_field('SubclassedDBFieldObject', 'Title')); - $this->assertFalse(DataObject::is_composite_field('SubclassedDBFieldObject', 'OtherField')); + $this->assertEquals('Money', $schema->compositeField(SubclassedDBFieldObject::class, 'MyMoney')); + $this->assertEquals('Money', $schema->compositeField(SubclassedDBFieldObject::class, 'OtherMoney')); + $this->assertNull($schema->compositeField(SubclassedDBFieldObject::class, 'Title')); + $this->assertNull($schema->compositeField(SubclassedDBFieldObject::class, 'OtherField')); $this->assertEquals( array( 'MyMoney' => 'Money', 'OtherMoney' => 'Money', 'OverriddenMoney' => 'Money', ), - DataObject::composite_fields('SubclassedDBFieldObject') + $schema->compositeFields(SubclassedDBFieldObject::class) ); } diff --git a/tests/model/DataObjectSchemaGenerationTest.php b/tests/model/DataObjectSchemaGenerationTest.php index 4b1c4a246..8c0a3439a 100644 --- a/tests/model/DataObjectSchemaGenerationTest.php +++ b/tests/model/DataObjectSchemaGenerationTest.php @@ -139,11 +139,12 @@ class DataObjectSchemaGenerationTest extends SapphireTest { * by the order of classnames of existing records */ public function testClassNameSpecGeneration() { + $schema = DataObject::getSchema(); // Test with blank entries DBClassName::clear_classname_cache(); $do1 = new DataObjectSchemaGenerationTest_DO(); - $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); + $fields = $schema->databaseFields(DataObjectSchemaGenerationTest_DO::class, false); /** @skipUpgrade */ $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( @@ -159,9 +160,6 @@ class DataObjectSchemaGenerationTest extends SapphireTest { $item1 = new DataObjectSchemaGenerationTest_IndexDO(); $item1->write(); DBClassName::clear_classname_cache(); - $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); - /** @skipUpgrade */ - $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( array( 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', @@ -175,9 +173,6 @@ class DataObjectSchemaGenerationTest extends SapphireTest { $item2 = new DataObjectSchemaGenerationTest_DO(); $item2->write(); DBClassName::clear_classname_cache(); - $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); - /** @skipUpgrade */ - $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( array( 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', @@ -193,9 +188,6 @@ class DataObjectSchemaGenerationTest extends SapphireTest { $item2 = new DataObjectSchemaGenerationTest_DO(); $item2->write(); DBClassName::clear_classname_cache(); - $fields = DataObject::database_fields('DataObjectSchemaGenerationTest_DO'); - /** @skipUpgrade */ - $this->assertEquals("DBClassName", $fields['ClassName']); $this->assertEquals( array( 'DataObjectSchemaGenerationTest_DO' => 'DataObjectSchemaGenerationTest_DO', diff --git a/tests/model/DataObjectTest.php b/tests/model/DataObjectTest.php index 89d865d34..96dcf4398 100644 --- a/tests/model/DataObjectTest.php +++ b/tests/model/DataObjectTest.php @@ -12,6 +12,7 @@ use SilverStripe\ORM\DataExtension; use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\ValidationResult; use SilverStripe\Security\Member; +use SilverStripe\View\ViewableData; /** * @package framework @@ -60,8 +61,8 @@ class DataObjectTest extends SapphireTest { } public function testDb() { - $obj = new DataObjectTest_TeamComment(); - $dbFields = $obj->db(); + $schema = DataObject::getSchema(); + $dbFields = $schema->fieldSpecs(DataObjectTest_TeamComment::class); // Assert fields are included $this->assertArrayHasKey('Name', $dbFields); @@ -73,15 +74,20 @@ class DataObjectTest extends SapphireTest { $this->assertArrayHasKey('ID', $dbFields); // Assert that the correct field type is returned when passing a field - $this->assertEquals('Varchar', $obj->db('Name')); - $this->assertEquals('Text', $obj->db('Comment')); + $this->assertEquals('Varchar', $schema->fieldSpec(DataObjectTest_TeamComment::class, 'Name')); + $this->assertEquals('Text', $schema->fieldSpec(DataObjectTest_TeamComment::class, 'Comment')); // Test with table required - $this->assertEquals('DataObjectTest_TeamComment.Varchar', $obj->db('Name', true)); - $this->assertEquals('DataObjectTest_TeamComment.Text', $obj->db('Comment', true)); - + $this->assertEquals( + 'DataObjectTest_TeamComment.Varchar', + $schema->fieldSpec(DataObjectTest_TeamComment::class, 'Name', ['includeClass']) + ); + $this->assertEquals( + 'DataObjectTest_TeamComment.Text', + $schema->fieldSpec(DataObjectTest_TeamComment::class, 'Comment', ['includeClass']) + ); $obj = new DataObjectTest_ExtendedTeamComment(); - $dbFields = $obj->db(); + $dbFields = $schema->fieldSpecs(DataObjectTest_ExtendedTeamComment::class); // fixed fields are still included in extended classes $this->assertArrayHasKey('Created', $dbFields); @@ -90,7 +96,7 @@ class DataObjectTest extends SapphireTest { $this->assertArrayHasKey('ID', $dbFields); // Assert overloaded fields have correct data type - $this->assertEquals('HTMLText', $obj->db('Comment')); + $this->assertEquals('HTMLText', $schema->fieldSpec(DataObjectTest_ExtendedTeamComment::class, 'Comment')); $this->assertEquals('HTMLText', $dbFields['Comment'], 'Calls to DataObject::db without a field specified return correct data types'); @@ -786,7 +792,7 @@ class DataObjectTest extends SapphireTest { $teamSingleton = singleton('DataObjectTest_Team'); $subteamInstance = $this->objFromFixture('DataObjectTest_SubTeam', 'subteam1'); - $subteamSingleton = singleton('DataObjectTest_SubTeam'); + $schema = DataObject::getSchema(); /* hasField() singleton checks */ $this->assertTrue($teamSingleton->hasField('ID'), @@ -837,35 +843,35 @@ class DataObjectTest extends SapphireTest { /* hasDatabaseField() singleton checks */ //$this->assertTrue($teamSingleton->hasDatabaseField('ID'), //'hasDatabaseField() finds built-in fields in singletons'); - $this->assertTrue($teamSingleton->hasDatabaseField('Title'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'Title'), 'hasDatabaseField() finds custom fields in singletons'); /* hasDatabaseField() instance checks */ - $this->assertFalse($teamInstance->hasDatabaseField('NonExistingField'), + $this->assertNull($schema->fieldSpec(DataObjectTest_Team::class, 'NonExistingField'), 'hasDatabaseField() doesnt find non-existing fields in instances'); - //$this->assertTrue($teamInstance->hasDatabaseField('ID'), + //$this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'ID'), //'hasDatabaseField() finds built-in fields in instances'); - $this->assertTrue($teamInstance->hasDatabaseField('Created'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'Created'), 'hasDatabaseField() finds built-in fields in instances'); - $this->assertTrue($teamInstance->hasDatabaseField('DatabaseField'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'DatabaseField'), 'hasDatabaseField() finds custom fields in instances'); - $this->assertFalse($teamInstance->hasDatabaseField('SubclassDatabaseField'), + $this->assertNull($schema->fieldSpec(DataObjectTest_Team::class, 'SubclassDatabaseField'), 'hasDatabaseField() doesnt find subclass fields in parentclass instances'); - //$this->assertFalse($teamInstance->hasDatabaseField('DynamicField'), + //$this->assertNull($schema->fieldSpec(DataObjectTest_Team::class, 'DynamicField'), //'hasDatabaseField() doesnt dynamic getters in instances'); - $this->assertTrue($teamInstance->hasDatabaseField('HasOneRelationshipID'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'HasOneRelationshipID'), 'hasDatabaseField() finds foreign keys in instances'); - $this->assertTrue($teamInstance->hasDatabaseField('ExtendedDatabaseField'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'ExtendedDatabaseField'), 'hasDatabaseField() finds extended fields in instances'); - $this->assertTrue($teamInstance->hasDatabaseField('ExtendedHasOneRelationshipID'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_Team::class, 'ExtendedHasOneRelationshipID'), 'hasDatabaseField() finds extended foreign keys in instances'); - $this->assertFalse($teamInstance->hasDatabaseField('ExtendedDynamicField'), + $this->assertNull($schema->fieldSpec(DataObjectTest_Team::class, 'ExtendedDynamicField'), 'hasDatabaseField() doesnt include extended dynamic getters in instances'); /* hasDatabaseField() subclass checks */ - $this->assertTrue($subteamInstance->hasDatabaseField('DatabaseField'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_SubTeam::class, 'DatabaseField'), 'hasField() finds custom fields in subclass instances'); - $this->assertTrue($subteamInstance->hasDatabaseField('SubclassDatabaseField'), + $this->assertNotEmpty($schema->fieldSpec(DataObjectTest_SubTeam::class, 'SubclassDatabaseField'), 'hasField() finds custom fields in subclass instances'); } @@ -874,9 +880,10 @@ class DataObjectTest extends SapphireTest { * @todo Re-enable all test cases for field inheritance aggregation after behaviour has been fixed */ public function testFieldInheritance() { - $teamInstance = $this->objFromFixture('DataObjectTest_Team', 'team1'); - $subteamInstance = $this->objFromFixture('DataObjectTest_SubTeam', 'subteam1'); + $schema = DataObject::getSchema(); + // Test logical fields (including composite) + $teamSpecifications = $schema->fieldSpecs(DataObjectTest_Team::class); $this->assertEquals( array( 'ID', @@ -891,10 +898,11 @@ class DataObjectTest extends SapphireTest { 'HasOneRelationshipID', 'ExtendedHasOneRelationshipID' ), - array_keys($teamInstance->db()), - 'inheritedDatabaseFields() contains all fields defined on instance: base, extended and foreign keys' + array_keys($teamSpecifications), + 'fieldSpecifications() contains all fields defined on instance: base, extended and foreign keys' ); + $teamFields = $schema->databaseFields(DataObjectTest_Team::class, false); $this->assertEquals( array( 'ID', @@ -909,10 +917,11 @@ class DataObjectTest extends SapphireTest { 'HasOneRelationshipID', 'ExtendedHasOneRelationshipID' ), - array_keys(DataObject::database_fields('DataObjectTest_Team', false)), + array_keys($teamFields), 'databaseFields() contains only fields defined on instance, including base, extended and foreign keys' ); + $subteamSpecifications = $schema->fieldSpecs(DataObjectTest_SubTeam::class); $this->assertEquals( array( 'ID', @@ -929,17 +938,18 @@ class DataObjectTest extends SapphireTest { 'SubclassDatabaseField', 'ParentTeamID', ), - array_keys($subteamInstance->db()), - 'inheritedDatabaseFields() on subclass contains all fields, including base, extended and foreign keys' + array_keys($subteamSpecifications), + 'fieldSpecifications() on subclass contains all fields, including base, extended and foreign keys' ); + $subteamFields = $schema->databaseFields(DataObjectTest_SubTeam::class, false); $this->assertEquals( array( 'ID', 'SubclassDatabaseField', 'ParentTeamID', ), - array_keys(DataObject::database_fields('DataObjectTest_SubTeam')), + array_keys($subteamFields), 'databaseFields() on subclass contains only fields defined on instance' ); } @@ -1103,22 +1113,26 @@ class DataObjectTest extends SapphireTest { DB::query("SELECT \"ID\" FROM \"DataObjectTest_Team\" WHERE \"Title\" = 'asdfasdf'")->value()); } - public function TestHasOwnTable() { + public function testHasOwnTable() { + $schema = DataObject::getSchema(); /* Test DataObject::has_own_table() returns true if the object has $has_one or $db values */ - $this->assertTrue(DataObject::has_own_table("DataObjectTest_Player")); - $this->assertTrue(DataObject::has_own_table("DataObjectTest_Team")); - $this->assertTrue(DataObject::has_own_table("DataObjectTest_Fixture")); + $this->assertTrue($schema->classHasTable(DataObjectTest_Player::class)); + $this->assertTrue($schema->classHasTable(DataObjectTest_Team::class)); + $this->assertTrue($schema->classHasTable(DataObjectTest_Fixture::class)); /* Root DataObject that always have a table, even if they lack both $db and $has_one */ - $this->assertTrue(DataObject::has_own_table("DataObjectTest_FieldlessTable")); + $this->assertTrue($schema->classHasTable(DataObjectTest_FieldlessTable::class)); /* Subclasses without $db or $has_one don't have a table */ - $this->assertFalse(DataObject::has_own_table("DataObjectTest_FieldlessSubTable")); + $this->assertFalse($schema->classHasTable(DataObjectTest_FieldlessSubTable::class)); /* Return false if you don't pass it a subclass of DataObject */ - $this->assertFalse(DataObject::has_own_table("SilverStripe\\ORM\\DataObject")); - $this->assertFalse(DataObject::has_own_table("SilverStripe\\View\\ViewableData")); - $this->assertFalse(DataObject::has_own_table("ThisIsntADataObject")); + $this->assertFalse($schema->classHasTable(DataObject::class)); + $this->assertFalse($schema->classHasTable(ViewableData::class)); + + // Invalid class + $this->setExpectedException(ReflectionException::class, 'Class ThisIsntADataObject does not exist'); + $this->assertFalse($schema->classHasTable("ThisIsntADataObject")); } public function testMerge() { @@ -1190,22 +1204,19 @@ class DataObjectTest extends SapphireTest { public function testValidateModelDefinitionsFailsWithArray() { Config::inst()->update('DataObjectTest_Team', 'has_one', array('NotValid' => array('NoArraysAllowed'))); $this->setExpectedException(InvalidArgumentException::class); - $object = new DataObjectTest_Team(); - $object->hasOneComponent('NotValid'); + DataObject::getSchema()->hasOneComponent(DataObjectTest_Team::class, 'NotValid'); } public function testValidateModelDefinitionsFailsWithIntKey() { Config::inst()->update('DataObjectTest_Team', 'has_many', array(12 => 'DataObjectTest_Player')); $this->setExpectedException(InvalidArgumentException::class); - $object = new DataObjectTest_Team(); - $object->hasManyComponent(12); + DataObject::getSchema()->hasManyComponent(DataObjectTest_Team::class, 12); } public function testValidateModelDefinitionsFailsWithIntValue() { Config::inst()->update('DataObjectTest_Team', 'many_many', array('Players' => 12)); $this->setExpectedException(InvalidArgumentException::class); - $object = new DataObjectTest_Team(); - $object->manyManyComponent('Players'); + DataObject::getSchema()->manyManyComponent(DataObjectTest_Team::class, 'Players'); } public function testNewClassInstance() { @@ -1241,7 +1252,8 @@ class DataObjectTest extends SapphireTest { $equipmentSuppliers = $team->EquipmentSuppliers(); // Check that DataObject::many_many() works as expected - list($relationClass, $class, $targetClass, $parentField, $childField, $joinTable) = $team->manyManyComponent('Sponsors'); + list($relationClass, $class, $targetClass, $parentField, $childField, $joinTable) + = DataObject::getSchema()->manyManyComponent(DataObjectTest_Team::class, 'Sponsors'); $this->assertEquals(ManyManyList::class, $relationClass); $this->assertEquals('DataObjectTest_Team', $class, 'DataObject::many_many() didn\'t find the correct base class'); @@ -1312,8 +1324,8 @@ class DataObjectTest extends SapphireTest { } public function testManyManyExtraFields() { - $player = $this->objFromFixture('DataObjectTest_Player', 'player1'); $team = $this->objFromFixture('DataObjectTest_Team', 'team1'); + $schema = DataObject::getSchema(); // Get all extra fields $teamExtraFields = $team->manyManyExtraFields(); @@ -1330,13 +1342,13 @@ class DataObjectTest extends SapphireTest { ), $teamExtraFields); // Extra fields are immediately available on the Team class (defined in $many_many_extraFields) - $teamExtraFields = $team->manyManyExtraFieldsForComponent('Players'); + $teamExtraFields = $schema->manyManyExtraFieldsForComponent(DataObjectTest_Team::class, 'Players'); $this->assertEquals($teamExtraFields, array( 'Position' => 'Varchar(100)' )); // We'll have to go through the relation to get the extra fields on Player - $playerExtraFields = $player->manyManyExtraFieldsForComponent('Teams'); + $playerExtraFields = $schema->manyManyExtraFieldsForComponent(DataObjectTest_Player::class, 'Teams'); $this->assertEquals($playerExtraFields, array( 'Position' => 'Varchar(100)' )); @@ -1522,7 +1534,7 @@ class DataObjectTest extends SapphireTest { $this->assertEquals ( 'DataObjectTest_Staff', - $company->hasManyComponent('CurrentStaff'), + DataObject::getSchema()->hasManyComponent(DataObjectTest_Company::class, 'CurrentStaff'), 'has_many strips field name data by default on single relationships.' ); @@ -1537,33 +1549,44 @@ class DataObjectTest extends SapphireTest { $this->assertEquals ( 'DataObjectTest_Staff.CurrentCompany', - $company->hasManyComponent('CurrentStaff', false), + DataObject::getSchema()->hasManyComponent(DataObjectTest_Company::class, 'CurrentStaff', false), 'has_many returns field name data on single records when $classOnly is false.' ); } public function testGetRemoteJoinField() { - $company = new DataObjectTest_Company(); + $schema = DataObject::getSchema(); - $staffJoinField = $company->getRemoteJoinField('CurrentStaff', 'has_many', $polymorphic); + // Company schema + $staffJoinField = $schema->getRemoteJoinField( + DataObjectTest_Company::class, 'CurrentStaff', 'has_many', $polymorphic + ); $this->assertEquals('CurrentCompanyID', $staffJoinField); $this->assertFalse($polymorphic, 'DataObjectTest_Company->CurrentStaff is not polymorphic'); - $previousStaffJoinField = $company->getRemoteJoinField('PreviousStaff', 'has_many', $polymorphic); + $previousStaffJoinField = $schema->getRemoteJoinField( + DataObjectTest_Company::class, 'PreviousStaff', 'has_many', $polymorphic + ); $this->assertEquals('PreviousCompanyID', $previousStaffJoinField); $this->assertFalse($polymorphic, 'DataObjectTest_Company->PreviousStaff is not polymorphic'); - $ceo = new DataObjectTest_CEO(); - - $this->assertEquals('CEOID', $ceo->getRemoteJoinField('Company', 'belongs_to', $polymorphic)); + // CEO Schema + $this->assertEquals('CEOID', $schema->getRemoteJoinField( + DataObjectTest_CEO::class, 'Company', 'belongs_to', $polymorphic + )); $this->assertFalse($polymorphic, 'DataObjectTest_CEO->Company is not polymorphic'); - $this->assertEquals('PreviousCEOID', $ceo->getRemoteJoinField('PreviousCompany', 'belongs_to', $polymorphic)); + $this->assertEquals('PreviousCEOID', $schema->getRemoteJoinField( + DataObjectTest_CEO::class, 'PreviousCompany', 'belongs_to', $polymorphic + )); $this->assertFalse($polymorphic, 'DataObjectTest_CEO->PreviousCompany is not polymorphic'); - $team = new DataObjectTest_Team(); - - $this->assertEquals('Favourite', $team->getRemoteJoinField('Fans', 'has_many', $polymorphic)); + // Team schema + $this->assertEquals('Favourite', $schema->getRemoteJoinField( + DataObjectTest_Team::class, 'Fans', 'has_many', $polymorphic + )); $this->assertTrue($polymorphic, 'DataObjectTest_Team->Fans is polymorphic'); - $this->assertEquals('TeamID', $team->getRemoteJoinField('Comments', 'has_many', $polymorphic)); + $this->assertEquals('TeamID', $schema->getRemoteJoinField( + DataObjectTest_Team::class, 'Comments', 'has_many', $polymorphic + )); $this->assertFalse($polymorphic, 'DataObjectTest_Team->Comments is not polymorphic'); } diff --git a/tests/model/ManyManyThroughListTest.php b/tests/model/ManyManyThroughListTest.php index fb97d3362..eab6323ca 100644 --- a/tests/model/ManyManyThroughListTest.php +++ b/tests/model/ManyManyThroughListTest.php @@ -206,8 +206,37 @@ class ManyManyThroughListTest extends SapphireTest 'ManyManyThroughListTest_JoinObject' => 'Text' ]); $this->setExpectedException(InvalidArgumentException::class); - $object = new ManyManyThroughListTest_Object(); - $object->manyManyComponent('Items'); + DataObject::getSchema()->manyManyComponent(ManyManyThroughListTest_Object::class, 'Items'); + } + + public function testRelationParsing() { + $schema = DataObject::getSchema(); + + // Parent components + $this->assertEquals( + [ + ManyManyThroughList::class, + ManyManyThroughListTest_Object::class, + ManyManyThroughListTest_Item::class, + 'ParentID', + 'ChildID', + ManyManyThroughListTest_JoinObject::class + ], + $schema->manyManyComponent(ManyManyThroughListTest_Object::class, 'Items') + ); + + // Belongs_many_many is the same, but with parent/child substituted + $this->assertEquals( + [ + ManyManyThroughList::class, + ManyManyThroughListTest_Item::class, + ManyManyThroughListTest_Object::class, + 'ChildID', + 'ParentID', + ManyManyThroughListTest_JoinObject::class + ], + $schema->manyManyComponent(ManyManyThroughListTest_Item::class, 'Objects') + ); } } diff --git a/tests/model/VersionedTest.php b/tests/model/VersionedTest.php index fa5c25065..331fe2a01 100644 --- a/tests/model/VersionedTest.php +++ b/tests/model/VersionedTest.php @@ -1,5 +1,6 @@ assertFalse( - (bool) $noversion->hasOwnTableDatabaseField('Version'), + $this->assertNull( + $schema->fieldSpec(DataObject::class, 'Version', ['uninherited']), 'Plain models have no version field.' ); $this->assertEquals( - 'Int', $versioned->hasOwnTableDatabaseField('Version'), + 'Int', + $schema->fieldSpec(VersionedTest_DataObject::class, 'Version', ['uninherited']), 'The versioned ext adds an Int version field.' ); - $this->assertEquals( - null, - $versionedSub->hasOwnTableDatabaseField('Version'), + $this->assertNull( + $schema->fieldSpec(VersionedTest_Subclass::class, 'Version', ['uninherited']), + 'Sub-classes of a versioned model don\'t have a Version field.' + ); + $this->assertNull( + $schema->fieldSpec(VersionedTest_AnotherSubclass::class, 'Version', ['uninherited']), 'Sub-classes of a versioned model don\'t have a Version field.' ); $this->assertEquals( - null, - $versionedAno->hasOwnTableDatabaseField('Version'), - 'Sub-classes of a versioned model don\'t have a Version field.' - ); - $this->assertEquals( - 'Varchar', $versionField->hasOwnTableDatabaseField('Version'), + 'Varchar(255)', + $schema->fieldSpec(VersionedTest_UnversionedWithField::class, 'Version', ['uninherited']), 'Models w/o Versioned can have their own Version field.' ); }